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:
2026-08-20 11:03:34 +02:00
co-authored by Claude Opus 5
commit ae397e1ea3
44 changed files with 6640 additions and 0 deletions
+356
View File
@@ -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)
}
}
}