SwyxWeb

React-Frontend mit Spring-Boot-Backend. Die Startseite verbindet sich aus dem Browser heraus per WebSocket mit der SwyxTray-App (ws://127.0.0.1:17654/ws), meldet eingehende Anrufe und startet ausgehende Anrufe.

SwyxWeb/
├── backend/    Spring Boot 4.1 (Java 21, Maven Wrapper)
└── frontend/   React 19 + TypeScript + Vite

Wer verbindet sich mit wem?

Die WebSocket-Verbindung wird ausschließlich im Browser aufgebaut in SwyxTrayClient.ts über new WebSocket(url). Das Backend baut keine eigene WebSocket-Verbindung auf; es hat zwei Aufgaben:

  1. GET /api/config liefert dem Frontend die Zieladresse, damit sie nicht im JS-Bundle fest verdrahtet ist (konfigurierbar in application.properties).
  2. Es nimmt unter POST /api/webhook Adressdaten fremder Systeme entgegen und reicht sie an die offenen Browser weiter (siehe Webhook).
  3. Es stellt unter seinem eigenen Port einen SwyxTray-Mock bereit, um die Startseite ohne echte Telefonanlage testen zu können (siehe unten). Der läuft nur mit dem Spring-Profil mock; im Normalbetrieb und damit auch im Container gibt es ihn nicht.

Zwei getrennte Ports: 8080 ist der Port dieser Anwendung (REST-API und, mit Profil mock, der Mock), 17654 der Port der SwyxTray-App auf dem Arbeitsplatz des Anwenders. Sie sind unabhängig voneinander.

Protokoll der SwyxTray-App

Quelle: Mitschnitt gegen die laufende App (Version 1.0.0.0, protocol: 1) am 13.08.2026. Mit der Tab-Verwaltung meldete die App im hello protocol: 6, mit der Adresssuche protocol: 7, mit dem Adress-Cache protocol: 8; die älteren Nachrichten sind unverändert geblieben. Implementiert in protocol.ts und SwyxTrayClient.ts.

Ein Anruf wird über die 1-basierte Leitungsnummer line identifiziert so, wie sie auch in SwyxIt! erscheint. Eine Anruf-Id gibt es nicht.

Die App schickt keine Ereignisnachrichten. Jede Zustandsänderung auch ein eingehender Anruf kommt als vollständiger snapshot über alle vier Leitungen. Die Anrufmeldung der Startseite entsteht deshalb aus dem Vergleich zweier Snapshots (mergeSnapshot).

Begrüßung (App → Seite, beim Verbinden)

{ "app": "SwyxTray", "version": "1.0.0.0", "protocol": 1, "session": 7, "type": "hello" }

Direkt danach folgt der erste Snapshot.

Snapshot (App → Seite, bei jeder Änderung)

{ "connected": true, "serverUp": true, "overall": "Ringing", "statusText": "Eingehender Ruf",
  "user": "Muster, M.", "server": "127.0.0.1", "type": "snapshot",
  "lines": [
    { "line": 1, "state": "LSRinging", "stateCode": 3, "stateText": "klingelt",
      "peer": "001602107449", "peerNumber": "001602107449", "peerName": "",
      "busy": true, "selected": true },
    { "line": 2, "state": "LSInactive", "stateCode": 0, "stateText": "frei",
      "peer": "unbekannt", "peerNumber": "", "peerName": "", "busy": false, "selected": false }
  ] }

state trägt die Namen der CLMgr-Aufzählung LineState, stateText den Klartext aus SwyxIt!. Belegt sind bisher LSInactive / 0 / "frei" und LSRinging / 3 / "klingelt". callStateOf bildet beides auf incoming · outgoing · connected ab und wertet dabei Name und Klartext aus; ein unbekannter Zustand gilt als belegt, damit eine klingelnde Leitung nie stillschweigend verschwindet. peer ist die fertige Anzeigeform, "unbekannt" der Platzhalter für „keine Gegenstelle". Freie Leitungen schickt die App immer mit; die Startseite blendet sie aus.

Kommandos (Seite → App)

// ->
{ "id": 7, "cmd": "call", "number": "+49 30 1234567" }
// <- Quittung, id gespiegelt
{ "id": 7, "ok": true, "line": 2, "type": "result" }
// <- Fehlerfall (ungültige Nummer, keine Verbindung zu SwyxIt!)
{ "id": 7, "ok": false, "error": "…", "type": "result" }

ok: true heißt nur, dass CLMgr den Auftrag angenommen hat gewählt wird asynchron. Der tatsächliche Verlauf kommt danach über die folgenden Snapshots. Die Startseite weist im Wählbereich darauf hin.

Kommando Felder Zweck Antwort
call number Wählvorgang starten line
answer line Anruf annehmen
hangup line Anruf beenden/ablehnen
ping Verbindungstest
status Snapshot anfordern
focus title, url Fenster in den Vordergrund holen focused
tabs Offene Tabs des Firefox-Plugins auflisten tabs
opentab url Neuen Tab öffnen tabId
closetab tabId Tab schließen
contacts query Adressdaten des Swyx-Clients durchsuchen contacts
addresses Adress-Cache der App am Stück abrufen addresses

Alles andere quittiert die App mit "Unbekanntes Kommando '…'.", ein fehlendes cmd mit "Feld 'cmd' fehlt.".

Fenster in den Vordergrund holen

// ->
{ "id": 9, "cmd": "focus", "title": "Kundenakte Muster GmbH", "url": "https://crm.example.local/kunden/4711" }
// <- Fenster war da und ist jetzt vorn
{ "type": "result", "id": 9, "ok": true, "focused": true }
// <- kein Fenster gefunden, stattdessen Browser mit der URL gestartet
{ "type": "result", "id": 9, "ok": true, "focused": false }

title wird als Teilzeichenkette gesucht, unabhängig von der Groß-/Kleinschreibung "swyxit" findet also das Fenster „SwyxIt!". url ist optional und wird nur benutzt, wenn kein Fenster passt; fehlt sie in diesem Fall, antwortet die App mit ok: false und "Kein Fenster mit Titel '…' gefunden und keine URL angegeben.". Ein fehlender oder leerer Titel ergibt "Feld 'title' fehlt.".

Seit der Umstellung des Tab-Bereichs benutzt die Startseite focus nicht mehr; das Kommando bleibt aber im Protokoll und in SwyxTrayClient.focusWindow() erhalten.

Tabs des Firefox-Plugins

Die drei Tab-Kommandos reicht die SwyxTray-App unverändert an das Firefox-Plugin durch gemeint sind also die Tabs des Firefox auf dem Rechner der App, nicht die Bereiche der Startseite. Es sind dieselben Kanäle, die auch die Tray-Menüpunkte Lese Tabs, Tab öffnen und Tab schliessen benutzen.

// ->
{ "id": 12, "cmd": "tabs" }
// <-
{ "type": "result", "id": 12, "ok": true, "tabs": [
    { "id": 43, "title": "Kundenakte", "url": "https://crm.example.local/kunden/4711", "active": true },
    { "id": 44, "title": "SwyxWeb", "url": "http://localhost:5173/", "active": false } ] }

// ->
{ "id": 13, "cmd": "opentab", "url": "https://crm.example.local/kunden/4711" }
// <-
{ "type": "result", "id": 13, "ok": true, "tabId": 43 }

// ->  tabId stammt aus einer tabs-Antwort
{ "id": 14, "cmd": "closetab", "tabId": 43 }
// <-
{ "type": "result", "id": 14, "ok": true }

url muss eine absolute http(s)-Adresse sein dieselbe Prüfung wie bei focus; sonst antwortet die App mit ok: false. Ist kein Plugin verbunden, endet jedes der drei Kommandos nach dem Zeitfenster von rund fünf Sekunden ebenfalls mit ok: false. Deshalb wartet die Startseite auf diese drei Kommandos 15 statt 10 Sekunden (SwyxTrayClient.ts).

Auch hier gilt: die App meldet Tab-Änderungen nicht von selbst. Die Liste wird nach jedem Öffnen und Schließen neu über tabs geholt.

Adressdaten des Swyx-Clients

Seit Protokoll 7 gibt die App das globale Telefonbuch der Anlage heraus dieselben Einträge, die SwyxIt! im Adressbuch zeigt.

// ->
{ "id": 20, "cmd": "contacts", "query": "abt" }
// <-
{ "type": "result", "id": 20, "ok": true, "contacts": [
    { "name": "Abt, Bettina", "number": "7587", "description": "S-SB" },
    { "name": "Abdul, Rokhsareh", "number": "5215", "description": "" } ] }

Wichtig für die Anzeige alles gegen die laufende App sondiert:

  • query ist Pflicht. Ein fehlender, leerer oder nur aus Leerzeichen bestehender Begriff und einer über 128 Zeichen ergeben dieselbe Meldung: "Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen)." Ein vollständiges Auflisten gibt es nicht; der Bereich der Startseite ist deshalb eine Suche.
  • Gesucht wird als Teilzeichenkette in Name und Rufnummer, Groß-/Kleinschreibung egal. Die description (Standortkürzel wie „HH-MT") wird nicht durchsucht.
  • Sortiert nach Namen, höchstens 100 Treffer gekürzt wird stillschweigend, es gibt kein Feld dafür. Ein limit im Kommando ignoriert die App.
  • description ist bei vielen Einträgen "". Namen sind nicht eindeutig: dieselbe Person kommt mit mehreren Durchwahlen vor.
  • query muss eine Zeichenkette sein; eine Zahl quittiert die App mit "Ungueltiges JSON." und zwar ohne id, die Antwort lässt sich also keinem Kommando zuordnen. SwyxTrayClient.searchContacts() schickt deshalb immer Text.

Die number ist die Durchwahl in der Form, die call direkt annimmt die Einträge im Bereich „Adressdaten" haben darum einen „Anrufen"-Knopf.

Adress-Cache (Protokoll 8)

Seit Protokoll 8 hält die App die Adressdaten selbst vor und gibt sie am Stück heraus ohne Suchbegriff und ohne Deckel. Das ersetzt im Normalfall die 110 Einzelabfragen des nächsten Abschnitts.

// ->
{ "id": 5, "cmd": "addresses" }
// <-
{ "type": "result", "id": 5, "ok": true,
  "addresses": [ { "name": "Muster GmbH", "number": "+493012345",
                   "description": "Globales Telefonbuch" } ] }

Dieselben Daten schickt die App außerdem unaufgefordert, direkt nach dem ersten Snapshot und wenn sich ihr Cache ändert:

{ "type": "addresses", "addresses": [  ] }

Diese Push-Nachricht ist die einzige Nachricht der App ohne id und ohne ok sie ist damit keine Quittung und wird von parseAddresses gelesen, nicht von parseResult. Die Einträge tragen dieselben Felder wie bei contacts.

Ein leeres addresses ist ein gültiger Zustand und heißt „Cache ist leer" nicht „keine Antwort". Stand 21.08.2026 meldet die Anlage genau das: das Kommando wird mit ok: true beantwortet, der Cache ist aber leer, und der Push beim Verbinden trägt []. Deshalb bleiben die Einzelabfragen als Rückfallebene bestehen.

Rückfallebene: den Gesamtbestand aus Einzelabfragen holen

Kennt die App das Kommando addresses nicht oder ist ihr Cache leer, setzt directory.ts den Bestand aus vielen contacts-Abfragen zusammen. Der Trick steckt in der Wahl der Suchbegriffe:

Jeder Eintrag hat eine Rufnummer, und die ist eine reine Ziffernfolge. Eine Nummer mit mindestens zwei Ziffern enthält deshalb mindestens eines der hundert Paare "00""99". Die Vereinigung dieser hundert Abfragen ist damit nachweislich vollständig solange keine davon am Deckel hängt.

Dazu kommen die zehn einstelligen Abfragen "0""9": eine einstellige Rufnummer (etwa 0 für eine Zentrale) käme in keinem Paar vor. Macht 110 Abfragen.

Gegen die Anlage gemessen (21.08.2026):

Einträge insgesamt 404
Abfragen 110 (keine Verfeinerung nötig)
größte Trefferzahl eines Ziffernpaars 59 also weit unter dem Deckel von 100
einstellige Abfragen am Deckel 7 von 10 (deshalb genügen sie allein nicht)
Dauer rund 6,7 s
Rufnummern durchgehend 4- bis 6-stellig, rein numerisch

Die App beantwortet die Abfragen nacheinander; mehrere gleichzeitig zu schicken bringt kaum etwas (110 Abfragen: 7,2 s einzeln, 6,3 s zu viert). Deshalb der Wartedialog.

Was passiert, wenn doch eine Abfrage gekürzt wird? Dann verfeinert der Durchlauf sie vorn und hinten je eine Ziffer angehängt, denn eine Teilzeichenkette kann an beiden Enden weitergehen. Das erfasst jeden Eintrag, dessen Rufnummer länger ist als die gekappte Abfrage. Bleibt eine Abfrage auch dreistellig noch gekürzt, meldet der Bereich den Bestand ausdrücklich als unvollständig, statt eine gekürzte Liste als vollständig auszugeben.

Die eine verbleibende Lücke, offen benannt: Ein Eintrag, dessen Rufnummer genau einer gekappten Abfrage entspricht, ließe sich nicht nachladen. Bei vier- bis sechsstelligen Rufnummern betrifft das nur den Fall einer einstelligen Rufnummer bei gleichzeitig gekappter einstelliger Abfrage.

Die Regeln des Protokolls stehen in protocol.test.ts, die des Gesamtabrufs in directory.test.ts darunter die wörtlich mitgeschnittene Nachricht eines echten eingehenden Anrufs.

Funktionen der Startseite

Die Seite ist in fünf Bereiche aufgeteilt (Tabs.tsx):

Tab Inhalt
Anrufe Rufnummer wählen (call), belegte Leitungen mit Aktionen und dem Zustand der App (statusText, angemeldeter Benutzer, Warnung bei connected: false), Verlauf der Anrufereignisse
Adressdaten Der gesamte Bestand des Telefonbuchs. Zuerst aus dem Adress-Cache, den die App beim Verbinden von selbst schickt dann steht er sofort und ohne Wartedialog. Sonst über addresses, und erst wenn auch das nichts liefert, über rund 110 Einzelabfragen hinter einem Wartedialog mit Fortschritt und „Abbrechen". Die Anzeige nennt die Herkunft. Danach liegt alles im Browser: das Filtern über Name, Durchwahl und Standortkürzel läuft ohne weitere Abfrage. Ein Klick auf „Anrufen" wählt die Durchwahl (call)
Webhook Was ein fremdes System an POST /api/webhook geschickt hat jüngste Nachricht zuerst, jede mit ihrem JSON im Original. Die Seite hört über Server-Sent Events mit, unabhängig vom geöffneten Bereich: Eine Nachricht erscheint sofort, und ist gerade ein anderer Bereich offen, zeigt der Tab die Zahl der ungesehenen Nachrichten
Tabs Offene Tabs des Firefox-Plugins anzeigen (tabs), einzeln schließen (closetab) und eine URL als neuen Tab öffnen (opentab); die Liste wird beim ersten Öffnen des Bereichs und nach jeder Änderung geholt, die eingetippte URL übersteht einen Reload (localStorage)
Verbindung Adresse, Verbinden/Trennen, Benachrichtigungen erlauben, Diagnose: Rohnachrichten-Log, Status abfragen, Freitext senden

Die Meldung eines eingehenden Anrufs steht bewusst über den Tabs mit Leitungsnummer, Name und Rufnummer, „Annehmen"/„Ablehnen" , damit sie in keinem Bereich untergeht. Liegt der Browser-Tab im Hintergrund, kommt zusätzlich eine System-Benachrichtigung (nach Erlaubnis).

Weiteres:

  • Der Verlauf entsteht aus dem Vergleich aufeinanderfolgender Snapshots.
  • Eine Nachricht, die keinem der drei Typen entspricht, wird im Log ausdrücklich als „nicht erkannt" vermerkt statt still verworfen.
  • Automatischer Reconnect mit exponentiellem Backoff, nur nach unerwartetem Abbruch.

Webhook für Adressdaten fremder Systeme

Ein anderes System kann Adressdaten per POST an die Anwendung schicken; die Startseite zeigt sie im Bereich Webhook an. Anders als alles Übrige läuft das nicht über die SwyxTray-App, sondern allein über das Backend.

curl -X POST https://swyxweb.appcreation.de/api/webhook \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Token: …" \
  -d '{"name":"Muster GmbH","number":"+493012345","description":"CRM-Import"}'

Die Quittung nennt die vergebene Nummer und die Eingangszeit:

{ "id": 1, "receivedAt": "2026-08-24T12:38:01.563742294Z" }

Angenommen wird beliebiges JSON Objekt wie Liste. Das aufrufende System muss sich an kein Schema halten; die Startseite zeigt die Nutzlast unverändert. Nur die Kopfzeile eines Eintrags fasst name und number zusammen, wenn es sie gibt, und zählt sonst Felder bzw. Einträge (summarize).

Endpunkt Zweck
POST /api/webhook Nachricht abliefern. 415 bei falschem Content-Type, 400 bei kaputtem JSON, 413 über der Größengrenze, 401 bei falschem Token
GET /api/webhook/events Strom für die Startseite (Server-Sent Events)
GET /api/webhook/history Verlauf, damit ein später geöffneter Bereich die vorigen Nachrichten sieht
DELETE /api/webhook/history Verlauf leeren das macht der Knopf „Leeren"

Wie die Nachricht in den Browser kommt

Über Server-Sent Events, nicht über den WebSocket: Der gehört der SwyxTray-App. Die Startseite hält eine EventSource auf /api/webhook/events offen (useWebhook.ts) einseitig, über dieselbe HTTPS-Verbindung wie die Seite, und der Browser baut sie nach einem Abbruch von selbst wieder auf.

Zwei Vorkehrungen, damit dabei nichts verloren geht:

  • Das Backend hält die letzten 50 Nachrichten im Speicher (WebhookService). Beim Wiederverbinden schickt der Browser die Last-Event-ID mit, und alles Jüngere wird nachgeliefert. Über einen Neustart hinaus wird nichts aufgehoben.
  • Alle 25 Sekunden geht ein Kommentar (:ping) durch die Leitung, damit ein Reverse Proxy die ruhende Verbindung nicht kappt.

Die Antwort trägt außerdem X-Accel-Buffering: no und Cache-Control: no-cache. Ohne den ersten Kopf sammelt nginx die Ereignisse in seinem Puffer und gibt sie erst später aus die Nachricht erschiene dann verspätet oder scheinbar gar nicht. nginx wertet ihn aus, andere Proxys ignorieren ihn folgenlos. Steht vor der Anwendung etwas anderes als nginx, muss dort die Pufferung für /api/webhook/events abgeschaltet sein (bei Apache mod_proxy: flushpackets=on).

Doppelte Nachrichten sind dadurch normal der Bereich holt beim Start den Verlauf und hört auf den Strom. Entschieden wird über die id (mergeEvent).

Token

app.webhook.token=            # leer ⇒ jeder Aufruf wird angenommen
app.webhook.history=50        # so viele Nachrichten bleiben im Speicher
app.webhook.max-size=262144   # größte Nutzlast in Byte

Für die öffentlich erreichbare Instanz gehört hier ein Geheimnis hinein. /api/webhook liegt unter derselben Adresse wie die Startseite und ist damit aus dem Internet erreichbar; ohne Token kann jeder beliebiges JSON in die Anzeige schreiben. Ist der Wert leer, weist das Backend beim Start ausdrücklich darauf hin (WARN).

Im Container:

docker run -d -p 8080:8080 -e APP_WEBHOOK_TOKEN=… … gitea.appcreation.de/sven/swyxweb:0.9.13

Das Token gilt nur für POST. Verlauf und Ereignisstrom bleiben offen sie liefern dasselbe, was die Seite ohnehin jedem Betrachter zeigt.

Starten

Aus VS Code

Im Debug-Panel die Compound-Konfiguration „SwyxWeb starten (Backend + Frontend)" wählen (launch.json). Sie startet das Backend im Debugger, danach den Vite-Dev-Server und öffnet Chrome, sobald dieser bereit ist Breakpoints funktionieren auf beiden Seiten. „Stop" beendet beides zusammen.

Alternative „SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)": startet das Backend mit dem Profil mock, /api/config liefert dann ws://localhost:8080/ws, die Startseite spricht also mit dem SwyxTray-Mock.

Benötigte Extensions: Extension Pack for Java (Backend) und JavaScript Debugger (im VS Code enthalten, für Vite und Chrome).

Backend

cd backend
./mvnw spring-boot:run

Läuft auf http://0.0.0.0:8080. Achtung: Auf diesem Rechner belegt bereits ein anderes Projekt Port 8080 dann auf einen freien Port ausweichen:

./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=8081"

Der Port der SwyxTray-App (17654) ändert sich dadurch nicht.

Frontend

cd frontend
npm install
npm run dev

Öffnet http://localhost:5173. Der Vite-Dev-Server leitet /api an http://localhost:8080 weiter (überschreibbar per VITE_BACKEND_URL). Die WebSocket-Verbindung läuft nicht über diesen Proxy der Browser verbindet sich direkt mit der SwyxTray-App.

Ohne echte Telefonanlage testen

Der Mock ist Opt-in. Sein WebSocket-Endpunkt /ws und seine Steuer-Endpunkte /api/mock/** hängen am Spring-Profil mock (MockProfile) ohne das Profil existieren die Bohnen nicht und beide Pfade antworten mit 404. So kann der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das Container-Image setzt das Profil bewusst nicht. Einschalten:

cd backend && ./mvnw spring-boot:run -Dspring-boot.run.profiles=mock
java -jar app.jar --spring.profiles.active=mock      # oder SPRING_PROFILES_ACTIVE=mock

Abgesichert ist beides durch je einen Test in BackendApplicationTests (ohne Profil keine Mock-Bohne) und MockProfileTests (mit Profil alle da).

Der SwyxTrayMockHandler spricht dasselbe Protokoll: hello (mit protocol: 8), Snapshot und Adress-Cache beim Verbinden, Quittungen auf call/answer/hangup/ping/status/focus/tabs/opentab/closetab/contacts/addresses und nach jeder Änderung einen vollständigen Snapshot über alle vier Leitungen. Einen eingehenden Anruf auslösen:

curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"

Für focus täuscht der Mock die Fenster „SwyxIt!", „SwyxWeb Google Chrome" und „Kundenakte Muster GmbH" vor (Teilzeichenkette, Schreibweise egal). Ein passender Titel ergibt focused: true, ein unbekannter mit URL focused: false, ein unbekannter ohne URL denselben Fehler wie die echte App. Geöffnet wird natürlich nichts der Mock protokolliert nur.

Für die Tab-Kommandos spielt der Mock das Firefox-Plugin und führt eine Tabliste im Speicher anfangs „SwyxWeb" und „Kundenakte Muster GmbH". opentab hängt einen Tab an und liefert dessen tabId, closetab entfernt ihn; eine unbekannte Kennung ergibt "Kein Tab mit der Kennung …", eine relative Adresse denselben Fehler wie die echte App. Geöffnet wird auch hier nichts. Der Mock bildet den Fall „kein Plugin verbunden" nicht nach; er antwortet immer sofort.

Für contacts führt der Mock ein Telefonbuch mit 154 Einträgen groß genug, dass der Bereich „Adressdaten" dieselbe Arbeit leisten muss wie gegen die Anlage. Drei Sonderfälle sind absichtlich dabei: zweimal „Muster, Max" mit verschiedenen Durchwahlen (Namen sind nicht eindeutig), Einträge ohne Beschreibung, und „Zentrale" mit der Rufnummer 0 die steckt in keinem Ziffernpaar und wird nur über die einstelligen Abfragen gefunden.

Er hält sich an dieselben Regeln wie die echte App: Teilzeichenkette in Name und Rufnummer, Schreibweise egal, nach Namen sortiert, gekappt bei 100 Treffern, und derselbe Fehlertext für einen fehlenden, leeren oder zu langen Suchbegriff.

Denselben Bestand gibt der Mock als Adress-Cache heraus auf addresses und beim Verbinden als Push. Der Cache lässt sich leeren, um die Rückfallebene zu prüfen; dann verhält er sich wie die Anlage zurzeit:

curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false"   # -> Einzelabfragen
curl -X POST "http://localhost:8080/api/mock/address-cache?filled=true"    # -> Cache

Jedes Umschalten schickt allen Verbundenen sofort einen neuen Push.

Die Startseite muss dazu auf ws://localhost:8080/ws zeigen entweder im Adressfeld eintragen oder das Backend mit --spring.profiles.active=mock --app.websocket.host=localhost --app.websocket.port=8080 starten (macht die VS-Code-Konfiguration „lokaler Test" automatisch).

Konfiguration der WebSocket-Adresse

Es gilt die erste Quelle, die etwas liefert:

Priorität Quelle Beispiel
1 Eingabefeld auf der Startseite zur Laufzeit änderbar
2 GET /api/config vom Backend app.websocket.host in application.properties
3 VITE_WS_URL aus frontend/.env siehe .env.example
4 Default im Code ws://127.0.0.1:17654/ws

Backend-seitig:

server.port=8080                     # Port dieser Anwendung

app.websocket.host=127.0.0.1   # SwyxTray-App
app.websocket.port=17654
app.websocket.path=/ws
app.websocket.secure=false           # true ⇒ wss://

Build und Tests

cd backend  && ./mvnw clean package     # erzeugt target/backend-0.0.1-SNAPSHOT.jar
cd frontend && npm test && npm run build

Um das Frontend aus dem Backend auszuliefern, frontend/dist/* nach backend/src/main/resources/static/ kopieren und neu packen.

Deployment als Docker-Container

Das Dockerfile im Wurzelverzeichnis erledigt genau das in drei Stufen: Frontend mit Node bauen, das Ergebnis in backend/src/main/resources/static/ legen und mit Maven zusammen zum Jar packen, dieses in ein schlankes JRE-Image kopieren. Herauskommt ein Container, der Startseite und REST-API über Port 8080 ausliefert als Benutzer swyx, nicht als root, mit einem Healthcheck gegen /api/config.

docker build -t swyxweb .
docker run -d -p 8080:8080 \
  -e APP_WEBSOCKET_HOST=127.0.0.1 \
  -e APP_WEBSOCKET_PORT=17654 \
  --name swyxweb swyxweb

Alle app.websocket.*-Werte sind zur Laufzeit über Umgebungsvariablen setzbar (APP_WEBSOCKET_HOST, _PORT, _PATH, _SECURE), das Bundle muss dafür nicht neu gebaut werden. Der Heap wächst über -XX:MaxRAMPercentage=75 mit dem Speicherlimit des Containers mit; überschreiben lässt sich das mit -e JAVA_OPTS=…. Soll die WebSocket-Adresse ausnahmsweise fest ins Bundle, geht das beim Bauen mit --build-arg VITE_WS_URL=ws://…/ws.

In die Registry schieben erledigt docker_push.sh es baut für linux/amd64 und pusht in die Paket-Registry des Gitea nach gitea.appcreation.de/sven/swyxweb (REGISTRY_IMAGE im Skript). Die Versionsnummer ist Pflicht und wird zum Tag des Images; sie wird bewusst nicht aus der pom.xml abgeleitet:

docker login gitea.appcreation.de
./docker_push.sh 0.9.13             # baut und pusht :0.9.13
./docker_push.sh 0.9.13 --dry-run   # nur bauen, nicht pushen

Ohne Argument bricht das Skript mit der Kurzhilfe ab. Zulässig ist das Format x.y.z.

Ein lokaler Maven- oder npm-Lauf ist vorher nicht nötig; beides passiert im Image-Build. Die beiden Build-Stufen laufen dabei nativ auf der Architektur des bauenden Rechners (--platform=$BUILDPLATFORM) JS-Bundle und Jar sind architekturunabhängig, nur das Laufzeit-Image wird für linux/amd64 gezogen. Auf einem ARM-Mac spart das die Emulation.

Der SwyxTray-Mock ist nicht dabei: SPRING_PROFILES_ACTIVE bleibt leer, also fehlen /ws und /api/mock/** (siehe Ohne echte Telefonanlage testen). Für eine Vorführung ohne Telefonanlage lässt er sich am einzelnen Container einschalten: -e SPRING_PROFILES_ACTIVE=mock.

Zwei Dinge, die der Container nicht lösen kann:

  • Läuft die Seite hinter HTTPS, blockiert der Browser die unverschlüsselte Verbindung zur SwyxTray-App. Dann braucht die App selbst TLS und APP_WEBSOCKET_SECURE=true (⇒ wss://).
  • Die WebSocket-Verbindung baut der Browser des Anwenders auf, nicht der Container. Port 17654 muss also vom Arbeitsplatz aus erreichbar sein, nicht vom Docker-Host.

Hinweise

  • HTTPS-Seite ⇒ wss://: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte ws://-Verbindungen (Mixed Content). Im Dev-Betrieb über http:// ist ws:// in Ordnung.
  • CORS/Origin: Der Mock akzeptiert alle Origins (setAllowedOriginPatterns("*")), damit der Vite-Dev-Server auf Port 5173 sich verbinden kann. Für die Produktion einschränken siehe WebSocketConfig.java und CorsConfig.java.
  • Firewall: Die Verbindung geht per Vorgabe an 127.0.0.1, bleibt also auf dem Rechner und braucht keine Freigabe. Zeigt die Adresse auf einen anderen Rechner, muss dort Port 17654 eingehend freigegeben sein sonst scheitert der Verbindungsaufbau ohne aussagekräftige Browser-Fehlermeldung.
  • System-Benachrichtigungen verlangen eine Benutzerinteraktion zur Erlaubniserteilung und funktionieren nur über https:// oder localhost.
S
Description
No description provided
Readme
417 KiB
Languages
TypeScript 53.7%
Java 37.7%
CSS 6.1%
Dockerfile 1.1%
Shell 0.9%
Other 0.5%