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>
400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
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)
|
||
}
|
||
}
|
||
}
|