Files
swyxweb/frontend/src/swyx/SwyxTrayClient.ts
T
SvenandClaude Opus 5 01594a1f3d Adressdaten-Bereich, Mock hinter einem Profil, Webhook
Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css,
README, application.properties) und deshalb nicht getrennt committet werden
können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen:

Adressdaten: Der Bereich zeigt den gesamten Bestand des globalen Telefon-
buchs - zuerst aus dem Adress-Cache der App (Protokoll 8), sonst über das
Kommando "addresses" und ersatzweise aus rund 110 Einzelabfragen hinter
einem Wartedialog. Der Mock gibt denselben Bestand heraus und lässt sich
über /api/mock/address-cache leeren, um die Rückfallebene zu prüfen.

Mock hinter dem Profil "mock": Seine Bohnen (/ws und /api/mock/**) hängen
jetzt an @Profile, sind ohne das Profil also nicht vorhanden. Damit kann
der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das
Container-Image setzt das Profil nicht. Je ein Test hält beide Richtungen
fest.

Webhook: POST /api/webhook nimmt beliebiges JSON eines fremden Systems an
und reicht es über Server-Sent Events an die offenen Browser weiter, wo es
der neue Bereich "Webhook" unverändert anzeigt. Der WebSocket kam dafür
nicht in Frage - er gehört der SwyxTray-App. Das Backend hält die letzten
50 Nachrichten vor und liefert sie beim Wiederverbinden anhand der
Last-Event-ID nach; ein Heartbeat und X-Accel-Buffering: no halten die
Verbindung durch Reverse Proxys hindurch offen. Ein Token (app.webhook.token)
ist vorgesehen, aber nicht voreingestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:11:55 +02:00

400 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
COMMANDS,
CONTACT_QUERY_MAX_LENGTH,
parseAddresses,
parseHello,
parseResult,
parseSnapshot,
type Contact,
type HelloMessage,
type ResultMessage,
type SnapshotMessage,
} from './protocol'
export type ConnectionStatus = 'idle' | 'connecting' | 'open' | 'closing' | 'closed'
export interface RawMessage {
direction: 'in' | 'out' | 'system'
text: string
at: Date
}
interface Listeners {
status: (status: ConnectionStatus) => void
hello: (hello: HelloMessage) => void
snapshot: (snapshot: SnapshotMessage) => void
/** Unaufgeforderter Adress-Cache der App; auch eine leere Liste zählt. */
addresses: (contacts: Contact[]) => void
raw: (message: RawMessage) => void
error: (message: string) => void
}
interface Pending {
resolve: (value: ResultMessage) => void
reject: (reason: Error) => void
timer: ReturnType<typeof setTimeout>
}
const CLOSE_CODE_NORMAL = 1000
const DEFAULT_TIMEOUT_MS = 10000
// Die Tab-Kommandos gehen ans Firefox-Plugin weiter; die App wartet darauf
// selbst fünf Sekunden, bevor sie mit einem Fehler antwortet.
const TAB_TIMEOUT_MS = 15000
/**
* WebSocket-Client für die SwyxTray-App. Die Verbindung wird im Browser
* aufgebaut; Quittungen werden über die numerische `id` zugeordnet,
* unaufgeforderte Anruf-Ereignisse an registrierte Hörer gemeldet.
*/
export class SwyxTrayClient {
private url: string | null = null
private socket: WebSocket | null = null
private nextId = 1
private readonly pending = new Map<number, Pending>()
private readonly listeners: { [K in keyof Listeners]: Set<Listeners[K]> } = {
status: new Set(),
hello: new Set(),
snapshot: new Set(),
addresses: new Set(),
raw: new Set(),
error: new Set(),
}
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private attempt = 0
// Wunsch des Benutzers: nach manuellem Trennen nicht erneut verbinden.
private desiredConnected = false
constructor(
private readonly reconnectDelayMs = 1000,
private readonly maxReconnectDelayMs = 15000,
) {}
on<K extends keyof Listeners>(event: K, listener: Listeners[K]): () => void {
this.listeners[event].add(listener as never)
return () => {
this.listeners[event].delete(listener as never)
}
}
isConnected(): boolean {
return this.socket?.readyState === WebSocket.OPEN
}
connect(url: string): void {
this.clearReconnectTimer()
this.desiredConnected = true
this.url = url
this.closeSocket('Neuverbindung')
this.open()
}
disconnect(): void {
this.desiredConnected = false
this.clearReconnectTimer()
this.attempt = 0
if (!this.socket) {
this.emit('status', 'closed')
return
}
this.emit('status', 'closing')
this.socket.close(CLOSE_CODE_NORMAL, 'Vom Benutzer getrennt')
}
/**
* Sendet ein Kommando und löst mit dessen Quittung auf. Die Felder des
* Kommandos liegen flach neben `id` und `cmd`.
*/
send(
cmd: string,
payload: Record<string, unknown> = {},
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<ResultMessage> {
return new Promise((resolve, reject) => {
if (!this.isConnected()) {
reject(new Error('Keine Verbindung zur SwyxTray-App.'))
return
}
const id = this.nextId++
const timer = setTimeout(() => {
this.pending.delete(id)
reject(new Error(`Zeitüberschreitung für Kommando '${cmd}'.`))
}, timeoutMs)
this.pending.set(id, { resolve, reject, timer })
const message = JSON.stringify({ id, cmd, ...payload })
this.socket!.send(message)
this.emit('raw', { direction: 'out', text: message, at: new Date() })
})
}
/** Freitext senden nur für den Diagnosebereich der Startseite. */
sendRaw(text: string): void {
if (!this.isConnected()) {
throw new Error('Keine Verbindung zur SwyxTray-App.')
}
this.socket!.send(text)
this.emit('raw', { direction: 'out', text, at: new Date() })
}
/**
* Startet einen Wählvorgang. Die Quittung bestätigt nur die Annahme des
* Auftrags der Verlauf kommt anschließend als Push-Nachricht.
*/
dial(number: string): Promise<ResultMessage> {
return this.send(COMMANDS.call, { number })
}
answer(line: number): Promise<ResultMessage> {
return this.send(COMMANDS.answer, { line })
}
hangup(line: number): Promise<ResultMessage> {
return this.send(COMMANDS.hangup, { line })
}
/**
* Holt auf dem Rechner der App das Fenster nach vorn, dessen Titel `title`
* enthält. Ohne Treffer öffnet die App `url` im Standardbrowser; fehlt auch
* die, antwortet sie mit einem Fehler.
*/
focusWindow(title: string, url?: string): Promise<ResultMessage> {
const payload: Record<string, unknown> = { title }
// Leeres Feld gar nicht erst mitschicken die App entscheidet an seinem
// Vorhandensein, ob sie ersatzweise den Browser startet.
if (url) payload.url = url
return this.send(COMMANDS.focus, payload)
}
/**
* Fragt die offenen Tabs des Firefox-Plugins ab. Die App reicht das Kommando
* nur durch; ohne verbundenes Plugin antwortet sie nach ihrem Zeitfenster von
* fünf Sekunden mit einem Fehler.
*/
listTabs(): Promise<ResultMessage> {
return this.send(COMMANDS.tabs, {}, TAB_TIMEOUT_MS)
}
/** Öffnet `url` als neuen Tab; die Quittung trägt dessen Kennung in `tabId`. */
openTab(url: string): Promise<ResultMessage> {
return this.send(COMMANDS.openTab, { url }, TAB_TIMEOUT_MS)
}
/** Schließt den Tab mit dieser Kennung sie stammt aus einer `tabs`-Antwort. */
closeTab(tabId: number): Promise<ResultMessage> {
return this.send(COMMANDS.closeTab, { tabId }, TAB_TIMEOUT_MS)
}
/**
* Durchsucht die Adressdaten des Swyx-Clients. Gesucht wird als
* Teilzeichenkette in Name und Rufnummer; die App liefert höchstens 100
* Treffer und kürzt ohne Hinweis. Ein leerer
* Suchbegriff wäre ein Fehler der App er wird hier gar nicht erst gesendet.
*/
searchContacts(query: string): Promise<ResultMessage> {
const wanted = query.trim()
if (!wanted) {
return Promise.reject(new Error('Bitte einen Suchbegriff angeben.'))
}
if (wanted.length > CONTACT_QUERY_MAX_LENGTH) {
return Promise.reject(
new Error(`Der Suchbegriff darf höchstens ${CONTACT_QUERY_MAX_LENGTH} Zeichen lang sein.`),
)
}
return this.send(COMMANDS.contacts, { query: wanted })
}
/**
* Holt den Adress-Cache der App am Stück ohne Suchbegriff und ohne Deckel.
* Ältere App-Versionen kennen das Kommando nicht und antworten mit
* `"Unbekanntes Kommando 'addresses'."`; die Startseite weicht dann auf die
* Einzelabfragen aus.
*/
listAddresses(): Promise<ResultMessage> {
return this.send(COMMANDS.addresses)
}
ping(): Promise<ResultMessage> {
return this.send(COMMANDS.ping)
}
/** Fordert einen frischen Snapshot an. */
requestStatus(): Promise<ResultMessage> {
return this.send(COMMANDS.status)
}
private open(): void {
const url = this.url
if (!url) return
this.emit('status', 'connecting')
this.emit('raw', { direction: 'system', text: `Verbinde mit ${url} …`, at: new Date() })
let socket: WebSocket
try {
socket = new WebSocket(url)
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
this.emit('error', `Ungültige WebSocket-Adresse: ${message}`)
this.emit('status', 'closed')
return
}
this.socket = socket
socket.onopen = () => {
if (this.socket !== socket) return
this.attempt = 0
this.emit('status', 'open')
this.emit('raw', { direction: 'system', text: 'Verbindung hergestellt.', at: new Date() })
}
socket.onmessage = (event: MessageEvent<unknown>) => {
if (this.socket !== socket) return
this.handleMessage(event.data)
}
socket.onerror = () => {
if (this.socket !== socket) return
// Der Browser liefert aus Sicherheitsgründen keine Fehlerdetails.
this.emit('error', 'Verbindungsfehler Adresse, Port und Erreichbarkeit der SwyxTray-App prüfen.')
}
socket.onclose = (event: CloseEvent) => {
if (this.socket !== socket) return
this.socket = null
this.failAllPending(new Error('Verbindung zur SwyxTray-App geschlossen.'))
this.emit('status', 'closed')
this.emit('raw', {
direction: 'system',
text: `Verbindung geschlossen (Code ${event.code}${event.reason ? `, ${event.reason}` : ''}).`,
at: new Date(),
})
if (this.desiredConnected) {
this.scheduleReconnect()
}
}
}
private handleMessage(data: unknown): void {
if (typeof data !== 'string') {
const size = data instanceof Blob ? data.size : data instanceof ArrayBuffer ? data.byteLength : 0
this.emit('raw', { direction: 'in', text: `[Binärdaten, ${size} Bytes]`, at: new Date() })
return
}
this.emit('raw', { direction: 'in', text: data, at: new Date() })
let parsed: unknown
try {
parsed = JSON.parse(data)
} catch {
// Kein JSON bleibt im Diagnoseprotokoll sichtbar, sonst ignorieren.
return
}
const result = parseResult(parsed)
if (result) {
const request = this.pending.get(result.id)
if (!request) return
this.pending.delete(result.id)
clearTimeout(request.timer)
if (result.ok) {
request.resolve(result)
} else {
request.reject(new Error(result.error ?? 'Unbekannter Fehler.'))
}
return
}
// Die App schickt keine Anruf-Ereignisse: Jede Änderung auch ein
// eingehender Anruf kommt als vollständiger Snapshot.
const snapshot = parseSnapshot(parsed)
if (snapshot) {
this.emit('snapshot', snapshot)
return
}
// Der Adress-Cache dagegen kommt unaufgefordert beim Verbinden und wenn
// die App ihn erneuert.
const addresses = parseAddresses(parsed)
if (addresses) {
this.emit('addresses', addresses)
return
}
const hello = parseHello(parsed)
if (hello) {
this.emit('hello', hello)
return
}
// Nicht stillschweigend verwerfen sonst bliebe eine Protokolländerung
// der App unbemerkt, genau wie bei den früher erwarteten call-Ereignissen.
this.emit('raw', {
direction: 'system',
text: 'Nachricht nicht erkannt Protokoll der App prüfen.',
at: new Date(),
})
}
private scheduleReconnect(): void {
if (this.reconnectTimer !== null) return
this.attempt += 1
const delay = Math.min(this.reconnectDelayMs * 2 ** (this.attempt - 1), this.maxReconnectDelayMs)
this.emit('raw', {
direction: 'system',
text: `Neuer Versuch #${this.attempt} in ${Math.round(delay / 1000)} s …`,
at: new Date(),
})
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
if (this.desiredConnected) this.open()
}, delay)
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
/** Schließt eine bestehende Verbindung, ohne einen Reconnect auszulösen. */
private closeSocket(reason: string): void {
const socket = this.socket
if (!socket) return
socket.onopen = null
socket.onmessage = null
socket.onerror = null
socket.onclose = null
this.socket = null
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
socket.close(CLOSE_CODE_NORMAL, reason)
}
this.failAllPending(new Error(reason))
}
private failAllPending(error: Error): void {
for (const request of this.pending.values()) {
clearTimeout(request.timer)
request.reject(error)
}
this.pending.clear()
}
/** Beendet den Client endgültig (Seitenwechsel). */
dispose(): void {
this.desiredConnected = false
this.clearReconnectTimer()
this.closeSocket('Seite verlassen')
for (const set of Object.values(this.listeners)) {
set.clear()
}
}
private emit<K extends keyof Listeners>(event: K, ...args: Parameters<Listeners[K]>): void {
for (const listener of this.listeners[event]) {
;(listener as (...a: unknown[]) => void)(...args)
}
}
}