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
+174
View File
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SwyxTrayClient, type ConnectionStatus, type RawMessage } from '../swyx/SwyxTrayClient'
import { mergeSnapshot, type CallEvent, type SnapshotMessage } from '../swyx/protocol'
export interface LogEntry extends RawMessage {
id: number
}
/** Zustand der App selbst, aus dem Snapshot unabhängig von den Leitungen. */
export interface TrayState {
connected?: boolean
serverUp?: boolean
overall?: string
statusText?: string
user?: string
server?: string
}
const MAX_LOG_ENTRIES = 300
const MAX_HISTORY = 50
/**
* Bindet den SwyxTrayClient an React: Verbindungsstatus, Zustand der Leitungen,
* Ereignisverlauf und die Telefonie-Kommandos.
*/
export function useSwyxTray() {
const [status, setStatus] = useState<ConnectionStatus>('idle')
const [error, setError] = useState<string | null>(null)
const [log, setLog] = useState<LogEntry[]>([])
const [history, setHistory] = useState<CallEvent[]>([])
// Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu.
const [lines, setLines] = useState<Record<number, CallEvent>>({})
const [tray, setTray] = useState<TrayState>({})
const [busy, setBusy] = useState(false)
const clientRef = useRef<SwyxTrayClient | null>(null)
const logIdRef = useRef(0)
// Stand der Leitungen für den Snapshot-Vergleich.
const linesRef = useRef<Record<number, CallEvent>>({})
if (clientRef.current === null) {
clientRef.current = new SwyxTrayClient()
}
const client = clientRef.current
useEffect(() => {
const unsubscribe = [
client.on('status', (next) => {
setStatus(next)
if (next === 'open') setError(null)
// Der Leitungszustand der alten Verbindung ist nach dem Abbruch wertlos;
// die App schickt beim Verbinden ohnehin einen Snapshot.
if (next === 'closed') {
linesRef.current = {}
setLines({})
setTray({})
}
}),
client.on('error', (message) => setError(message)),
client.on('raw', (message) => {
setLog((entries) => {
const next = [...entries, { ...message, id: logIdRef.current++ }]
return next.length > MAX_LOG_ENTRIES ? next.slice(next.length - MAX_LOG_ENTRIES) : next
})
}),
client.on('snapshot', (snapshot: SnapshotMessage) => {
setTray({
connected: snapshot.connected,
serverUp: snapshot.serverUp,
overall: snapshot.overall,
statusText: snapshot.statusText,
user: snapshot.user,
server: snapshot.server,
})
// Der Vollzustand ersetzt den bisherigen Stand; die Anruf-Ereignisse für
// Verlauf und Benachrichtigung entstehen aus dem Vergleich mit ihm.
// Der Vergleich läuft über die Ref, nicht im State-Updater der wird
// im StrictMode doppelt ausgeführt und würde den Verlauf verdoppeln.
const { lines: next, events } = mergeSnapshot(linesRef.current, snapshot.lines)
linesRef.current = next
setLines(next)
if (events.length > 0) {
setHistory((entries) => [...[...events].reverse(), ...entries].slice(0, MAX_HISTORY))
}
}),
]
return () => {
unsubscribe.forEach((off) => off())
}
}, [client])
// Beim Verlassen der Seite Verbindung und Timer aufräumen.
useEffect(() => () => client.dispose(), [client])
const connect = useCallback((url: string) => client.connect(url), [client])
const disconnect = useCallback(() => client.disconnect(), [client])
/** Führt ein Kommando aus und übernimmt Fehleranzeige und Busy-Zustand. */
const run = useCallback(async <T,>(action: () => Promise<T>): Promise<T | null> => {
setBusy(true)
setError(null)
try {
return await action()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
return null
} finally {
setBusy(false)
}
}, [])
const dial = useCallback((number: string) => run(() => client.dial(number)), [client, run])
const answer = useCallback((line: number) => run(() => client.answer(line)), [client, run])
const hangup = useCallback((line: number) => run(() => client.hangup(line)), [client, run])
const refresh = useCallback(() => run(() => client.requestStatus()), [client, run])
const focusWindow = useCallback(
(title: string, url?: string) => run(() => client.focusWindow(title, url)),
[client, run],
)
// Tabs des Firefox-Plugins; die App reicht diese drei Kommandos nur durch.
const listTabs = useCallback(() => run(() => client.listTabs()), [client, run])
const openTab = useCallback((url: string) => run(() => client.openTab(url)), [client, run])
const closeTab = useCallback((tabId: number) => run(() => client.closeTab(tabId)), [client, run])
const sendRaw = useCallback(
(text: string) => {
try {
client.sendRaw(text)
return true
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
return false
}
},
[client],
)
const clearLog = useCallback(() => setLog([]), [])
const calls = useMemo(
() => Object.values(lines).sort((a, b) => a.line - b.line),
[lines],
)
/** Erste klingelnde Leitung eines eingehenden Anrufs. */
const ringingCall = useMemo(
() => calls.find((call) => call.event === 'incoming') ?? null,
[calls],
)
return {
status,
error,
log,
history,
calls,
ringingCall,
tray,
busy,
connect,
disconnect,
dial,
answer,
hangup,
refresh,
focusWindow,
listTabs,
openTab,
closeTab,
sendRaw,
clearLog,
}
}