first commit
SwyxWeb: React-Frontend und Spring-Boot-Backend mit SwyxTray-Mock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
import {
|
||||
COMMANDS,
|
||||
parseHello,
|
||||
parseResult,
|
||||
parseSnapshot,
|
||||
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
|
||||
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(),
|
||||
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)
|
||||
}
|
||||
|
||||
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 Ereignisse: Jede Änderung – auch ein eingehender
|
||||
// Anruf – kommt als vollständiger Snapshot.
|
||||
const snapshot = parseSnapshot(parsed)
|
||||
if (snapshot) {
|
||||
this.emit('snapshot', snapshot)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user