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:
commit
ae397e1ea3
44 files changed
+6640
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
callStateOf,
|
||||
describeCall,
|
||||
describeTab,
|
||||
mergeSnapshot,
|
||||
parseHello,
|
||||
parseResult,
|
||||
parseSnapshot,
|
||||
parseTabs,
|
||||
type CallEvent,
|
||||
} from './protocol'
|
||||
|
||||
/**
|
||||
* Die Beispielnachrichten sind gegen die laufende SwyxTray-App 1.0.0.0
|
||||
* mitgeschnitten (ws://192.168.180.135:17654/ws).
|
||||
*/
|
||||
const HELLO = '{"app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello"}'
|
||||
|
||||
/** Wörtlich mitgeschnitten, während ein Anruf auf Leitung 1 einging. */
|
||||
const RINGING_SNAPSHOT =
|
||||
'{"connected":true,"serverUp":true,"overall":"Ringing","statusText":"Eingehender Ruf",' +
|
||||
'"user":"Muster, M.","server":"127.0.0.1","lines":[' +
|
||||
'{"line":1,"state":"LSRinging","stateCode":3,"stateText":"klingelt","peer":"001602107449",' +
|
||||
'"peerNumber":"001602107449","peerName":"","busy":true,"selected":true},' +
|
||||
'{"line":2,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' +
|
||||
'"peerNumber":"","peerName":"","busy":false,"selected":false},' +
|
||||
'{"line":3,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' +
|
||||
'"peerNumber":"","peerName":"","busy":false,"selected":false},' +
|
||||
'{"line":4,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' +
|
||||
'"peerNumber":"","peerName":"","busy":false,"selected":false}],"type":"snapshot"}'
|
||||
|
||||
/** Leitungseintrag im Snapshot; Vorgabe ist eine freie Leitung. */
|
||||
function line(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
line: 1,
|
||||
state: 'LSInactive',
|
||||
stateCode: 0,
|
||||
stateText: 'frei',
|
||||
peer: 'unbekannt',
|
||||
peerNumber: '',
|
||||
peerName: '',
|
||||
busy: false,
|
||||
selected: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(...entries: Record<string, unknown>[]) {
|
||||
return {
|
||||
connected: true,
|
||||
serverUp: true,
|
||||
overall: 'Idle',
|
||||
statusText: 'Bereit',
|
||||
user: 'Muster, M.',
|
||||
server: '127.0.0.1',
|
||||
lines: entries,
|
||||
type: 'snapshot',
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseHello', () => {
|
||||
it('liest die Begrüßung der App', () => {
|
||||
expect(parseHello(JSON.parse(HELLO))).toEqual({
|
||||
app: 'SwyxTray',
|
||||
version: '1.0.0.0',
|
||||
protocol: 1,
|
||||
session: 7,
|
||||
})
|
||||
})
|
||||
|
||||
it('hält andere Nachrichten für keine Begrüßung', () => {
|
||||
expect(parseHello(snapshot())).toBeNull()
|
||||
})
|
||||
|
||||
it('liest die Protokollversion der Tab-Verwaltung', () => {
|
||||
expect(
|
||||
parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":6,"session":1,"type":"hello"}'))
|
||||
?.protocol,
|
||||
).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('callStateOf', () => {
|
||||
it('hält freie Leitungen für frei', () => {
|
||||
expect(callStateOf('LSInactive', 'frei')).toBeNull()
|
||||
expect(callStateOf('LSTerminate', 'beendet')).toBeNull()
|
||||
})
|
||||
|
||||
it('erkennt einen eingehenden Anruf am Zustand', () => {
|
||||
expect(callStateOf('LSRinging', 'klingelt')).toBe('incoming')
|
||||
})
|
||||
|
||||
it('erkennt ihn auch nur am Klartext der App', () => {
|
||||
expect(callStateOf('LSIrgendwas', 'klingelt')).toBe('incoming')
|
||||
})
|
||||
|
||||
it('erkennt den Wählvorgang', () => {
|
||||
expect(callStateOf('LSDialing', 'wählt')).toBe('outgoing')
|
||||
expect(callStateOf('LSAlerting', 'Verbindungsaufbau')).toBe('outgoing')
|
||||
})
|
||||
|
||||
it('hält alles Unbekannte für belegt statt für frei', () => {
|
||||
// Sonst verschwände eine Leitung stillschweigend – der Fehler, wegen dem
|
||||
// eingehende Anrufe gar nicht angezeigt wurden.
|
||||
expect(callStateOf('LSActive', 'verbunden')).toBe('connected')
|
||||
expect(callStateOf('LSVölligNeu', 'was auch immer')).toBe('connected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSnapshot', () => {
|
||||
it('liest den Zustand der App', () => {
|
||||
expect(parseSnapshot(snapshot(line()))).toMatchObject({
|
||||
connected: true,
|
||||
serverUp: true,
|
||||
overall: 'Idle',
|
||||
statusText: 'Bereit',
|
||||
user: 'Muster, M.',
|
||||
server: '127.0.0.1',
|
||||
})
|
||||
})
|
||||
|
||||
it('verwirft freie Leitungen – die App schickt immer alle vier', () => {
|
||||
const parsed = parseSnapshot(
|
||||
snapshot(line({ line: 1 }), line({ line: 2 }), line({ line: 3 }), line({ line: 4 })),
|
||||
)
|
||||
expect(parsed?.lines).toEqual([])
|
||||
})
|
||||
|
||||
it('liest die klingelnde Leitung mit Gegenstelle', () => {
|
||||
const parsed = parseSnapshot(
|
||||
snapshot(
|
||||
line({ line: 1 }),
|
||||
line({
|
||||
line: 2,
|
||||
state: 'LSRinging',
|
||||
stateCode: 4,
|
||||
stateText: 'klingelt',
|
||||
peer: 'Muster GmbH (+493012345)',
|
||||
peerNumber: '+493012345',
|
||||
peerName: 'Muster GmbH',
|
||||
busy: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(parsed?.lines).toEqual([
|
||||
{
|
||||
line: 2,
|
||||
event: 'incoming',
|
||||
direction: 'incoming',
|
||||
peer: 'Muster GmbH (+493012345)',
|
||||
peerNumber: '+493012345',
|
||||
peerName: 'Muster GmbH',
|
||||
stateText: 'klingelt',
|
||||
state: 'LSRinging',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('lässt den Platzhalter "unbekannt" weg', () => {
|
||||
const parsed = parseSnapshot(snapshot(line({ state: 'LSActive', stateText: 'verbunden' })))
|
||||
expect(parsed?.lines[0]).toMatchObject({ line: 1, event: 'connected', peer: undefined })
|
||||
})
|
||||
|
||||
it('hält andere Nachrichten für keinen Snapshot', () => {
|
||||
expect(parseSnapshot(JSON.parse(HELLO))).toBeNull()
|
||||
expect(parseSnapshot('hallo')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('echter eingehender Anruf', () => {
|
||||
it('meldet den mitgeschnittenen Anruf als klingelnde Leitung', () => {
|
||||
const parsed = parseSnapshot(JSON.parse(RINGING_SNAPSHOT))
|
||||
expect(parsed?.statusText).toBe('Eingehender Ruf')
|
||||
expect(parsed?.lines).toEqual([
|
||||
{
|
||||
line: 1,
|
||||
event: 'incoming',
|
||||
direction: 'incoming',
|
||||
peer: '001602107449',
|
||||
peerNumber: '001602107449',
|
||||
peerName: undefined,
|
||||
stateText: 'klingelt',
|
||||
state: 'LSRinging',
|
||||
},
|
||||
])
|
||||
|
||||
// Aus dem Vergleich mit dem vorherigen Stand entsteht die Anrufmeldung.
|
||||
const { events } = mergeSnapshot({}, parsed!.lines)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ line: 1, event: 'incoming' })
|
||||
})
|
||||
|
||||
it('räumt die Leitung wieder ab, wenn der Anrufer auflegt', () => {
|
||||
const ringing = parseSnapshot(JSON.parse(RINGING_SNAPSHOT))!
|
||||
const { lines } = mergeSnapshot({}, ringing.lines)
|
||||
const idle = parseSnapshot(snapshot(line({ line: 1 }), line({ line: 2 })))!
|
||||
const { lines: next, events } = mergeSnapshot(lines, idle.lines)
|
||||
expect(next).toEqual({})
|
||||
expect(events[0]).toMatchObject({ line: 1, event: 'ended', peerNumber: '001602107449' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeSnapshot', () => {
|
||||
const ringing: CallEvent = {
|
||||
line: 2,
|
||||
event: 'incoming',
|
||||
direction: 'incoming',
|
||||
peerNumber: '+493012345',
|
||||
peerName: 'Muster GmbH',
|
||||
}
|
||||
|
||||
it('meldet eine neu klingelnde Leitung', () => {
|
||||
const { lines, events } = mergeSnapshot({}, [ringing])
|
||||
expect(events).toEqual([ringing])
|
||||
expect(lines[2]).toEqual(ringing)
|
||||
})
|
||||
|
||||
it('meldet nichts, solange sich nichts ändert', () => {
|
||||
const { lines } = mergeSnapshot({}, [ringing])
|
||||
expect(mergeSnapshot(lines, [ringing]).events).toEqual([])
|
||||
})
|
||||
|
||||
it('führt die Richtung über das Annehmen hinweg mit', () => {
|
||||
const { lines } = mergeSnapshot({}, [ringing])
|
||||
const connected = { ...ringing, event: 'connected' as const, direction: undefined }
|
||||
const { events } = mergeSnapshot(lines, [connected])
|
||||
expect(events).toEqual([{ ...connected, direction: 'incoming' }])
|
||||
})
|
||||
|
||||
it('meldet das Ende, wenn die Leitung im Snapshot fehlt', () => {
|
||||
const { lines } = mergeSnapshot({}, [ringing])
|
||||
const { lines: next, events } = mergeSnapshot(lines, [])
|
||||
expect(events).toEqual([{ ...ringing, event: 'ended', state: undefined, stateText: undefined }])
|
||||
expect(next).toEqual({})
|
||||
})
|
||||
|
||||
it('erkennt einen zweiten Anruf auf derselben Leitung', () => {
|
||||
const { lines } = mergeSnapshot({}, [ringing])
|
||||
const other = { ...ringing, peerNumber: '+499998888', peerName: 'Andere GmbH' }
|
||||
expect(mergeSnapshot(lines, [other]).events).toEqual([other])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseResult', () => {
|
||||
it('liest die Erfolgsquittung mit Leitungsnummer', () => {
|
||||
expect(parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))).toEqual({
|
||||
id: 11,
|
||||
ok: true,
|
||||
line: 4,
|
||||
error: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('liest die Fehlerquittung', () => {
|
||||
expect(
|
||||
parseResult(JSON.parse('{"id":8,"ok":false,"error":"Unbekanntes Kommando","type":"result"}')),
|
||||
).toMatchObject({ id: 8, ok: false, error: 'Unbekanntes Kommando' })
|
||||
})
|
||||
|
||||
it('liest die beiden Quittungen auf focus', () => {
|
||||
// Fenster war da und ist jetzt vorn.
|
||||
expect(parseResult(JSON.parse('{"type":"result","id":9,"ok":true,"focused":true}'))).toMatchObject(
|
||||
{ id: 9, ok: true, focused: true },
|
||||
)
|
||||
// Kein Fenster gefunden – die App hat die URL im Browser geöffnet.
|
||||
expect(parseResult(JSON.parse('{"type":"result","id":9,"ok":true,"focused":false}'))).toMatchObject(
|
||||
{ id: 9, ok: true, focused: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('lässt focused bei allen anderen Quittungen offen', () => {
|
||||
expect(parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))?.focused).toBeUndefined()
|
||||
})
|
||||
|
||||
it('liest die Quittung auf tabs', () => {
|
||||
const raw =
|
||||
'{"type":"result","id":12,"ok":true,"tabs":[' +
|
||||
'{"id":43,"title":"Kundenakte","url":"https://crm.example.local/kunden/4711","active":true},' +
|
||||
'{"id":44,"title":"SwyxWeb","url":"http://localhost:5173/","active":false}]}'
|
||||
expect(parseResult(JSON.parse(raw))?.tabs).toEqual([
|
||||
{ id: 43, title: 'Kundenakte', url: 'https://crm.example.local/kunden/4711', active: true },
|
||||
{ id: 44, title: 'SwyxWeb', url: 'http://localhost:5173/', active: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('liest die Kennung aus der Quittung auf opentab', () => {
|
||||
expect(parseResult(JSON.parse('{"type":"result","id":13,"ok":true,"tabId":43}'))).toMatchObject({
|
||||
id: 13,
|
||||
ok: true,
|
||||
tabId: 43,
|
||||
})
|
||||
})
|
||||
|
||||
it('lässt tabs und tabId bei allen anderen Quittungen offen', () => {
|
||||
const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))
|
||||
expect(result?.tabs).toBeUndefined()
|
||||
expect(result?.tabId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hält einen Snapshot für keine Quittung', () => {
|
||||
expect(parseResult(snapshot())).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTabs', () => {
|
||||
it('nimmt fehlende Felder hin – nur die Kennung ist Pflicht', () => {
|
||||
expect(parseTabs([{ id: 7 }])).toEqual([{ id: 7, title: undefined, url: undefined, active: false }])
|
||||
})
|
||||
|
||||
it('verwirft Einträge ohne brauchbare Kennung', () => {
|
||||
// Ohne Kennung ließe sich der Tab ohnehin nicht schließen.
|
||||
expect(parseTabs([{ title: 'Ohne Kennung' }, 'Unfug', null, { id: 9 }])).toEqual([
|
||||
{ id: 9, title: undefined, url: undefined, active: false },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeTab', () => {
|
||||
it('nimmt den Titel, sonst die URL, sonst die Kennung', () => {
|
||||
expect(describeTab({ id: 1, title: 'Kundenakte', url: 'https://x/', active: true })).toBe('Kundenakte')
|
||||
expect(describeTab({ id: 2, url: 'https://x/', active: false })).toBe('https://x/')
|
||||
expect(describeTab({ id: 3, active: false })).toBe('Tab 3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeCall', () => {
|
||||
it('bevorzugt die fertige Anzeigeform der App', () => {
|
||||
expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe(
|
||||
'Muster GmbH (+49301)',
|
||||
)
|
||||
expect(describeCall({ line: 3, event: 'connected' })).toBe('Leitung 3')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Protokoll der SwyxTray-App, aufgezeichnet gegen `ws://192.168.180.135:17654/ws`
|
||||
* (App-Version 1.0.0.0, `protocol: 1`; seit der Tab-Verwaltung meldet die App
|
||||
* `protocol: 6`).
|
||||
*
|
||||
* App → Seite:
|
||||
* { "app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello" }
|
||||
* { "type":"snapshot","connected":true,"serverUp":true,"overall":"Idle",
|
||||
* "statusText":"Bereit","user":"Muster, M.","server":"127.0.0.1",
|
||||
* "lines":[{"line":1,"state":"LSInactive","stateCode":0,"stateText":"frei",
|
||||
* "peer":"unbekannt","peerNumber":"","peerName":"","busy":false,
|
||||
* "selected":true}, …] }
|
||||
* { "type":"result","id":7,"ok":true,"line":2 }
|
||||
*
|
||||
* Seite → App:
|
||||
* { "id":7,"cmd":"call","number":"+49 30 1234567" } ferner answer/hangup (je `line`),
|
||||
* ping, status und focus (bestätigt durch Sondierung; alles andere quittiert die App mit
|
||||
* „Unbekanntes Kommando").
|
||||
* { "id":9,"cmd":"focus","title":"Kundenakte Muster GmbH","url":"https://…" }
|
||||
* → { "type":"result","id":9,"ok":true,"focused":true } Fenster war da und ist jetzt vorn
|
||||
* → { "type":"result","id":9,"ok":true,"focused":false } kein Fenster – Browser mit `url`
|
||||
* Der Titel wird als Teilzeichenkette gesucht, unabhängig von der Schreibweise. Ohne
|
||||
* Treffer und ohne `url` antwortet die App mit `ok:false`.
|
||||
*
|
||||
* Seit Protokoll 6 reicht die App drei weitere Kommandos unverändert an das
|
||||
* Firefox-Plugin durch – die Tabs gehören also zum Browser auf dem Rechner der App:
|
||||
* { "id":12,"cmd":"tabs" }
|
||||
* → { "type":"result","id":12,"ok":true,
|
||||
* "tabs":[{"id":43,"title":"Kundenakte","url":"https://…","active":true}, …] }
|
||||
* { "id":13,"cmd":"opentab","url":"https://…" } → { …,"ok":true,"tabId":43 }
|
||||
* { "id":14,"cmd":"closetab","tabId":43 } → { …,"ok":true }
|
||||
* Ist kein Plugin verbunden, antwortet die App nach rund fünf Sekunden mit `ok:false`.
|
||||
*
|
||||
* **Die App kennt keine Ereignisnachrichten.** Jede Zustandsänderung – auch ein
|
||||
* eingehender Anruf – kommt als vollständiger `snapshot` über *alle* Leitungen.
|
||||
* Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots
|
||||
* (siehe {@link mergeSnapshot}).
|
||||
*/
|
||||
|
||||
export type CallEventKind = 'incoming' | 'outgoing' | 'connected' | 'ended'
|
||||
export type CallDirection = 'incoming' | 'outgoing'
|
||||
|
||||
export interface CallEvent {
|
||||
/** 1-basierte Leitungsnummer, wie in SwyxIt! */
|
||||
line: number
|
||||
event: CallEventKind
|
||||
/** Bei `connected` aus dem vorherigen Zustand derselben Leitung übernommen. */
|
||||
direction?: CallDirection
|
||||
/** Fertige Anzeigeform der App, z. B. "Muster GmbH (+493012345)". */
|
||||
peer?: string
|
||||
peerNumber?: string
|
||||
peerName?: string
|
||||
/** Klartext der App zum Leitungszustand, z. B. "klingelt". */
|
||||
stateText?: string
|
||||
/** Rohzustand der App, z. B. "LSRinging" – für die Diagnose. */
|
||||
state?: string
|
||||
}
|
||||
|
||||
export interface ResultMessage {
|
||||
id: number
|
||||
ok: boolean
|
||||
line?: number
|
||||
/** Nur bei `focus`: true = Fenster geholt, false = Browser mit der URL gestartet. */
|
||||
focused?: boolean
|
||||
/** Nur bei `tabs`: die offenen Tabs des Firefox-Plugins. */
|
||||
tabs?: BrowserTab[]
|
||||
/** Nur bei `opentab`: Kennung des neu geöffneten Tabs. */
|
||||
tabId?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Ein Tab des Firefox-Plugins, wie ihn die Quittung auf `tabs` meldet. */
|
||||
export interface BrowserTab {
|
||||
/** Kennung des Tabs; sie geht unverändert an `closetab` zurück. */
|
||||
id: number
|
||||
title?: string
|
||||
url?: string
|
||||
/** Ist das der im Fenster sichtbare Tab? */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/** Begrüßung beim Verbindungsaufbau. */
|
||||
export interface HelloMessage {
|
||||
app?: string
|
||||
version?: string
|
||||
protocol?: number
|
||||
session?: number
|
||||
}
|
||||
|
||||
/** Vollzustand; die App schickt ihn beim Verbinden und nach jeder Änderung. */
|
||||
export interface SnapshotMessage {
|
||||
/** Nur belegte Leitungen; freie Leitungen sind hier nicht enthalten. */
|
||||
lines: CallEvent[]
|
||||
/** Ist die App mit SwyxIt! verbunden? */
|
||||
connected?: boolean
|
||||
/** Ist der SwyxServer erreichbar? */
|
||||
serverUp?: boolean
|
||||
/** Gesamtzustand, z. B. "Idle" oder "Dialing". */
|
||||
overall?: string
|
||||
/** Klartext dazu, z. B. "Bereit". */
|
||||
statusText?: string
|
||||
user?: string
|
||||
server?: string
|
||||
raw: unknown
|
||||
}
|
||||
|
||||
/** Kommandonamen; alle gegen die App bestätigt. */
|
||||
export const COMMANDS = {
|
||||
call: 'call',
|
||||
answer: 'answer',
|
||||
hangup: 'hangup',
|
||||
ping: 'ping',
|
||||
status: 'status',
|
||||
focus: 'focus',
|
||||
tabs: 'tabs',
|
||||
openTab: 'opentab',
|
||||
closeTab: 'closetab',
|
||||
} as const
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string' && value !== '') return value
|
||||
if (typeof value === 'number') return String(value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Ganze Zahlen aus dem Protokoll – Leitungsnummer, Kommando-Id, Tab-Kennung.
|
||||
* Eine Ziffernfolge als Text wird ebenfalls akzeptiert.
|
||||
*/
|
||||
function asLine(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) return value
|
||||
if (typeof value === 'string' && /^\d+$/.test(value.trim())) return Number(value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === 'boolean' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitungszustand der App → Anrufzustand der Seite; `null` heißt „frei".
|
||||
*
|
||||
* `state` trägt die Namen der CLMgr-Aufzählung `LineState`, `stateText` den
|
||||
* deutschen Klartext aus SwyxIt!. Bestätigt ist bisher nur `LSInactive`/"frei";
|
||||
* deshalb wird beides ausgewertet und ein **unbekannter Zustand als belegt**
|
||||
* behandelt – eine klingelnde Leitung darf nie stillschweigend verschwinden.
|
||||
*/
|
||||
export function callStateOf(state?: string, stateText?: string): CallEventKind | null {
|
||||
const name = (state ?? '').toLowerCase()
|
||||
const text = (stateText ?? '').toLowerCase()
|
||||
const has = (...needles: string[]) => needles.some((n) => name.includes(n) || text.includes(n))
|
||||
|
||||
// Frei oder gerade freigeworden.
|
||||
if (has('inactive', 'disabled', 'terminate', 'frei', 'beendet')) return null
|
||||
// Eingehend: die Leitung klingelt beim Benutzer.
|
||||
if (has('ringing', 'knocking', 'klingelt', 'ruft an')) return 'incoming'
|
||||
// Ausgehend: gewählt wird, die Gegenstelle wird gerufen.
|
||||
if (has('dialing', 'initiate', 'alerting', 'wählt', 'wahl', 'verbindungsaufbau')) return 'outgoing'
|
||||
// Alles Übrige gilt als belegt (aktiv, gehalten, Konferenz, unbekannt).
|
||||
return 'connected'
|
||||
}
|
||||
|
||||
/** Liest einen Leitungseintrag aus dem Snapshot; `null`, wenn die Leitung frei ist. */
|
||||
export function parseSnapshotLine(raw: unknown): CallEvent | null {
|
||||
if (!isRecord(raw)) return null
|
||||
const line = asLine(raw.line)
|
||||
if (line === undefined) return null
|
||||
|
||||
const state = asString(raw.state)
|
||||
const stateText = asString(raw.stateText)
|
||||
const event = callStateOf(state, stateText)
|
||||
if (!event) return null
|
||||
|
||||
// "unbekannt" ist der Platzhalter der App für eine Leitung ohne Gegenstelle.
|
||||
const peer = asString(raw.peer)
|
||||
return {
|
||||
line,
|
||||
event,
|
||||
// Bei `connected` verrät der Zustand die Richtung nicht – sie kommt aus dem
|
||||
// vorherigen Snapshot derselben Leitung (siehe mergeSnapshot).
|
||||
direction: event === 'incoming' || event === 'outgoing' ? event : undefined,
|
||||
peer: peer === 'unbekannt' ? undefined : peer,
|
||||
peerNumber: asString(raw.peerNumber),
|
||||
peerName: asString(raw.peerName),
|
||||
stateText,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
/** Liest die Quittung auf ein Kommando. */
|
||||
export function parseResult(raw: unknown): ResultMessage | null {
|
||||
if (!isRecord(raw)) return null
|
||||
if (raw.type !== undefined && raw.type !== 'result') return null
|
||||
if (typeof raw.ok !== 'boolean') return null
|
||||
|
||||
const id = asLine(raw.id)
|
||||
if (id === undefined) return null
|
||||
|
||||
return {
|
||||
id,
|
||||
ok: raw.ok,
|
||||
line: asLine(raw.line),
|
||||
focused: asBoolean(raw.focused),
|
||||
tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined,
|
||||
tabId: asLine(raw.tabId),
|
||||
error: asString(raw.error),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Tabliste aus der Quittung auf `tabs`. Einträge ohne brauchbare
|
||||
* Kennung fallen weg – ohne sie ließe sich der Tab ohnehin nicht schließen.
|
||||
*/
|
||||
export function parseTabs(raw: unknown[]): BrowserTab[] {
|
||||
return raw.flatMap((entry) => {
|
||||
if (!isRecord(entry)) return []
|
||||
const id = asLine(entry.id)
|
||||
if (id === undefined) return []
|
||||
return [
|
||||
{
|
||||
id,
|
||||
title: asString(entry.title),
|
||||
url: asString(entry.url),
|
||||
active: asBoolean(entry.active) ?? false,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/** Anzeigename eines Tabs: Titel, sonst die URL, sonst die Kennung. */
|
||||
export function describeTab(tab: BrowserTab): string {
|
||||
return tab.title ?? tab.url ?? `Tab ${tab.id}`
|
||||
}
|
||||
|
||||
/** Liest die Begrüßung. */
|
||||
export function parseHello(raw: unknown): HelloMessage | null {
|
||||
if (!isRecord(raw) || raw.type !== 'hello') return null
|
||||
return {
|
||||
app: asString(raw.app),
|
||||
version: asString(raw.version),
|
||||
protocol: asLine(raw.protocol),
|
||||
session: asLine(raw.session),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Vollzustand. Freie Leitungen werden verworfen – die App schickt
|
||||
* immer alle vier Leitungen mit, auch die unbenutzten.
|
||||
*/
|
||||
export function parseSnapshot(raw: unknown): SnapshotMessage | null {
|
||||
if (!isRecord(raw) || raw.type !== 'snapshot') return null
|
||||
|
||||
const entries = Array.isArray(raw.lines) ? raw.lines : []
|
||||
const lines = entries.flatMap((entry) => {
|
||||
const parsed = parseSnapshotLine(entry)
|
||||
return parsed ? [parsed] : []
|
||||
})
|
||||
|
||||
return {
|
||||
lines,
|
||||
connected: asBoolean(raw.connected),
|
||||
serverUp: asBoolean(raw.serverUp),
|
||||
overall: asString(raw.overall),
|
||||
statusText: asString(raw.statusText),
|
||||
user: asString(raw.user),
|
||||
server: asString(raw.server),
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt einen Snapshot mit dem bisherigen Stand zusammen.
|
||||
*
|
||||
* Weil die App keine Ereignisse schickt, entstehen sie hier aus dem Vergleich:
|
||||
* neue oder veränderte Leitungen ergeben ein Ereignis, verschwundene ein
|
||||
* `ended`. Die Richtung wird über den Wechsel hinweg mitgeführt, damit ein
|
||||
* angenommener Anruf im Verlauf eingehend bleibt.
|
||||
*/
|
||||
export function mergeSnapshot(
|
||||
previous: Record<number, CallEvent>,
|
||||
lines: CallEvent[],
|
||||
): { lines: Record<number, CallEvent>; events: CallEvent[] } {
|
||||
const next: Record<number, CallEvent> = {}
|
||||
const events: CallEvent[] = []
|
||||
|
||||
for (const entry of lines) {
|
||||
const before = previous[entry.line]
|
||||
// Gleiche Gegenstelle ⇒ derselbe Anruf; nur dann darf etwas übernommen werden.
|
||||
const sameCall =
|
||||
before !== undefined &&
|
||||
(entry.peerNumber ?? entry.peer) === (before.peerNumber ?? before.peer)
|
||||
|
||||
const merged: CallEvent = {
|
||||
...entry,
|
||||
direction: entry.direction ?? (sameCall ? before.direction : undefined),
|
||||
peer: entry.peer ?? (sameCall ? before.peer : undefined),
|
||||
peerNumber: entry.peerNumber ?? (sameCall ? before.peerNumber : undefined),
|
||||
peerName: entry.peerName ?? (sameCall ? before.peerName : undefined),
|
||||
}
|
||||
next[entry.line] = merged
|
||||
|
||||
if (!before || before.event !== merged.event || !sameCall) {
|
||||
events.push(merged)
|
||||
}
|
||||
}
|
||||
|
||||
for (const before of Object.values(previous)) {
|
||||
if (!next[before.line]) {
|
||||
events.push({ ...before, event: 'ended', stateText: undefined, state: undefined })
|
||||
}
|
||||
}
|
||||
|
||||
return { lines: next, events: events.sort((a, b) => a.line - b.line) }
|
||||
}
|
||||
|
||||
/** Anzeigename einer Leitung: fertige Anzeigeform, sonst Name oder Nummer. */
|
||||
export function describeCall(event: CallEvent): string {
|
||||
return event.peer ?? event.peerName ?? event.peerNumber ?? `Leitung ${event.line}`
|
||||
}
|
||||
Reference in new issue
Block a user