Adressdaten aus dem Swyx-Client; core-Modul und Firefox-Plugin ausgelagert

SwyxTray liest jetzt die Telefonbuecher (global und persoenlich) des
SwyxIt!-Clients in einen Adress-Cache, bietet Kontaktsuche ueber
FulltextSearchInContactsEx und Nummernaufloesung ueber DispResolveNumber;
alles ueber den WebSocket-Zugang abrufbar.

Das Spring-Boot-Modul core/ und das SwyxFFPlugin werden als eigene
Projekte gepflegt und aus diesem Repository entfernt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 15:10:13 +02:00
co-authored by Claude Fable 5
parent cd355c4e21
commit fcd0d62d78
48 changed files with 427 additions and 17014 deletions
+3 -55
View File
@@ -1,62 +1,10 @@
{ {
// Startkonfigurationen aller Teilprojekte: Java-Core, Flutter-App und // Startkonfigurationen fuer die SwyxTray-App (C#/.NET, nur Windows).
// SwyxTray. Die SwyxTray-App hat kein Fenster: sichtbar ist nur das Symbol // Die App hat kein Fenster: sichtbar ist nur das Symbol im Infobereich der
// im Infobereich der Taskleiste, alles Weitere steht im Protokoll unter // Taskleiste, alles Weitere steht im Protokoll unter
// %LOCALAPPDATA%\SwyxTray\swyxtray.log. // %LOCALAPPDATA%\SwyxTray\swyxtray.log.
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"type": "java",
"name": "Spring Boot: Swyx Core",
"request": "launch",
"cwd": "${workspaceFolder}/core",
"mainClass": "de.assecutor.swyx.SwyxCoreApplication",
"projectName": "swyx-core",
"args": [],
"vmArgs": "-Dspring.profiles.active=dev -Dspring.devtools.restart.enabled=true",
"env": {
"SERVER_PORT": "8080"
},
"console": "internalConsole"
},
{
"type": "java",
"name": "Spring Boot: Swyx Core (Production)",
"request": "launch",
"cwd": "${workspaceFolder}/core",
"mainClass": "de.assecutor.swyx.SwyxCoreApplication",
"projectName": "swyx-core",
"args": [],
"vmArgs": "-Dspring.profiles.active=prod -Dvaadin.productionMode=true",
"env": {
"SERVER_PORT": "8080"
},
"console": "internalConsole"
},
{
"type": "dart",
"name": "Flutter: Swyx App (Debug)",
"request": "launch",
"program": "${workspaceFolder}/app/lib/main.dart",
"cwd": "${workspaceFolder}/app",
"flutterMode": "debug"
},
{
"type": "dart",
"name": "Flutter: Swyx App (Profile)",
"request": "launch",
"program": "${workspaceFolder}/app/lib/main.dart",
"cwd": "${workspaceFolder}/app",
"flutterMode": "profile"
},
{
"type": "dart",
"name": "Flutter: Swyx App (Release)",
"request": "launch",
"program": "${workspaceFolder}/app/lib/main.dart",
"cwd": "${workspaceFolder}/app",
"flutterMode": "release"
},
{ {
// Der Standardweg — funktioniert auch, wenn VS Code remote verbunden // Der Standardweg — funktioniert auch, wenn VS Code remote verbunden
// ist: eine geplante Aufgabe startet die App in der Desktop-Sitzung des // ist: eine geplante Aufgabe startet die App in der Desktop-Sitzung des
+48 -22
View File
@@ -26,18 +26,8 @@ laufenden SwyxIt!-Client verbindet.
Browser laufende Seite wählen, annehmen und auflegen kann und im Gegenzug Browser laufende Seite wählen, annehmen und auflegen kann und im Gegenzug
jede Zustandsänderung zugestellt bekommt (siehe unten). jede Zustandsänderung zugestellt bekommt (siehe unten).
- Kontextmenü (Rechtsklick): Status, belegte Leitungen, *WebSocket-Zugang*, - Kontextmenü (Rechtsklick): Status, belegte Leitungen, *WebSocket-Zugang*,
*Lese Tabs*, *Tab öffnen*, *Tab schliessen*, *Neu verbinden*, *Neu verbinden*, *Protokoll öffnen*, *Beenden*. Doppelklick zeigt den
*Protokoll öffnen*, *Beenden*. Doppelklick zeigt den Status als Sprechblase. Status als Sprechblase.
Die drei Tab-Punkte laufen über den Tab-Verwaltungskanal zum Firefox-Plugin
(siehe unten); ohne verbundenes Plugin (oder mit `PluginPort: 0`) bleiben
sie ohne Ergebnis bzw. grau. *Lese Tabs* holt die Titel der offenen Tabs
und zeigt sie als Sprechblase — die vollständige Liste samt Adressen landet
im Protokoll. *Tab öffnen* öffnet einen Tab mit `https://www.assecutor.de`,
*Tab schliessen* schließt alle Tabs, deren Titel „Assecutor Data Service
GmbH" enthält — als Komposition aus `list` und `close` je Treffer, wie beim
`focus`-Kommando (beide Werte sind Konstanten in
[TrayApplicationContext.cs](src/SwyxTray/TrayApplicationContext.cs)).
## Verwendetes SDK ## Verwendetes SDK
@@ -59,8 +49,16 @@ Genutzte Schnittstellen:
- `IClientLineMgrDisp``DispInit`, `DispRegisterUser`, `DispNumberOfLines`, - `IClientLineMgrDisp``DispInit`, `DispRegisterUser`, `DispNumberOfLines`,
`DispGetLine`, `DispSelectedLineNumber`, `DispIsServerUp`, `DispGetLine`, `DispSelectedLineNumber`, `DispIsServerUp`,
`DispGetCurrentUser`, `DispGetCurrentServer`, `DispReleaseUser` `DispGetCurrentUser`, `DispGetCurrentServer`, `DispReleaseUser`,
`FulltextSearchInContactsEx`, `DispResolveNumber`, `DispClientConfig`
- `IClientLineDisp``DispState`, `DispPeerNumber`, `DispPeerName` - `IClientLineDisp``DispState`, `DispPeerNumber`, `DispPeerName`
- `IClientConfig``PbxPhoneBookEnumerator`, `UserPhoneBookEnumerator`
(globales und persönliches Telefonbuch für den Adress-Cache)
- `INameNumberSearchResult(Collection)` — Ergebnistyp der Kontaktsuche.
Die Telefonbuch-Sammlungen und ihre Einträge kommen dagegen **nicht** als
`IDispCollection`/`IPbxPhoneBookEntryDisp` an, sondern als reine
IDispatch-Objekte — sie werden per `IEnumerable` und Late-Binding gelesen
(siehe „Stand der Prüfung")
- `IClientLineMgrEventsPub_Event``PubOnLineMgrNotification(msg, param)` - `IClientLineMgrEventsPub_Event``PubOnLineMgrNotification(msg, param)`
- `CLMgrLineStates` — die Leitungszustände als typisierte Aufzählung - `CLMgrLineStates` — die Leitungszustände als typisierte Aufzählung
@@ -83,7 +81,6 @@ Genutzte Schnittstellen:
| [Web/Protocol.cs](src/SwyxTray/Web/Protocol.cs) | die JSON-Nachrichten | | [Web/Protocol.cs](src/SwyxTray/Web/Protocol.cs) | die JSON-Nachrichten |
| [Web/WebSocketConfig.cs](src/SwyxTray/Web/WebSocketConfig.cs) | Port, Token, erlaubte Origins | | [Web/WebSocketConfig.cs](src/SwyxTray/Web/WebSocketConfig.cs) | Port, Token, erlaubte Origins |
| [examples/browser-client.html](examples/browser-client.html) | Beispielseite zum Erproben des Zugangs | | [examples/browser-client.html](examples/browser-client.html) | Beispielseite zum Erproben des Zugangs |
| [src/SwyxFFPlugin/](src/SwyxFFPlugin/) | die Firefox-Erweiterung: [manifest.json](src/SwyxFFPlugin/manifest.json) und [background.js](src/SwyxFFPlugin/background.js) |
| [Log.cs](src/SwyxTray/Log.cs) | Dateiprotokoll unter `%LOCALAPPDATA%\SwyxTray\swyxtray.log` | | [Log.cs](src/SwyxTray/Log.cs) | Dateiprotokoll unter `%LOCALAPPDATA%\SwyxTray\swyxtray.log` |
### Zwei Entwurfsentscheidungen ### Zwei Entwurfsentscheidungen
@@ -207,13 +204,12 @@ Hauptports (`focus`, `tabs`, `opentab`, `closetab`) enden dann mit
SwyxTray einen vorhandenen Tab als Komposition nach vorn: `list`, passenden SwyxTray einen vorhandenen Tab als Komposition nach vorn: `list`, passenden
Tab per `close` schließen und die URL per `open` neu öffnen. Tab per `close` schließen und die URL per `open` neu öffnen.
Das Plugin selbst liegt in [src/SwyxFFPlugin/](src/SwyxFFPlugin/) — Das Plugin selbst (SwyxFFPlugin) wird als **eigenes Projekt** gepflegt und ist
[background.js](src/SwyxFFPlugin/background.js) setzt genau diese drei nicht Teil dieses Repositories. Sein `background.js` setzt genau diese drei
Aufträge um und verbindet sich selbsttätig neu (1 s Abstand, je Fehlversuch Aufträge um und verbindet sich selbsttätig neu (1 s Abstand, je Fehlversuch
verdoppelt bis 30 s), falls SwyxTray noch nicht oder nicht mehr läuft. verdoppelt bis 30 s), falls SwyxTray noch nicht oder nicht mehr läuft.
Installation zum Erproben: `about:debugging#/runtime/this-firefox` Installation zum Erproben: `about:debugging#/runtime/this-firefox`
*Temporäres Add-on laden…* → die `manifest.json` des Ordners wählen (Näheres *Temporäres Add-on laden…* → die `manifest.json` des Plugin-Projekts wählen.
im [README des Plugins](src/SwyxFFPlugin/README.md)).
Sind die Prüfungen wieder eingeschaltet, braucht die Adresse zusätzlich das Sind die Prüfungen wieder eingeschaltet, braucht die Adresse zusätzlich das
Token (`ws://127.0.0.1:17655/?token=…`), und in `AllowedOrigins` muss der Token (`ws://127.0.0.1:17655/?token=…`), und in `AllowedOrigins` muss der
@@ -233,7 +229,10 @@ wobei die UUID je Installation zufällig vergeben wird — sie steht in
| `tabs` | — | liest die offenen Firefox-Tabs über das Plugin aus | | `tabs` | — | liest die offenen Firefox-Tabs über das Plugin aus |
| `opentab` | `url` | öffnet über das Plugin einen neuen Firefox-Tab | | `opentab` | `url` | öffnet über das Plugin einen neuen Firefox-Tab |
| `closetab` | `tabId` | schließt über das Plugin den Tab mit dieser Id | | `closetab` | `tabId` | schließt über das Plugin den Tab mit dieser Id |
| `status` | — | fordert den vollen Zustand an | | `addresses` | — | liefert die zwischengespeicherten Adressdaten (Telefonbücher), Antwort im Feld `addresses` |
| `contacts` | `query` | durchsucht die Adressdaten des Swyx-Clients (Telefonbücher und Kontakt-Plugins), Antwort im Feld `contacts` |
| `resolve` | `number` | löst eine Rufnummer über die Adressdaten in einen Namen auf, Antwort im Feld `name` (leer = unbekannt) |
| `status` | — | fordert den vollen Zustand samt der zwischengespeicherten Adressdaten an |
| `reconnect` | — | verwirft die CLMgr-Verbindung und baut sie neu auf | | `reconnect` | — | verwirft die CLMgr-Verbindung und baut sie neu auf |
| `ping` | — | Lebenszeichen | | `ping` | — | Lebenszeichen |
@@ -292,9 +291,10 @@ Tab-Liste von selbst — wer sie aktuell braucht, fragt mit `tabs` nach:
### Meldungen (App → Seite) ### Meldungen (App → Seite)
Direkt nach dem Verbinden kommen `hello` und ein `snapshot`, danach ein Direkt nach dem Verbinden kommen `hello`, ein `snapshot` und die
`snapshot` bei **jeder** Zustandsänderung. Die Seite muss also nie pollen und zwischengespeicherten Adressdaten als `addresses`, danach ein `snapshot` bei
nie selbst Zustand mitführen: **jeder** Zustandsänderung. Die Seite muss also nie pollen und nie selbst
Zustand mitführen:
```json ```json
{"type":"snapshot","connected":true,"serverUp":true,"overall":"Ringing", {"type":"snapshot","connected":true,"serverUp":true,"overall":"Ringing",
@@ -306,6 +306,22 @@ nie selbst Zustand mitführen:
`overall` ist derselbe Gesamtzustand, den auch die Farbe des Tray-Symbols zeigt. `overall` ist derselbe Gesamtzustand, den auch die Farbe des Tray-Symbols zeigt.
**Adressdaten** (seit Protokollversion 8): Die App liest beim Verbindungsaufbau
zu SwyxIt! — also beim Start und bei jeder Neuverbindung — sowie danach **alle
60 Minuten** das globale und das persönliche Telefonbuch aus dem Swyx-Client
und hält sie im Speicher vor. Jede verbundene Seite bekommt den Stand als
`addresses`-Nachricht zugestellt: direkt nach dem `snapshot` beim Verbinden,
auf ein `status`-Kommando und unaufgefordert **nach jedem Neueinlesen**. Wer
den Cache selbst abfragen will, sendet das Kommando `addresses` und erhält
die Einträge im gleichnamigen Feld der Antwort. Bei einem Lesefehler behält
der Cache den letzten erfolgreichen Stand.
```json
{"type":"addresses","addresses":[
{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"},
{"name":"Schmidt, Anna","number":"123","description":"Persoenliches Telefonbuch"}]}
```
Zusätzlich zum `snapshot` meldet die App **Anruf-Ereignisse** als eigene Zusätzlich zum `snapshot` meldet die App **Anruf-Ereignisse** als eigene
Nachricht vom Typ `call` — damit muss eine Seite keine zwei Zustände Nachricht vom Typ `call` — damit muss eine Seite keine zwei Zustände
vergleichen, um einen eingehenden Ruf zu erkennen: vergleichen, um einen eingehenden Ruf zu erkennen:
@@ -536,6 +552,16 @@ der Ereignissenke, Auslesen aller vier Leitungen samt Zustand und ausgewählter
Leitung, Ableitung des Gesamtzustands, Symbolerzeugung, Änderungserkennung Leitung, Ableitung des Gesamtzustands, Symbolerzeugung, Änderungserkennung
(keine Doppelmeldungen über mehrere Zyklen) und das Dateiprotokoll. (keine Doppelmeldungen über mehrere Zyklen) und das Dateiprotokoll.
**Adressdaten gegen den laufenden Client verifiziert:** Das globale Telefonbuch
wird mit allen **404 Einträgen** gelesen (`Adressdaten gelesen: 404 Einträge`,
knapp eine Sekunde nach dem Verbindungsaufbau). Dabei zeigte sich, dass CLMgr
die Telefonbücher als reine IDispatch-Objekte liefert: weder die Sammlung noch
ihre Einträge implementieren die typisierten Interop-Schnittstellen
(`IDispCollection`, `IPbxPhoneBookEntryDisp`) — ein Cast darauf scheitert still
und ergibt 0 Einträge. Gelesen wird deshalb per `IEnumerable` und Late-Binding
(`dynamic`), siehe `AppendPhoneBook` in
[Swyx/SwyxClient.cs](src/SwyxTray/Swyx/SwyxClient.cs).
**WebSocket-Zugang gegen die laufende App geprüft** (mit einem **WebSocket-Zugang gegen die laufende App geprüft** (mit einem
`ClientWebSocket` als Gegenstelle): `ClientWebSocket` als Gegenstelle):
-24
View File
@@ -1,24 +0,0 @@
# Template — copy to `.env` and fill in your values.
# Do NOT commit `.env` (contains secrets).
SERVER_PORT=8080
SWYX_ADMIN_USERNAME=admin
SWYX_ADMIN_PASSWORD=change-me
SWYX_PLUGINS_OUTLOOK_ENABLED=false
SWYX_PLUGINS_OUTLOOK_TENANT_ID=
SWYX_PLUGINS_OUTLOOK_CLIENT_ID=
SWYX_PLUGINS_OUTLOOK_CLIENT_SECRET=
SWYX_PLUGINS_OUTLOOK_BASE_URL=https://graph.microsoft.com/v1.0
SWYX_PLUGINS_OUTLOOK_SCOPE=https://graph.microsoft.com/.default
MONGODB_URI=
MONGODB_HOST=localhost
MONGODB_PORT=27017
MONGODB_DATABASE=swyx
MONGODB_USERNAME=
MONGODB_PASSWORD=
MONGODB_AUTH_DATABASE=admin
LOGGING_LEVEL_DE_ASSECUTOR_SWYX=INFO
-15039
View File
File diff suppressed because it is too large Load Diff
-104
View File
@@ -1,104 +0,0 @@
{
"name": "no-name",
"license": "UNLICENSED",
"type": "module",
"dependencies": {
"@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.6.4",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/polymer-legacy-adapter": "24.6.4",
"@vaadin/react-components": "24.6.4",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.6.4",
"@vaadin/vaadin-material-styles": "24.6.4",
"@vaadin/vaadin-themable-mixin": "24.6.4",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"lit": "3.2.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.29.0"
},
"devDependencies": {
"@babel/preset-react": "7.26.3",
"@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "4.3.4",
"async": "3.2.6",
"glob": "10.4.5",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.7.3",
"vite": "6.0.11",
"vite-plugin-checker": "0.8.0",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
},
"vaadin": {
"dependencies": {
"@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.6.4",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/polymer-legacy-adapter": "24.6.4",
"@vaadin/react-components": "24.6.4",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.6.4",
"@vaadin/vaadin-material-styles": "24.6.4",
"@vaadin/vaadin-themable-mixin": "24.6.4",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"lit": "3.2.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.29.0"
},
"devDependencies": {
"@babel/preset-react": "7.26.3",
"@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "4.3.4",
"async": "3.2.6",
"glob": "10.4.5",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.7.3",
"vite": "6.0.11",
"vite-plugin-checker": "0.8.0",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
},
"hash": "eb4789b12aacb7dfe73ac33a6dd463d0f265e60c4e8b5123fb0bd291e624727e"
},
"overrides": {
"@vaadin/bundles": "$@vaadin/bundles",
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
"@vaadin/react-components": "$@vaadin/react-components",
"@vaadin/common-frontend": "$@vaadin/common-frontend",
"react-dom": "$react-dom",
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
"react-router-dom": "$react-router-dom",
"lit": "$lit",
"@polymer/polymer": "$@polymer/polymer",
"react": "$react",
"date-fns": "$date-fns",
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles"
}
}
-161
View File
@@ -1,161 +0,0 @@
<?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>3.4.1</version>
<relativePath/>
</parent>
<groupId>de.assecutor.swyx</groupId>
<artifactId>swyx-core</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>swyx-core</name>
<description>Swyx Webhook Service für Anrufsignalisierung mit Admin-Oberfläche</description>
<properties>
<java.version>21</java.version>
<vaadin.version>24.6.5</vaadin.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-bom</artifactId>
<version>${vaadin.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.38</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.38</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.version}</version>
<executions>
<execution>
<goals>
<goal>prepare-frontend</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>production</id>
<build>
<plugins>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.version}</version>
<executions>
<execution>
<goals>
<goal>build-frontend</goal>
</goals>
<phase>compile</phase>
</execution>
</executions>
<configuration>
<productionMode>true</productionMode>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
-23
View File
@@ -1,23 +0,0 @@
<!DOCTYPE html>
<!--
This file is auto-generated by Vaadin.
-->
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<style>
body, #outlet {
height: 100vh;
width: 100%;
margin: 0;
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
</head>
<body>
<!-- This outlet div is where the views are rendered -->
<div id="outlet"></div>
</body>
</html>
@@ -1 +0,0 @@
/* swyx-admin theme styles */
@@ -1,3 +0,0 @@
{
"lumoImports": ["typography", "color", "spacing", "badge", "utility"]
}
@@ -1,20 +0,0 @@
package de.assecutor.swyx;
import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.component.page.Push;
import com.vaadin.flow.theme.Theme;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@Push
@Theme("swyx-admin")
@EnableScheduling
@SpringBootApplication
public class SwyxCoreApplication implements AppShellConfigurator {
public static void main(String[] args) {
SpringApplication.run(SwyxCoreApplication.class, args);
}
}
@@ -1,8 +0,0 @@
package de.assecutor.swyx.plugins;
public interface Plugin {
String name();
void start();
}
@@ -1,38 +0,0 @@
package de.assecutor.swyx.plugins;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class PluginManager {
private static final Logger log = LoggerFactory.getLogger(PluginManager.class);
private final List<Plugin> plugins;
public PluginManager(List<Plugin> plugins) {
this.plugins = plugins;
}
@EventListener(ApplicationReadyEvent.class)
public void startAll() {
log.info("Starting {} plugin(s)", plugins.size());
for (Plugin plugin : plugins) {
try {
log.info("Starting plugin '{}'", plugin.name());
plugin.start();
} catch (Exception ex) {
log.error("Failed to start plugin '{}'", plugin.name(), ex);
}
}
}
public List<Plugin> getPlugins() {
return List.copyOf(plugins);
}
}
@@ -1,20 +0,0 @@
package de.assecutor.swyx.plugins.outlook;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OutlookContact(
String id,
String userPrincipalName,
String displayName,
String givenName,
String surname,
String mail,
String mobilePhone,
@JsonProperty("businessPhones") List<String> businessPhones,
String jobTitle,
String department) {
}
@@ -1,116 +0,0 @@
package de.assecutor.swyx.plugins.outlook;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
@Service
public class OutlookContactService {
private static final Logger log = LoggerFactory.getLogger(OutlookContactService.class);
private final OutlookProperties properties;
private final RestClient restClient = RestClient.create();
private final AtomicReference<List<OutlookContact>> cache = new AtomicReference<>(List.of());
private volatile Instant lastSync;
public OutlookContactService(OutlookProperties properties) {
this.properties = properties;
}
public List<OutlookContact> getContacts() {
return cache.get();
}
public Instant getLastSync() {
return lastSync;
}
public List<OutlookContact> sync() {
if (!properties.enabled()) {
log.debug("Outlook plugin disabled, skipping sync");
return cache.get();
}
if (isBlank(properties.tenantId()) || isBlank(properties.clientId()) || isBlank(properties.clientSecret())) {
log.warn("Outlook credentials incomplete — skipping sync");
return cache.get();
}
String accessToken = requestAccessToken();
List<OutlookContact> contacts = fetchAllContacts(accessToken);
cache.set(List.copyOf(contacts));
lastSync = Instant.now();
log.info("Outlook sync completed: {} contacts loaded", contacts.size());
return contacts;
}
private String requestAccessToken() {
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("client_id", properties.clientId());
form.add("client_secret", properties.clientSecret());
form.add("scope", properties.scope());
form.add("grant_type", "client_credentials");
TokenResponse response = restClient.post()
.uri(properties.tokenUrl())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(form)
.retrieve()
.body(TokenResponse.class);
if (response == null || isBlank(response.accessToken())) {
throw new IllegalStateException("Outlook token endpoint returned no access_token");
}
return response.accessToken();
}
private List<OutlookContact> fetchAllContacts(String accessToken) {
List<OutlookContact> all = new ArrayList<>();
String url = properties.baseUrl() + "/users?$select=id,userPrincipalName,displayName,givenName,surname,mail,mobilePhone,businessPhones,jobTitle,department&$top=100";
while (url != null && !url.isBlank()) {
UsersResponse response = restClient.get()
.uri(url)
.header("Authorization", "Bearer " + accessToken)
.retrieve()
.body(UsersResponse.class);
if (response == null) {
break;
}
if (response.value() != null) {
all.addAll(response.value());
}
url = response.nextLink();
}
return Collections.unmodifiableList(all);
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record TokenResponse(@JsonProperty("access_token") String accessToken,
@JsonProperty("token_type") String tokenType,
@JsonProperty("expires_in") Long expiresIn) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record UsersResponse(List<OutlookContact> value,
@JsonProperty("@odata.nextLink") String nextLink) {
}
}
@@ -1,56 +0,0 @@
package de.assecutor.swyx.plugins.outlook;
import de.assecutor.swyx.plugins.Plugin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@Configuration
@EnableConfigurationProperties(OutlookProperties.class)
public class OutlookPlugin implements Plugin {
private static final Logger log = LoggerFactory.getLogger(OutlookPlugin.class);
private final OutlookProperties properties;
private final OutlookContactService contactService;
public OutlookPlugin(OutlookProperties properties, OutlookContactService contactService) {
this.properties = properties;
this.contactService = contactService;
}
@Override
public String name() {
return "outlook";
}
@Override
public void start() {
if (!properties.enabled()) {
log.info("Outlook plugin disabled via configuration");
return;
}
log.info("Outlook plugin starting — performing initial contact sync");
try {
contactService.sync();
} catch (Exception ex) {
log.error("Initial Outlook contact sync failed", ex);
}
}
@Scheduled(fixedRate = 60L * 60L * 1000L)
public void scheduledSync() {
if (!properties.enabled()) {
return;
}
try {
contactService.sync();
} catch (Exception ex) {
log.error("Scheduled Outlook contact sync failed", ex);
}
}
}
@@ -1,28 +0,0 @@
package de.assecutor.swyx.plugins.outlook;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "swyx.plugins.outlook")
public record OutlookProperties(
boolean enabled,
String tenantId,
String clientId,
String clientSecret,
String baseUrl,
String tokenUrl,
String scope) {
public OutlookProperties {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = "https://graph.microsoft.com/v1.0";
}
if (tokenUrl == null || tokenUrl.isBlank()) {
tokenUrl = tenantId != null
? "https://login.microsoftonline.com/" + tenantId + "/oauth2/v2.0/token"
: "";
}
if (scope == null || scope.isBlank()) {
scope = "https://graph.microsoft.com/.default";
}
}
}
@@ -1,7 +0,0 @@
package de.assecutor.swyx.security;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "swyx.admin")
public record AdminAccountProperties(String username, String password) {
}
@@ -1,56 +0,0 @@
package de.assecutor.swyx.security;
import com.vaadin.flow.spring.security.VaadinWebSecurity;
import de.assecutor.swyx.ui.view.LoginView;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(AdminAccountProperties.class)
public class SecurityConfig extends VaadinWebSecurity {
private final AdminAccountProperties adminAccount;
public SecurityConfig(AdminAccountProperties adminAccount) {
this.adminAccount = adminAccount;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
UserDetails admin = User.builder()
.username(adminAccount.username())
.password(encoder.encode(adminAccount.password()))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(admin);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers(new AntPathRequestMatcher("/api/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/ws/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/actuator/health")).permitAll());
http.csrf(csrf -> csrf
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/**"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/ws/**")));
super.configure(http);
setLoginView(http, LoginView.class);
}
}
@@ -1,25 +0,0 @@
package de.assecutor.swyx.security;
import com.vaadin.flow.spring.security.AuthenticationContext;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
public class SecurityService {
private final AuthenticationContext authenticationContext;
public SecurityService(AuthenticationContext authenticationContext) {
this.authenticationContext = authenticationContext;
}
public Optional<UserDetails> getAuthenticatedUser() {
return authenticationContext.getAuthenticatedUser(UserDetails.class);
}
public void logout() {
authenticationContext.logout();
}
}
@@ -1,79 +0,0 @@
package de.assecutor.swyx.ui;
import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.applayout.DrawerToggle;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.sidenav.SideNav;
import com.vaadin.flow.component.sidenav.SideNavItem;
import com.vaadin.flow.router.Layout;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.swyx.security.SecurityService;
import de.assecutor.swyx.ui.view.DashboardView;
import de.assecutor.swyx.ui.view.EventsView;
import jakarta.annotation.security.PermitAll;
@Layout
@PermitAll
public class MainLayout extends AppLayout {
private final SecurityService securityService;
public MainLayout(SecurityService securityService) {
this.securityService = securityService;
setPrimarySection(Section.DRAWER);
addHeaderContent();
addDrawerContent();
}
private void addHeaderContent() {
DrawerToggle toggle = new DrawerToggle();
toggle.setAriaLabel("Menü umschalten");
H1 title = new H1("Swyx Admin");
title.addClassNames(LumoUtility.FontSize.LARGE, LumoUtility.Margin.NONE);
Span status = new Span(VaadinIcon.CIRCLE.create(), new Span(" Webhook aktiv"));
status.getStyle().set("color", "var(--lumo-success-color)");
status.getStyle().set("font-size", "var(--lumo-font-size-s)");
HorizontalLayout rightSection = new HorizontalLayout(status);
rightSection.setAlignItems(FlexComponent.Alignment.CENTER);
rightSection.setSpacing(true);
securityService.getAuthenticatedUser().ifPresent(user -> {
Span userName = new Span(user.getUsername());
userName.addClassNames(LumoUtility.FontWeight.MEDIUM);
Button logoutBtn = new Button(VaadinIcon.SIGN_OUT.create(), e -> securityService.logout());
logoutBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
logoutBtn.setTooltipText("Abmelden");
rightSection.add(userName, logoutBtn);
});
HorizontalLayout header = new HorizontalLayout(toggle, title, rightSection);
header.setAlignItems(FlexComponent.Alignment.CENTER);
header.setWidthFull();
header.setPadding(true);
header.expand(title);
addToNavbar(true, header);
}
private void addDrawerContent() {
Span appName = new Span("Swyx Core");
appName.addClassNames(LumoUtility.FontWeight.SEMIBOLD, LumoUtility.Padding.MEDIUM);
SideNav nav = new SideNav();
nav.addItem(new SideNavItem("Dashboard", DashboardView.class, VaadinIcon.DASHBOARD.create()));
nav.addItem(new SideNavItem("Anruf-Events", EventsView.class, VaadinIcon.PHONE.create()));
addToDrawer(appName, nav);
}
}
@@ -1,117 +0,0 @@
package de.assecutor.swyx.ui.view;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.DetachEvent;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.swyx.webhook.CallEventStore;
import de.assecutor.swyx.webhook.CallEventType;
import de.assecutor.swyx.webhook.StoredCallEvent;
import jakarta.annotation.security.PermitAll;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Route("")
@PageTitle("Dashboard")
@PermitAll
public class DashboardView extends VerticalLayout {
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
private final CallEventStore store;
private final Grid<StoredCallEvent> grid = new Grid<>(StoredCallEvent.class, false);
private final StatCard totalCard = new StatCard("Events gesamt");
private final StatCard ringingCard = new StatCard("Eingehend (Ringing)");
private final StatCard answeredCard = new StatCard("Angenommen");
private final StatCard missedCard = new StatCard("Verpasst");
private CallEventStore.Registration registration;
public DashboardView(CallEventStore store) {
this.store = store;
setSizeFull();
setPadding(true);
setSpacing(true);
add(new H2("Live Übersicht"));
add(buildStatsRow());
add(buildRecentGrid());
expand(grid);
refresh(store.snapshot());
}
private HorizontalLayout buildStatsRow() {
HorizontalLayout row = new HorizontalLayout(totalCard, ringingCard, answeredCard, missedCard);
row.setWidthFull();
row.setSpacing(true);
return row;
}
private Grid<StoredCallEvent> buildRecentGrid() {
grid.setAllRowsVisible(false);
grid.setSizeFull();
grid.addColumn(e -> e.receivedAt().format(TIME_FORMAT)).setHeader("Zeit").setAutoWidth(true).setFlexGrow(0);
grid.addColumn(e -> e.eventType().name()).setHeader("Typ").setAutoWidth(true).setFlexGrow(0);
grid.addColumn(StoredCallEvent::from).setHeader("Von").setAutoWidth(true);
grid.addColumn(StoredCallEvent::to).setHeader("An").setAutoWidth(true);
grid.addColumn(StoredCallEvent::user).setHeader("Nebenstelle").setAutoWidth(true);
grid.addColumn(StoredCallEvent::callId).setHeader("Call ID").setAutoWidth(true);
return grid;
}
@Override
protected void onAttach(AttachEvent attachEvent) {
registration = store.register(event -> attachEvent.getUI().access(() -> refresh(store.snapshot())));
}
@Override
protected void onDetach(DetachEvent detachEvent) {
if (registration != null) {
registration.remove();
registration = null;
}
}
private void refresh(List<StoredCallEvent> events) {
List<StoredCallEvent> recent = events.size() > 20 ? events.subList(0, 20) : events;
grid.setItems(recent);
totalCard.setValue(events.size());
ringingCard.setValue(count(events, CallEventType.RINGING));
answeredCard.setValue(count(events, CallEventType.ANSWERED));
missedCard.setValue(count(events, CallEventType.MISSED));
}
private static long count(List<StoredCallEvent> events, CallEventType type) {
return events.stream().filter(e -> e.eventType() == type).count();
}
private static class StatCard extends VerticalLayout {
private final Span value = new Span("0");
StatCard(String label) {
Span title = new Span(label);
title.addClassNames(LumoUtility.FontSize.SMALL, LumoUtility.TextColor.SECONDARY);
value.addClassNames(LumoUtility.FontSize.XXLARGE, LumoUtility.FontWeight.BOLD);
add(title, value);
setSpacing(false);
setPadding(true);
getStyle().set("background", "var(--lumo-contrast-5pct)");
getStyle().set("border-radius", "var(--lumo-border-radius-l)");
setAlignItems(FlexComponent.Alignment.START);
setWidthFull();
}
void setValue(long number) {
value.setText(Long.toString(number));
}
}
}
@@ -1,170 +0,0 @@
package de.assecutor.swyx.ui.view;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.DetachEvent;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.provider.ListDataProvider;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.swyx.webhook.CallEventStore;
import de.assecutor.swyx.webhook.CallEventType;
import de.assecutor.swyx.webhook.StoredCallEvent;
import jakarta.annotation.security.PermitAll;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Objects;
@Route("events")
@PageTitle("Anruf-Events")
@PermitAll
public class EventsView extends VerticalLayout {
private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss");
private final CallEventStore store;
private final Grid<StoredCallEvent> grid = new Grid<>(StoredCallEvent.class, false);
private final ListDataProvider<StoredCallEvent> dataProvider = new ListDataProvider<>(new ArrayList<>());
private final TextField searchField = new TextField();
private final ComboBox<CallEventType> typeFilter = new ComboBox<>();
private CallEventStore.Registration registration;
public EventsView(CallEventStore store) {
this.store = store;
setSizeFull();
setPadding(true);
setSpacing(true);
add(new H3("Alle empfangenen Events"));
add(buildToolbar());
add(grid);
expand(grid);
configureGrid();
refresh();
}
private HorizontalLayout buildToolbar() {
searchField.setPlaceholder("Suchen (Rufnummer, Call-ID, Nebenstelle)");
searchField.setPrefixComponent(VaadinIcon.SEARCH.create());
searchField.setValueChangeMode(ValueChangeMode.LAZY);
searchField.setClearButtonVisible(true);
searchField.addValueChangeListener(e -> applyFilters());
searchField.setWidth("360px");
typeFilter.setPlaceholder("Alle Typen");
typeFilter.setItems(Arrays.asList(CallEventType.values()));
typeFilter.setClearButtonVisible(true);
typeFilter.addValueChangeListener(e -> applyFilters());
Button refresh = new Button("Aktualisieren", VaadinIcon.REFRESH.create(), e -> refresh());
HorizontalLayout toolbar = new HorizontalLayout(searchField, typeFilter, refresh);
toolbar.setWidthFull();
return toolbar;
}
private void configureGrid() {
grid.setSizeFull();
grid.addColumn(e -> e.receivedAt().format(DATE_TIME_FORMAT)).setHeader("Empfangen").setAutoWidth(true).setFlexGrow(0);
grid.addColumn(e -> e.eventType().name()).setHeader("Typ").setAutoWidth(true).setFlexGrow(0);
grid.addColumn(StoredCallEvent::from).setHeader("Von").setAutoWidth(true);
grid.addColumn(StoredCallEvent::to).setHeader("An").setAutoWidth(true);
grid.addColumn(StoredCallEvent::user).setHeader("Nebenstelle").setAutoWidth(true);
grid.addColumn(e -> e.durationSeconds() != null ? e.durationSeconds() + " s" : "")
.setHeader("Dauer").setAutoWidth(true).setFlexGrow(0);
grid.addColumn(StoredCallEvent::callId).setHeader("Call ID").setAutoWidth(true);
grid.setDataProvider(dataProvider);
grid.addItemClickListener(e -> showDetails(e.getItem()));
}
private void applyFilters() {
String query = searchField.getValue() == null ? "" : searchField.getValue().trim().toLowerCase();
CallEventType type = typeFilter.getValue();
dataProvider.setFilter(event -> {
if (type != null && event.eventType() != type) {
return false;
}
if (query.isEmpty()) {
return true;
}
return matches(event.from(), query)
|| matches(event.to(), query)
|| matches(event.callId(), query)
|| matches(event.user(), query);
});
}
private static boolean matches(String value, String query) {
return value != null && value.toLowerCase().contains(query);
}
@Override
protected void onAttach(AttachEvent attachEvent) {
registration = store.register(event -> attachEvent.getUI().access(this::refresh));
}
@Override
protected void onDetach(DetachEvent detachEvent) {
if (registration != null) {
registration.remove();
registration = null;
}
}
private void refresh() {
dataProvider.getItems().clear();
dataProvider.getItems().addAll(store.snapshot());
dataProvider.refreshAll();
applyFilters();
}
private void showDetails(StoredCallEvent event) {
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Event-Details");
dialog.setWidth("480px");
VerticalLayout content = new VerticalLayout();
content.setPadding(false);
content.setSpacing(false);
content.add(row("Call ID", event.callId()));
content.add(row("Event-Typ", event.eventType().name()));
content.add(row("Von", event.from()));
content.add(row("An", event.to()));
content.add(row("Nebenstelle", event.user()));
content.add(row("Empfangen", event.receivedAt().format(DATE_TIME_FORMAT)));
content.add(row("Zeitstempel (Swyx)",
event.timestamp() != null ? event.timestamp().format(DATE_TIME_FORMAT) : null));
content.add(row("Dauer", event.durationSeconds() != null ? event.durationSeconds() + " s" : null));
dialog.add(content);
dialog.getFooter().add(new Button("Schließen", e -> dialog.close()));
dialog.open();
}
private static HorizontalLayout row(String label, String value) {
Span labelSpan = new Span(label);
labelSpan.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL);
labelSpan.setWidth("140px");
Span valueSpan = new Span(Objects.toString(value, ""));
valueSpan.addClassNames(LumoUtility.FontWeight.MEDIUM);
HorizontalLayout row = new HorizontalLayout(labelSpan, valueSpan);
row.setPadding(false);
row.getStyle().set("padding", "var(--lumo-space-xs) 0");
return row;
}
}
@@ -1,55 +0,0 @@
package de.assecutor.swyx.ui.view;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.login.LoginForm;
import com.vaadin.flow.component.login.LoginI18n;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.auth.AnonymousAllowed;
@Route(value = "login", autoLayout = false)
@PageTitle("Anmelden — Swyx Admin")
@AnonymousAllowed
public class LoginView extends VerticalLayout implements BeforeEnterObserver {
private final LoginForm loginForm = new LoginForm();
public LoginView() {
setSizeFull();
setAlignItems(FlexComponent.Alignment.CENTER);
setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
H1 title = new H1("Swyx Admin");
title.getStyle().set("margin-bottom", "var(--lumo-space-m)");
LoginI18n i18n = LoginI18n.createDefault();
LoginI18n.Form form = i18n.getForm();
form.setTitle("Anmelden");
form.setUsername("Benutzername");
form.setPassword("Passwort");
form.setSubmit("Anmelden");
i18n.setForm(form);
LoginI18n.ErrorMessage errorMessage = i18n.getErrorMessage();
errorMessage.setTitle("Anmeldung fehlgeschlagen");
errorMessage.setMessage("Benutzername oder Passwort ist ungültig.");
i18n.setErrorMessage(errorMessage);
loginForm.setI18n(i18n);
loginForm.setAction("login");
loginForm.setForgotPasswordButtonVisible(false);
add(title, loginForm);
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
if (event.getLocation().getQueryParameters().getParameters().containsKey("error")) {
loginForm.setError(true);
}
}
}
@@ -1,35 +0,0 @@
package de.assecutor.swyx.webhook;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.time.OffsetDateTime;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class CallEventRequest {
@NotBlank
@JsonAlias({"callId", "call_id", "CallId"})
private String callId;
@JsonAlias({"eventType", "event", "type"})
private CallEventType eventType;
@JsonAlias({"from", "caller", "callerNumber", "CallingPartyNumber"})
private String from;
@JsonAlias({"to", "callee", "calleeNumber", "CalledPartyNumber"})
private String to;
@JsonAlias({"user", "userName", "extension"})
private String user;
@JsonAlias({"timestamp", "eventTime", "time"})
private OffsetDateTime timestamp;
@JsonAlias({"durationSeconds", "duration"})
private Integer durationSeconds;
}
@@ -1,46 +0,0 @@
package de.assecutor.swyx.webhook;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class CallEventService {
private final CallEventStore store;
public void handle(CallEventRequest event) {
store.add(event);
CallEventType type = event.getEventType() != null ? event.getEventType() : CallEventType.UNKNOWN;
switch (type) {
case RINGING -> onRinging(event);
case ANSWERED -> onAnswered(event);
case ENDED -> onEnded(event);
case TRANSFERRED -> onTransferred(event);
case MISSED -> onMissed(event);
default -> log.warn("Unhandled Swyx event type for callId={}", event.getCallId());
}
}
private void onRinging(CallEventRequest event) {
log.info("Ringing: {} -> {} (user={})", event.getFrom(), event.getTo(), event.getUser());
}
private void onAnswered(CallEventRequest event) {
log.info("Answered: callId={} user={}", event.getCallId(), event.getUser());
}
private void onEnded(CallEventRequest event) {
log.info("Ended: callId={} duration={}s", event.getCallId(), event.getDurationSeconds());
}
private void onTransferred(CallEventRequest event) {
log.info("Transferred: callId={}", event.getCallId());
}
private void onMissed(CallEventRequest event) {
log.info("Missed: {} -> {}", event.getFrom(), event.getTo());
}
}
@@ -1,43 +0,0 @@
package de.assecutor.swyx.webhook;
import org.springframework.stereotype.Component;
import java.time.OffsetDateTime;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
@Component
public class CallEventStore {
private static final int MAX_ENTRIES = 500;
private final Deque<StoredCallEvent> buffer = new ArrayDeque<>(MAX_ENTRIES);
private final List<Consumer<StoredCallEvent>> listeners = new CopyOnWriteArrayList<>();
public synchronized StoredCallEvent add(CallEventRequest request) {
StoredCallEvent entry = StoredCallEvent.of(request, OffsetDateTime.now());
buffer.addFirst(entry);
while (buffer.size() > MAX_ENTRIES) {
buffer.removeLast();
}
listeners.forEach(l -> l.accept(entry));
return entry;
}
public synchronized List<StoredCallEvent> snapshot() {
return List.copyOf(buffer);
}
public Registration register(Consumer<StoredCallEvent> listener) {
listeners.add(listener);
return () -> listeners.remove(listener);
}
@FunctionalInterface
public interface Registration {
void remove();
}
}
@@ -1,10 +0,0 @@
package de.assecutor.swyx.webhook;
public enum CallEventType {
RINGING,
ANSWERED,
ENDED,
TRANSFERRED,
MISSED,
UNKNOWN
}
@@ -1,30 +0,0 @@
package de.assecutor.swyx.webhook;
import java.time.OffsetDateTime;
import java.util.UUID;
public record StoredCallEvent(
String id,
OffsetDateTime receivedAt,
String callId,
CallEventType eventType,
String from,
String to,
String user,
OffsetDateTime timestamp,
Integer durationSeconds
) {
public static StoredCallEvent of(CallEventRequest request, OffsetDateTime receivedAt) {
return new StoredCallEvent(
UUID.randomUUID().toString(),
receivedAt,
request.getCallId(),
request.getEventType() != null ? request.getEventType() : CallEventType.UNKNOWN,
request.getFrom(),
request.getTo(),
request.getUser(),
request.getTimestamp(),
request.getDurationSeconds()
);
}
}
@@ -1,32 +0,0 @@
package de.assecutor.swyx.webhook;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/swyx")
@RequiredArgsConstructor
public class SwyxWebhookController {
private final CallEventService callEventService;
@PostMapping("/webhook/call")
public ResponseEntity<Map<String, Object>> receiveCallEvent(@Valid @RequestBody CallEventRequest request) {
log.info("Swyx call event received: callId={}, type={}, from={}, to={}",
request.getCallId(), request.getEventType(), request.getFrom(), request.getTo());
callEventService.handle(request);
return ResponseEntity.ok(Map.of(
"status", "ok",
"callId", request.getCallId()
));
}
}
@@ -1,46 +0,0 @@
package de.assecutor.swyx.ws;
import de.assecutor.swyx.webhook.CallEventStore;
import de.assecutor.swyx.webhook.StoredCallEvent;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class CallEventBroadcaster {
public static final String TOPIC_ALL = "/topic/calls";
public static final String TOPIC_PER_TYPE_PREFIX = "/topic/calls/";
private final CallEventStore store;
private final SimpMessagingTemplate messaging;
private CallEventStore.Registration registration;
public CallEventBroadcaster(CallEventStore store, SimpMessagingTemplate messaging) {
this.store = store;
this.messaging = messaging;
}
@PostConstruct
void subscribe() {
registration = store.register(this::publish);
}
@PreDestroy
void unsubscribe() {
if (registration != null) {
registration.remove();
}
}
private void publish(StoredCallEvent event) {
messaging.convertAndSend(TOPIC_ALL, event);
if (event.eventType() != null) {
messaging.convertAndSend(TOPIC_PER_TYPE_PREFIX + event.eventType().name().toLowerCase(), event);
}
log.debug("Broadcast STOMP call event: callId={} type={}", event.callId(), event.eventType());
}
}
@@ -1,24 +0,0 @@
package de.assecutor.swyx.ws;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class StompConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/stomp").setAllowedOriginPatterns("*");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/user");
}
}
@@ -1,34 +0,0 @@
package de.assecutor.swyx.ws;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Controller
public class StompMessageController {
@MessageMapping("/ping")
@SendTo("/topic/pong")
public Map<String, Object> ping(@Payload Map<String, Object> payload) {
log.info("STOMP /app/ping: {}", payload);
Map<String, Object> response = new HashMap<>(payload);
response.put("pongAt", OffsetDateTime.now().toString());
return response;
}
@MessageMapping("/broadcast")
@SendTo("/topic/messages")
public Map<String, Object> broadcast(@Payload Map<String, Object> payload) {
log.info("STOMP /app/broadcast: {}", payload);
Map<String, Object> response = new HashMap<>(payload);
response.put("broadcastAt", OffsetDateTime.now().toString());
return response;
}
}
-43
View File
@@ -1,43 +0,0 @@
spring:
application:
name: swyx-core
jackson:
default-property-inclusion: non_null
data:
mongodb:
uri: ${MONGODB_URI:}
host: ${MONGODB_HOST:localhost}
port: ${MONGODB_PORT:27017}
database: ${MONGODB_DATABASE:swyx}
username: ${MONGODB_USERNAME:}
password: ${MONGODB_PASSWORD:}
authentication-database: ${MONGODB_AUTH_DATABASE:admin}
server:
port: ${SERVER_PORT:8080}
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: when-authorized
swyx:
admin:
username: ${SWYX_ADMIN_USERNAME:admin}
password: ${SWYX_ADMIN_PASSWORD:admin}
plugins:
outlook:
enabled: ${SWYX_PLUGINS_OUTLOOK_ENABLED:false}
tenant-id: ${SWYX_PLUGINS_OUTLOOK_TENANT_ID:}
client-id: ${SWYX_PLUGINS_OUTLOOK_CLIENT_ID:}
client-secret: ${SWYX_PLUGINS_OUTLOOK_CLIENT_SECRET:}
base-url: ${SWYX_PLUGINS_OUTLOOK_BASE_URL:https://graph.microsoft.com/v1.0}
scope: ${SWYX_PLUGINS_OUTLOOK_SCOPE:https://graph.microsoft.com/.default}
logging:
level:
de.assecutor.swyx: ${LOGGING_LEVEL_DE_ASSECUTOR_SWYX:INFO}
@@ -1,48 +0,0 @@
package de.assecutor.swyx.webhook;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SwyxWebhookControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate rest;
@Test
void acceptsCallEventPayload() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String body = """
{
"callId": "abc-123",
"eventType": "RINGING",
"from": "+4930111111",
"to": "+4930222222",
"user": "200",
"timestamp": "2026-04-16T10:00:00+02:00"
}
""";
ResponseEntity<String> response = rest.postForEntity(
"http://localhost:" + port + "/api/swyx/webhook/call",
new HttpEntity<>(body, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).contains("abc-123");
}
}
-38
View File
@@ -1,38 +0,0 @@
// This TypeScript configuration file is generated by vaadin-maven-plugin.
// This is needed for TypeScript compiler to compile your TypeScript code in the project.
// It is recommended to commit this file to the VCS.
// You might want to change the configurations to fit your preferences
// For more information about the configurations, please refer to http://www.typescriptlang.org/docs/handbook/tsconfig-json.html
{
"_version": "9.1",
"compilerOptions": {
"sourceMap": true,
"jsx": "react-jsx",
"inlineSources": true,
"module": "esNext",
"target": "es2020",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"paths": {
"@vaadin/flow-frontend": ["./src/main/frontend/generated/jar-resources"],
"@vaadin/flow-frontend/*": ["./src/main/frontend/generated/jar-resources/*"],
"Frontend/*": ["./src/main/frontend/*"]
}
},
"include": [
"src/main/frontend/**/*",
"types.d.ts"
],
"exclude": [
"src/main/frontend/generated/jar-resources/**"
]
}
-17
View File
@@ -1,17 +0,0 @@
// This TypeScript modules definition file is generated by vaadin-maven-plugin.
// You can not directly import your different static files into TypeScript,
// This is needed for TypeScript compiler to declare and export as a TypeScript module.
// It is recommended to commit this file to the VCS.
// You might want to change the configurations to fit your preferences
declare module '*.css?inline' {
import type { CSSResultGroup } from 'lit';
const content: CSSResultGroup;
export default content;
}
// Allow any CSS Custom Properties
declare module 'csstype' {
interface Properties {
[index: `--${string}`]: any;
}
}
-9
View File
@@ -1,9 +0,0 @@
import { UserConfigFn } from 'vite';
import { overrideVaadinConfig } from './vite.generated';
const customConfig: UserConfigFn = (env) => ({
// Here you can add custom Vite parameters
// https://vitejs.dev/config/
});
export default overrideVaadinConfig(customConfig);
-38
View File
@@ -1,38 +0,0 @@
# SwyxFFPlugin
Firefox-Erweiterung zur Tab-Verwaltung fuer SwyxTray. Das Hintergrundskript
haelt eine WebSocket-Verbindung zum **Plugin-Port** von SwyxTray
(voreingestellt `ws://127.0.0.1:17655/`, einstellbar ueber `PluginPort` in
`%LOCALAPPDATA%\SwyxTray\websocket.json`) und beantwortet drei Auftraege:
| Auftrag | Antwort |
|---|---|
| `{"type":"tab","id":…,"action":"list"}` | `{"cmd":"tabresult","id":…,"ok":true,"tabs":[{id,title,url,active},…]}` |
| `{"type":"tab","id":…,"action":"open","url":…}` | `{"cmd":"tabresult","id":…,"ok":true,"tabId":…}` |
| `{"type":"tab","id":…,"action":"close","tabId":…}` | `{"cmd":"tabresult","id":…,"ok":true}` |
Das Protokoll im Einzelnen steht im [Haupt-README](../../README.md) unter
„Firefox-Plugin (SwyxFFPlugin)".
Auf diesem Kanal fragt SwyxTray, das Plugin antwortet — genutzt vom
`focus`-Kommando des Hauptports und von den Kontextmenuepunkten **Lese Tabs**,
**Tab oeffnen** und **Tab schliessen** des Tray-Symbols. Laeuft SwyxTray (noch) nicht, verbindet das Skript sich selbst
neu: beginnend mit 1 s Abstand, je Fehlversuch verdoppelt bis hoechstens 30 s.
## Installieren (zum Erproben)
1. In Firefox `about:debugging#/runtime/this-firefox` oeffnen.
2. **Temporaeres Add-on laden…** und die [manifest.json](manifest.json) dieses
Ordners auswaehlen.
3. Im Protokoll von SwyxTray erscheint `WebSocket … verbunden (…, Plugin-Port)`.
Ein temporaeres Add-on ueberlebt den Firefox-Neustart nicht. Fuer eine
dauerhafte Installation muss die Erweiterung signiert werden (etwa unsigniert
nur in Firefox Developer Edition/ESR mit `xpinstall.signatures.required=false`).
Sind in SwyxTray die Handshake-Pruefungen eingeschaltet (zur Zeit sind sie
abgeschaltet, siehe Haupt-README), braucht die Adresse in
[background.js](background.js) zusaetzlich das Token
(`ws://127.0.0.1:17655/?token=…`), und in `AllowedOrigins` muss der Origin
`moz-extension://<UUID>` der Erweiterung stehen — die UUID zeigt
`about:debugging` als „Interne UUID".
-82
View File
@@ -1,82 +0,0 @@
// SwyxFFPlugin — Hintergrundskript.
//
// Haelt eine WebSocket-Verbindung zum Plugin-Port von SwyxTray (voreingestellt
// 17655, siehe %LOCALAPPDATA%\SwyxTray\websocket.json). Auf diesem Kanal ist
// die Richtung umgekehrt zum Hauptport: SwyxTray fragt, das Plugin antwortet.
// Jeder Auftrag {"type":"tab","id":…,"action":…} wird mit genau einem
// {"cmd":"tabresult","id":…,"ok":…} beantwortet, die id unveraendert.
const ENDPOINT = "ws://127.0.0.1:17655/";
const SUBPROTOCOL = "swyxtray.v1";
// Wiederverbindung mit Verdopplung bis zur Obergrenze — SwyxTray laeuft
// womoeglich noch nicht, wenn Firefox startet, oder wird zwischendurch beendet.
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 30000;
let reconnectDelay = RECONNECT_MIN_MS;
function connect() {
const ws = new WebSocket(ENDPOINT, [SUBPROTOCOL]);
ws.onopen = () => {
reconnectDelay = RECONNECT_MIN_MS;
console.info(`SwyxFFPlugin: verbunden mit ${ENDPOINT}`);
};
ws.onmessage = (e) => handleMessage(ws, e.data);
// onerror liefert keine Einzelheiten und wird stets von onclose gefolgt —
// die Wiederverbindung haengt deshalb allein an onclose.
ws.onclose = () => {
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
};
}
async function handleMessage(ws, data) {
let m;
try {
m = JSON.parse(data);
} catch {
return; // kaputtes JSON — dazu gibt es keine id, also auch keine Antwort
}
// hello beim Verbinden und result auf ein ping kommen ohne type "tab" —
// beantwortet wird nur, was eine Auftrags-id traegt.
if (m.type !== "tab") return;
const reply = (fields) =>
ws.send(JSON.stringify({ cmd: "tabresult", id: m.id, ...fields }));
try {
if (m.action === "list") {
const tabs = await browser.tabs.query({});
reply({
ok: true,
tabs: tabs.map((t) => ({
id: t.id,
title: t.title,
url: t.url,
active: t.active,
})),
});
} else if (m.action === "open") {
const tab = await browser.tabs.create({ url: m.url });
reply({ ok: true, tabId: tab.id });
} else if (m.action === "close") {
if (m.tabId == null) {
reply({ ok: false });
} else {
await browser.tabs.remove(m.tabId);
reply({ ok: true });
}
} else {
reply({ ok: false });
}
} catch (err) {
console.warn(`SwyxFFPlugin: Auftrag '${m.action}' fehlgeschlagen:`, err);
reply({ ok: false });
}
}
connect();
-17
View File
@@ -1,17 +0,0 @@
{
"manifest_version": 2,
"name": "SwyxFFPlugin",
"version": "1.0",
"description": "Tab-Verwaltung fuer SwyxTray: verbindet sich mit ws://127.0.0.1:17655/ und beantwortet die Auftraege list, open und close.",
"permissions": ["tabs"],
"background": {
"scripts": ["background.js"],
"persistent": true
},
"browser_specific_settings": {
"gecko": {
"id": "swyxffplugin@appcreation.de",
"strict_min_version": "115.0"
}
}
}
+247
View File
@@ -1,3 +1,4 @@
using System.Collections;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
using IpPbx.CLMgrLib; using IpPbx.CLMgrLib;
@@ -36,8 +37,23 @@ internal sealed class SwyxClient : IDisposable
/// <summary>Wird nur ausgeloest, wenn sich der Zustand tatsaechlich geaendert hat.</summary> /// <summary>Wird nur ausgeloest, wenn sich der Zustand tatsaechlich geaendert hat.</summary>
public event Action<SwyxSnapshot, SwyxSnapshot>? SnapshotChanged; public event Action<SwyxSnapshot, SwyxSnapshot>? SnapshotChanged;
/// <summary>
/// Wird nach jedem erfolgreichen Neueinlesen der Adressdaten ausgeloest —
/// auch ohne inhaltliche Aenderung, damit die Abnehmer den frischen Stand
/// zugestellt bekommen.
/// </summary>
public event Action<IReadOnlyList<SwyxContact>>? AddressesChanged;
public SwyxSnapshot Current { get; private set; } = SwyxSnapshot.Offline; public SwyxSnapshot Current { get; private set; } = SwyxSnapshot.Offline;
/// <summary>
/// Die Adressdaten aus dem Swyx-Client (globales und persoenliches
/// Telefonbuch), gelesen beim Verbindungsaufbau und danach zyklisch.
/// Bleibt bei Lesefehlern auf dem letzten erfolgreichen Stand — ein
/// veralteter Cache ist besser als ein leerer.
/// </summary>
public IReadOnlyList<SwyxContact> Addresses { get; private set; } = Array.Empty<SwyxContact>();
public bool IsConnected => _disp is not null; public bool IsConnected => _disp is not null;
/// <summary> /// <summary>
@@ -91,6 +107,10 @@ internal sealed class SwyxClient : IDisposable
Log.Info($"Mit CLMgr verbunden (UserId {_userId}, Benutzer '{disp.DispGetCurrentUser}', " + Log.Info($"Mit CLMgr verbunden (UserId {_userId}, Benutzer '{disp.DispGetCurrentUser}', " +
$"Server '{disp.DispGetCurrentServer}', {disp.DispNumberOfLines} Leitungen)."); $"Server '{disp.DispGetCurrentServer}', {disp.DispNumberOfLines} Leitungen).");
// Adressdaten direkt nach dem Verbindungsaufbau einlesen — damit
// deckt derselbe Weg den Programmstart und jede Neuverbindung ab.
RefreshAddresses();
return true; return true;
} }
catch (Exception ex) when (ex is COMException or UnauthorizedAccessException) catch (Exception ex) when (ex is COMException or UnauthorizedAccessException)
@@ -347,6 +367,233 @@ internal sealed class SwyxClient : IDisposable
return index + 1; return index + 1;
} }
/// <summary>
/// Obergrenze fuer eine Kontaktantwort. Die Suche des Clients kennt keine
/// eigene Grenze; ohne Deckel koennte eine breite Anfrage ("e") tausende
/// Eintraege in eine WebSocket-Nachricht giessen.
/// </summary>
private const int MaxContactResults = 100;
/// <summary>
/// Liest die Adressdaten (globales und persoenliches Telefonbuch) neu aus
/// dem Swyx-Client und aktualisiert den Cache. Wirft nicht: bei einem
/// Lesefehler bleibt der letzte erfolgreiche Stand erhalten, gemeldet wird
/// der Fehler im Protokoll. Nach jedem erfolgreichen Lesen feuert
/// <see cref="AddressesChanged"/>.
/// </summary>
public void RefreshAddresses()
{
var disp = _disp;
if (disp is null)
{
return;
}
List<SwyxContact> addresses;
try
{
addresses = ReadAddresses(disp);
}
catch (Exception ex) when (ex is COMException or InvalidCastException
or Microsoft.CSharp.RuntimeBinder.RuntimeBinderException)
{
Log.Error("Adressdaten konnten nicht gelesen werden; der Cache behaelt " +
$"seinen letzten Stand ({Addresses.Count} Eintraege).", ex);
return;
}
var changed = !addresses.SequenceEqual(Addresses);
Addresses = addresses;
Log.Info($"Adressdaten gelesen: {addresses.Count} Eintraege" +
(changed ? "." : " (unveraendert)."));
AddressesChanged?.Invoke(Addresses);
}
private static List<SwyxContact> ReadAddresses(IClientLineMgrDisp disp)
{
var addresses = new List<SwyxContact>();
if (disp.DispClientConfig is not IClientConfig config)
{
Log.Debug("DispClientConfig liefert keine IClientConfig — keine Adressdaten.");
return addresses;
}
try
{
AppendPhoneBook(config.PbxPhoneBookEnumerator, addresses, "Globales Telefonbuch", hasDescription: true);
AppendPhoneBook(config.UserPhoneBookEnumerator, addresses, "Persoenliches Telefonbuch", hasDescription: false);
}
finally
{
Marshal.FinalReleaseComObject(config);
}
// Stabile Reihenfolge: der Vergleich mit dem Cache und die Anzeige
// sollen nicht davon abhaengen, in welcher Folge CLMgr liefert.
addresses.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
return addresses;
}
/// <summary>
/// Sammelt die Eintraege eines Telefonbuchs ein. Versteckte Eintraege
/// (<c>Hide</c>) bleiben aussen vor; sie zeigt auch SwyxIt! nicht an.
///
/// CLMgr liefert die Telefonbuecher als reine IDispatch-Objekte: weder die
/// Sammlung noch ihre Eintraege implementieren die typisierten
/// Interop-Schnittstellen (<c>IDispCollection</c>,
/// <c>IPbxPhoneBookEntryDisp</c>) — ein Cast darauf scheitert still und
/// ergibt 0 Eintraege. Aufzaehlbar ist die Sammlung aber ueber
/// <c>DISPID_NEWENUM</c> (<see cref="IEnumerable"/>), und die Eigenschaften
/// der Eintraege sind per Late-Binding erreichbar. Gegen SwyxIt! 14.21
/// geprueft: 404 Eintraege im globalen Telefonbuch.
/// </summary>
/// <param name="source">Quelle fuer <see cref="SwyxContact.Description"/>,
/// falls der Eintrag keine eigene Beschreibung hat.</param>
/// <param name="hasDescription">Nur das globale Telefonbuch fuehrt eine
/// <c>Description</c>; beim persoenlichen wuerde der Zugriff scheitern.</param>
private static void AppendPhoneBook(object? raw, List<SwyxContact> addresses, string source, bool hasDescription)
{
if (raw is null)
{
Log.Debug($"{source}: CLMgr liefert keine Sammlung.");
return;
}
if (raw is not IEnumerable collection)
{
Log.Error($"{source}: Sammlung ist nicht aufzaehlbar ({raw.GetType().Name}).");
ReleaseIfCom(raw);
return;
}
try
{
foreach (var item in collection)
{
if (item is null)
{
continue;
}
try
{
dynamic entry = item;
if ((int)entry.Hide != 0)
{
continue;
}
string name = entry.Name as string ?? string.Empty;
string number = entry.Number as string ?? string.Empty;
string description = hasDescription ? (entry.Description as string ?? string.Empty) : string.Empty;
addresses.Add(new SwyxContact(
Name: name,
Number: number,
Description: string.IsNullOrWhiteSpace(description) ? source : description));
}
finally
{
ReleaseIfCom(item);
}
}
}
finally
{
ReleaseIfCom(raw);
}
}
private static void ReleaseIfCom(object obj)
{
if (Marshal.IsComObject(obj))
{
Marshal.FinalReleaseComObject(obj);
}
}
/// <summary>
/// Durchsucht die Adressdaten ueber den Swyx-Client: globales und
/// persoenliches Telefonbuch sowie die im Client eingerichteten
/// Kontakt-Plugins, jeweils auch nach Rufnummern. Die Suche selbst macht
/// der Client — hier wird nur das Ergebnis eingesammelt.
/// </summary>
public IReadOnlyList<SwyxContact> SearchContacts(string? query)
{
var disp = RequireConnection();
var term = (query ?? string.Empty).Trim();
if (term.Length is 0 or > 128)
{
throw new ArgumentException("Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen).");
}
disp.FulltextSearchInContactsEx(term,
bSearchInPhonebook: 1, bSearchInPlugins: 1, bSearchInNumbers: 1,
out var raw);
if (raw is not INameNumberSearchResultCollection collection)
{
return Array.Empty<SwyxContact>();
}
try
{
var contacts = new List<SwyxContact>(Math.Min(collection.Count, MaxContactResults));
foreach (var item in collection)
{
if (item is not INameNumberSearchResult result)
{
continue;
}
try
{
if (contacts.Count >= MaxContactResults)
{
Log.Info($"Kontaktsuche '{term}': mehr als {MaxContactResults} Treffer, " +
"Antwort wird abgeschnitten.");
break;
}
contacts.Add(new SwyxContact(
Name: result.Name ?? string.Empty,
Number: result.Number ?? string.Empty,
Description: result.Description ?? string.Empty));
}
finally
{
Marshal.FinalReleaseComObject(result);
}
}
Log.Debug($"Kontaktsuche '{term}': {contacts.Count} Treffer.");
return contacts;
}
finally
{
Marshal.FinalReleaseComObject(collection);
}
}
/// <summary>
/// Loest eine Rufnummer ueber die Adressdaten des Clients in einen Namen
/// auf — derselbe Weg, ueber den auch SwyxIt! Anrufer beschriftet.
/// Liefert eine leere Zeichenkette, wenn der Client nichts findet.
/// </summary>
public string ResolveNumber(string? number)
{
var disp = RequireConnection();
var dialstring = SanitizeNumber(number);
var resolved = disp.DispResolveNumber(dialstring) ?? string.Empty;
// Kennt der Client die Nummer nicht, gibt er sie unveraendert zurueck —
// das ist kein Name und wird deshalb als "nichts gefunden" gemeldet.
return resolved == dialstring ? string.Empty : resolved;
}
private IClientLineMgrDisp RequireConnection() private IClientLineMgrDisp RequireConnection()
=> _disp ?? throw new InvalidOperationException("Keine Verbindung zu SwyxIt!."); => _disp ?? throw new InvalidOperationException("Keine Verbindung zu SwyxIt!.");
+7
View File
@@ -52,6 +52,13 @@ public sealed record SwyxLineInfo(
public bool IsBusy => !State.IsIdle(); public bool IsBusy => !State.IsIdle();
} }
/// <summary>
/// Ein Adressdatensatz aus der Kontaktsuche des Clients. <c>Description</c>
/// nennt die Quelle bzw. Zusatzangaben, so wie der Client sie liefert
/// (z. B. Telefonbuch- oder Plugin-Herkunft).
/// </summary>
public sealed record SwyxContact(string Name, string Number, string Description);
/// <summary>Momentaufnahme des gesamten Clients.</summary> /// <summary>Momentaufnahme des gesamten Clients.</summary>
public sealed record SwyxSnapshot( public sealed record SwyxSnapshot(
bool IsConnected, bool IsConnected,
+22 -168
View File
@@ -23,21 +23,22 @@ internal sealed class TrayApplicationContext : ApplicationContext
/// </summary> /// </summary>
private static readonly TimeSpan PollingOnlyInterval = TimeSpan.FromSeconds(1); private static readonly TimeSpan PollingOnlyInterval = TimeSpan.FromSeconds(1);
/// <summary>
/// Zyklus fuer das Neueinlesen der Adressdaten aus dem Swyx-Client. Der
/// erste Abruf laeuft direkt beim Verbindungsaufbau (siehe
/// <see cref="SwyxClient"/>); dieser Zyklus haelt den Cache aktuell.
/// </summary>
private static readonly TimeSpan AddressRefreshInterval = TimeSpan.FromMinutes(60);
private readonly NotifyIcon _notifyIcon; private readonly NotifyIcon _notifyIcon;
private readonly SwyxClient _client; private readonly SwyxClient _client;
private readonly System.Windows.Forms.Timer _safetyTimer; private readonly System.Windows.Forms.Timer _safetyTimer;
private readonly System.Windows.Forms.Timer _addressTimer;
private readonly ToolStripMenuItem _statusItem; private readonly ToolStripMenuItem _statusItem;
private readonly ToolStripMenuItem _linesItem; private readonly ToolStripMenuItem _linesItem;
private readonly ToolStripSeparator _linesSeparator; private readonly ToolStripSeparator _linesSeparator;
private readonly ToolStripMenuItem _webSocketItem; private readonly ToolStripMenuItem _webSocketItem;
private readonly ToolStripMenuItem _copyEndpointItem; private readonly ToolStripMenuItem _copyEndpointItem;
private readonly ToolStripMenuItem _readTabsItem;
private readonly ToolStripMenuItem _openTabItem;
private readonly ToolStripMenuItem _closeTabItem;
/// <summary>Ziel der Menuepunkte "Tab oeffnen" und "Tab schliessen".</summary>
private const string AssecutorUrl = "https://www.assecutor.de";
private const string AssecutorTitle = "Assecutor Data Service GmbH";
private LocalWebSocketServer? _server; private LocalWebSocketServer? _server;
private bool _disposed; private bool _disposed;
@@ -58,13 +59,6 @@ internal sealed class TrayApplicationContext : ApplicationContext
(_, _) => OpenWithShell(WebSocketConfig.FilePath)) (_, _) => OpenWithShell(WebSocketConfig.FilePath))
}); });
_readTabsItem = new ToolStripMenuItem(
"Lese Tabs", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, ReadTabsAsync)) { Enabled = false };
_openTabItem = new ToolStripMenuItem(
"Tab oeffnen", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, OpenAssecutorTabAsync)) { Enabled = false };
_closeTabItem = new ToolStripMenuItem(
"Tab schliessen", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, CloseAssecutorTabAsync)) { Enabled = false };
var menu = new ContextMenuStrip(); var menu = new ContextMenuStrip();
menu.Items.AddRange(new ToolStripItem[] menu.Items.AddRange(new ToolStripItem[]
{ {
@@ -72,9 +66,6 @@ internal sealed class TrayApplicationContext : ApplicationContext
_linesItem, _linesItem,
_linesSeparator, _linesSeparator,
_webSocketItem, _webSocketItem,
_readTabsItem,
_openTabItem,
_closeTabItem,
new ToolStripSeparator(), new ToolStripSeparator(),
new ToolStripMenuItem("Neu verbinden", null, (_, _) => Reconnect()), new ToolStripMenuItem("Neu verbinden", null, (_, _) => Reconnect()),
new ToolStripMenuItem("Protokoll oeffnen", null, (_, _) => OpenLog()), new ToolStripMenuItem("Protokoll oeffnen", null, (_, _) => OpenLog()),
@@ -93,6 +84,14 @@ internal sealed class TrayApplicationContext : ApplicationContext
_client = new SwyxClient(); _client = new SwyxClient();
_client.SnapshotChanged += OnSnapshotChanged; _client.SnapshotChanged += OnSnapshotChanged;
_client.AddressesChanged += OnAddressesChanged;
_addressTimer = new System.Windows.Forms.Timer
{
Interval = (int)AddressRefreshInterval.TotalMilliseconds
};
_addressTimer.Tick += (_, _) => _client.RefreshAddresses();
_addressTimer.Start();
_safetyTimer = new System.Windows.Forms.Timer { Interval = (int)SafetyInterval.TotalMilliseconds }; _safetyTimer = new System.Windows.Forms.Timer { Interval = (int)SafetyInterval.TotalMilliseconds };
_safetyTimer.Tick += (_, _) => _safetyTimer.Tick += (_, _) =>
@@ -168,157 +167,6 @@ internal sealed class TrayApplicationContext : ApplicationContext
: "WebSocket: aus (siehe Protokoll)"; : "WebSocket: aus (siehe Protokoll)";
_copyEndpointItem.Enabled = _server is { IsRunning: true }; _copyEndpointItem.Enabled = _server is { IsRunning: true };
_readTabsItem.Enabled = _openTabItem.Enabled = _closeTabItem.Enabled =
_server is { IsRunning: true, PluginPort: > 0 };
}
/// <summary>
/// Gemeinsamer Rahmen der Tab-Menuepunkte: waehrend der Auftrag beim
/// Firefox-Plugin laeuft, ist der Punkt gesperrt — sonst stiesse ein
/// zweiter Klick eine zweite Anfrage an. async void ist hier richtig:
/// ein Menue-Handler hat keinen Aufrufer, der auf ein Task warten koennte.
/// </summary>
private async void RunTabAction(ToolStripMenuItem item, Func<LocalWebSocketServer, Task> action)
{
if (_server is not { IsRunning: true } server)
{
return;
}
item.Enabled = false;
try
{
await action(server);
}
finally
{
if (!_disposed)
{
item.Enabled = true;
}
}
}
/// <summary>
/// Holt die offenen Tabs vom Firefox-Plugin (ueber den Plugin-Port) und
/// zeigt ihre Titel als Sprechblase; die vollstaendige Liste samt Adressen
/// steht im Protokoll.
/// </summary>
private async Task ReadTabsAsync(LocalWebSocketServer server)
{
var tabs = await server.TryListTabsAsync(CancellationToken.None);
if (_disposed)
{
return;
}
if (tabs is null)
{
_notifyIcon.ShowBalloonTip(5_000, "Lese Tabs",
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
return;
}
if (tabs.Count == 0)
{
_notifyIcon.ShowBalloonTip(5_000, "Lese Tabs", "Keine Tabs offen.", ToolTipIcon.Info);
return;
}
Log.Info($"Firefox meldet {tabs.Count} offene(n) Tab(s): " + string.Join(" | ",
tabs.Select(t => $"{(t.Active == true ? "*" : "")}{t.Title} <{t.Url}>")));
// Eine Sprechblase fasst hoechstens 255 Zeichen — die ersten Titel
// genuegen, der Rest steht im Protokoll.
const int maxShown = 5;
var lines = tabs.Take(maxShown)
.Select(t => Truncate((t.Active == true ? "▶ " : "• ") + (t.Title ?? "(ohne Titel)"), 40))
.ToList();
if (tabs.Count > maxShown)
{
lines.Add($"… und {tabs.Count - maxShown} weitere (siehe Protokoll)");
}
_notifyIcon.ShowBalloonTip(10_000, $"{tabs.Count} offene(r) Firefox-Tab(s)",
Truncate(string.Join(Environment.NewLine, lines), 255), ToolTipIcon.Info);
}
/// <summary>Oeffnet ueber das Plugin einen Tab mit der Assecutor-Seite.</summary>
private async Task OpenAssecutorTabAsync(LocalWebSocketServer server)
{
var tabId = await server.TryOpenTabAsync(AssecutorUrl, CancellationToken.None);
if (_disposed)
{
return;
}
if (tabId is null)
{
_notifyIcon.ShowBalloonTip(5_000, "Tab oeffnen",
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
return;
}
Log.Info($"Firefox-Tab {tabId} mit {AssecutorUrl} geoeffnet.");
_notifyIcon.ShowBalloonTip(5_000, "Tab oeffnen", $"{AssecutorUrl} geoeffnet.", ToolTipIcon.Info);
}
/// <summary>
/// Schliesst die Tabs mit dem Assecutor-Titel — wie beim focus-Kommando
/// als Komposition aus den Grundauftraegen des Kanals: erst <c>list</c>,
/// dann je Treffer ein <c>close</c> mit der Tab-Id. So laesst sich auch
/// unterscheiden, ob das Plugin fehlt oder nur kein Tab passt.
/// </summary>
private async Task CloseAssecutorTabAsync(LocalWebSocketServer server)
{
var tabs = await server.TryListTabsAsync(CancellationToken.None);
if (_disposed)
{
return;
}
if (tabs is null)
{
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
return;
}
var matching = tabs
.Where(t => t.Id is not null
&& t.Title?.Contains(AssecutorTitle, StringComparison.OrdinalIgnoreCase) == true)
.ToList();
if (matching.Count == 0)
{
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
$"Kein Tab \"{AssecutorTitle}\" offen.", ToolTipIcon.Warning);
return;
}
var closed = 0;
foreach (var tab in matching)
{
if (await server.TryCloseTabAsync(tab.Id!.Value, CancellationToken.None))
{
closed++;
}
}
if (_disposed)
{
return;
}
Log.Info($"Firefox-Tab(s) \"{AssecutorTitle}\": {closed} von {matching.Count} geschlossen.");
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
closed == matching.Count
? $"Tab \"{AssecutorTitle}\" geschlossen."
: $"Nur {closed} von {matching.Count} Tabs geschlossen (siehe Protokoll).",
closed == matching.Count ? ToolTipIcon.Info : ToolTipIcon.Warning);
} }
/// <summary> /// <summary>
@@ -370,6 +218,9 @@ internal sealed class TrayApplicationContext : ApplicationContext
_server?.Broadcast(previous, current); _server?.Broadcast(previous, current);
} }
private void OnAddressesChanged(IReadOnlyList<SwyxContact> addresses)
=> _server?.BroadcastAddresses(addresses);
private void UpdateIcon(SwyxSnapshot s) private void UpdateIcon(SwyxSnapshot s)
{ {
_notifyIcon.Icon = TrayIcons.For(s.Overall); _notifyIcon.Icon = TrayIcons.For(s.Overall);
@@ -490,8 +341,11 @@ internal sealed class TrayApplicationContext : ApplicationContext
_disposed = true; _disposed = true;
_safetyTimer.Stop(); _safetyTimer.Stop();
_safetyTimer.Dispose(); _safetyTimer.Dispose();
_addressTimer.Stop();
_addressTimer.Dispose();
_server?.Dispose(); _server?.Dispose();
_client.SnapshotChanged -= OnSnapshotChanged; _client.SnapshotChanged -= OnSnapshotChanged;
_client.AddressesChanged -= OnAddressesChanged;
_client.Dispose(); _client.Dispose();
_notifyIcon.Visible = false; _notifyIcon.Visible = false;
_notifyIcon.ContextMenuStrip?.Dispose(); _notifyIcon.ContextMenuStrip?.Dispose();
+20
View File
@@ -30,6 +30,10 @@ internal sealed class CommandExecutor
public Task<SwyxSnapshot> GetSnapshotAsync(CancellationToken cancellationToken) public Task<SwyxSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
=> RunAsync(() => _client.Current, cancellationToken); => RunAsync(() => _client.Current, cancellationToken);
/// <summary>Die zwischengespeicherten Adressdaten aus dem Swyx-Client.</summary>
public Task<IReadOnlyList<SwyxContact>> GetAddressesAsync(CancellationToken cancellationToken)
=> RunAsync(() => _client.Addresses, cancellationToken);
public async Task<ResultMessage> ExecuteAsync(ClientCommand command, CancellationToken cancellationToken) public async Task<ResultMessage> ExecuteAsync(ClientCommand command, CancellationToken cancellationToken)
{ {
try try
@@ -59,6 +63,22 @@ internal sealed class CommandExecutor
Line: await RunAsync(() => _client.Hold(command.Line), cancellationToken) Line: await RunAsync(() => _client.Hold(command.Line), cancellationToken)
.ConfigureAwait(false)); .ConfigureAwait(false));
case "addresses":
return new ResultMessage(command.Id, true,
Addresses: (await GetAddressesAsync(cancellationToken).ConfigureAwait(false))
.Select(ContactMessage.From).ToList());
case "contacts":
return new ResultMessage(command.Id, true,
Contacts: (await RunAsync(() => _client.SearchContacts(command.Query), cancellationToken)
.ConfigureAwait(false))
.Select(ContactMessage.From).ToList());
case "resolve":
return new ResultMessage(command.Id, true,
Name: await RunAsync(() => _client.ResolveNumber(command.Number), cancellationToken)
.ConfigureAwait(false));
case "focus": case "focus":
return new ResultMessage(command.Id, true, return new ResultMessage(command.Id, true,
Focused: await FocusAsync(command, cancellationToken).ConfigureAwait(false)); Focused: await FocusAsync(command, cancellationToken).ConfigureAwait(false));
+25 -21
View File
@@ -59,27 +59,6 @@ internal sealed class LocalWebSocketServer : IDisposable
public bool IsRemote => _config.AllowRemoteAccess; public bool IsRemote => _config.AllowRemoteAccess;
/// <summary>
/// Fragt die offenen Tabs beim Firefox-Plugin ab. <c>null</c>, wenn kein
/// Plugin verbunden ist, keine Antwort kommt oder es den Auftrag ablehnt.
/// </summary>
public Task<IReadOnlyList<TabInfo>?> TryListTabsAsync(CancellationToken cancellationToken)
=> _tabChannel.TryListTabsAsync(cancellationToken);
/// <summary>
/// Oeffnet ueber das Firefox-Plugin einen neuen Tab mit der URL.
/// <c>null</c> bei fehlendem Plugin, Zeitablauf oder Ablehnung.
/// </summary>
public Task<int?> TryOpenTabAsync(string url, CancellationToken cancellationToken)
=> _tabChannel.TryOpenTabAsync(url, cancellationToken);
/// <summary>
/// Schliesst ueber das Firefox-Plugin den Tab mit dieser Id.
/// <c>true</c> nur bei bestaetigtem Erfolg.
/// </summary>
public Task<bool> TryCloseTabAsync(int tabId, CancellationToken cancellationToken)
=> _tabChannel.TryCloseTabAsync(tabId, cancellationToken);
public void Start() public void Start()
{ {
if (!_config.Enabled) if (!_config.Enabled)
@@ -336,6 +315,31 @@ internal sealed class LocalWebSocketServer : IDisposable
/// <summary>Verteilt nur den Zustand, ohne Anruf-Ereignisse abzuleiten.</summary> /// <summary>Verteilt nur den Zustand, ohne Anruf-Ereignisse abzuleiten.</summary>
public void Broadcast(SwyxSnapshot snapshot) => Broadcast(snapshot, snapshot); public void Broadcast(SwyxSnapshot snapshot) => Broadcast(snapshot, snapshot);
/// <summary>
/// Verteilt die neu eingelesenen Adressdaten an alle Verbundenen — nur an
/// den Hauptport, wie die Zustandsmeldungen. Neue Verbindungen bekommen
/// den Cache ohnehin direkt nach dem snapshot zugestellt.
/// </summary>
public void BroadcastAddresses(IReadOnlyList<SwyxContact> addresses)
{
if (_sessions.IsEmpty)
{
return;
}
var message = Protocol.Serialize(AddressesMessage.From(addresses));
foreach (var session in _sessions.Values)
{
if (session.IsPlugin)
{
continue;
}
_ = session.TrySendManyAsync([message], _shutdown.Token);
}
}
/// <summary> /// <summary>
/// Verteilt einen Zustand samt der daraus abgeleiteten Anruf-Ereignisse an /// Verteilt einen Zustand samt der daraus abgeleiteten Anruf-Ereignisse an
/// alle Verbundenen — erst der snapshot, dann die Ereignisse, damit der /// alle Verbundenen — erst der snapshot, dann die Ereignisse, damit der
+33 -3
View File
@@ -29,9 +29,12 @@ internal sealed class ClientCommand
public string? Cmd { get; set; } public string? Cmd { get; set; }
/// <summary>Nur bei <c>call</c>.</summary> /// <summary>Bei <c>call</c> und <c>resolve</c>.</summary>
public string? Number { get; set; } public string? Number { get; set; }
/// <summary>Nur bei <c>contacts</c>: der Suchbegriff (Name oder Nummer).</summary>
public string? Query { get; set; }
/// <summary>1-basierte Leitung; fehlt sie, waehlt die App selbst.</summary> /// <summary>1-basierte Leitung; fehlt sie, waehlt die App selbst.</summary>
public int? Line { get; set; } public int? Line { get; set; }
@@ -71,15 +74,42 @@ internal sealed record HelloMessage(string App, string Version, int Protocol, in
public string Type => "hello"; public string Type => "hello";
} }
/// <summary>Ein Adressdatensatz in der Antwort auf <c>contacts</c> und in
/// der <c>addresses</c>-Nachricht.</summary>
internal sealed record ContactMessage(string Name, string Number, string Description)
{
public static ContactMessage From(SwyxContact contact)
=> new(contact.Name, contact.Number, contact.Description);
}
/// <summary>
/// Die zwischengespeicherten Adressdaten aus dem Swyx-Client (globales und
/// persoenliches Telefonbuch). Wird nach dem <c>snapshot</c> beim Verbinden,
/// auf <c>status</c> und nach jedem Neueinlesen mit Aenderung gesendet — die
/// Seite muss die Daten also nie selbst anfordern.
/// </summary>
internal sealed record AddressesMessage(IReadOnlyList<ContactMessage> Addresses)
{
public string Type => "addresses";
public static AddressesMessage From(IEnumerable<SwyxContact> addresses)
=> new(addresses.Select(ContactMessage.From).ToList());
}
/// <summary> /// <summary>
/// Antwort auf genau ein Kommando. <c>Focused</c> wird nur bei <c>focus</c> /// Antwort auf genau ein Kommando. <c>Focused</c> wird nur bei <c>focus</c>
/// gesetzt: <c>true</c> = Fenster in den Vordergrund geholt, <c>false</c> = /// gesetzt: <c>true</c> = Fenster in den Vordergrund geholt, <c>false</c> =
/// stattdessen den Browser mit der URL gestartet. <c>Tabs</c> steht nur in /// stattdessen den Browser mit der URL gestartet. <c>Tabs</c> steht nur in
/// der Antwort auf <c>tabs</c>, <c>TabId</c> nur in der auf <c>opentab</c>. /// der Antwort auf <c>tabs</c>, <c>TabId</c> nur in der auf <c>opentab</c>,
/// <c>Contacts</c> nur in der auf <c>contacts</c>, <c>Name</c> nur in der
/// auf <c>resolve</c> (leer, wenn der Client die Nummer nicht kennt),
/// <c>Addresses</c> nur in der auf <c>addresses</c> (der komplette Cache).
/// </summary> /// </summary>
internal sealed record ResultMessage( internal sealed record ResultMessage(
int? Id, bool Ok, string? Error = null, int? Line = null, bool? Focused = null, int? Id, bool Ok, string? Error = null, int? Line = null, bool? Focused = null,
int? TabId = null, IReadOnlyList<TabInfo>? Tabs = null) int? TabId = null, IReadOnlyList<TabInfo>? Tabs = null,
IReadOnlyList<ContactMessage>? Contacts = null, string? Name = null,
IReadOnlyList<ContactMessage>? Addresses = null)
{ {
public string Type => "result"; public string Type => "result";
} }
+9 -1
View File
@@ -28,8 +28,16 @@ internal sealed class WebSocketConfig
/// <c>open</c> und <c>close</c> aufgeteilt. /// <c>open</c> und <c>close</c> aufgeteilt.
/// Version 6: die Tab-Verwaltung steht als <c>tabs</c>, <c>opentab</c> /// Version 6: die Tab-Verwaltung steht als <c>tabs</c>, <c>opentab</c>
/// und <c>closetab</c> auch Webseiten auf dem Hauptport offen. /// und <c>closetab</c> auch Webseiten auf dem Hauptport offen.
/// Version 7: Adressdaten aus dem Swyx-Client — <c>contacts</c>
/// (Kontaktsuche in Telefonbuechern und Kontakt-Plugins) und
/// <c>resolve</c> (Rufnummer zu Name).
/// Version 8: <c>addresses</c>-Nachricht mit den zwischengespeicherten
/// Telefonbuecher-Eintraegen — beim Verbinden, auf <c>status</c> und nach
/// jedem Neueinlesen (Start, Neuverbindung, alle 60 Minuten). Dazu das
/// Kommando <c>addresses</c>, mit dem eine Seite den Cache jederzeit
/// selbst abrufen kann.
/// </summary> /// </summary>
public const int ProtocolVersion = 6; public const int ProtocolVersion = 8;
/// <summary> /// <summary>
/// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen — /// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen —
+13 -2
View File
@@ -54,6 +54,7 @@ internal sealed class WebSocketSession : IDisposable
if (!IsPlugin) if (!IsPlugin)
{ {
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false); await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
await SendAddressesAsync(cancellationToken).ConfigureAwait(false);
} }
var buffer = new byte[8 * 1024]; var buffer = new byte[8 * 1024];
@@ -128,11 +129,13 @@ internal sealed class WebSocketSession : IDisposable
return; return;
} }
// 'status' beantwortet der Server mit dem vollen Zustand; die Quittung // 'status' beantwortet der Server mit dem vollen Zustand samt der
// danach haelt den Ablauf fuer den Client einheitlich. // zwischengespeicherten Adressdaten; die Quittung danach haelt den
// Ablauf fuer den Client einheitlich.
if (command.Cmd.Equals("status", StringComparison.OrdinalIgnoreCase)) if (command.Cmd.Equals("status", StringComparison.OrdinalIgnoreCase))
{ {
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false); await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
await SendAddressesAsync(cancellationToken).ConfigureAwait(false);
await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken) await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return; return;
@@ -181,6 +184,14 @@ internal sealed class WebSocketSession : IDisposable
.ConfigureAwait(false); .ConfigureAwait(false);
} }
/// <summary>Stellt der Seite die zwischengespeicherten Adressdaten zu.</summary>
private async Task SendAddressesAsync(CancellationToken cancellationToken)
{
var addresses = await _executor.GetAddressesAsync(cancellationToken).ConfigureAwait(false);
await SendAsync(Protocol.Serialize(AddressesMessage.From(addresses)), cancellationToken)
.ConfigureAwait(false);
}
/// <summary> /// <summary>
/// Auf einem WebSocket darf immer nur ein Sendevorgang gleichzeitig laufen. /// Auf einem WebSocket darf immer nur ein Sendevorgang gleichzeitig laufen.
/// Da Zustandsmeldungen aus dem UI-Thread und Antworten aus dem /// Da Zustandsmeldungen aus dem UI-Thread und Antworten aus dem