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 } 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() private readonly listeners: { [K in keyof Listeners]: Set } = { status: new Set(), hello: new Set(), snapshot: new Set(), addresses: new Set(), raw: new Set(), error: new Set(), } private reconnectTimer: ReturnType | 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(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 = {}, timeoutMs = DEFAULT_TIMEOUT_MS, ): Promise { 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 { return this.send(COMMANDS.call, { number }) } answer(line: number): Promise { return this.send(COMMANDS.answer, { line }) } hangup(line: number): Promise { 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 { const payload: Record = { 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 { 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 { 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 { 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 { 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 { return this.send(COMMANDS.addresses) } ping(): Promise { return this.send(COMMANDS.ping) } /** Fordert einen frischen Snapshot an. */ requestStatus(): Promise { 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) => { 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(event: K, ...args: Parameters): void { for (const listener of this.listeners[event]) { ;(listener as (...a: unknown[]) => void)(...args) } } }