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
+5
View File
@@ -0,0 +1,5 @@
import HomePage from './pages/HomePage'
export default function App() {
return <HomePage />
}
@@ -0,0 +1,196 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import { describeTab, type BrowserTab, type ResultMessage } from '../swyx/protocol'
interface Props {
/** Panel ist sichtbar erst dann wird von selbst geladen. */
active: boolean
disabled: boolean
busy: boolean
onList: () => Promise<ResultMessage | null>
onOpen: (url: string) => Promise<ResultMessage | null>
onClose: (tabId: number) => Promise<ResultMessage | null>
}
const STORAGE_URL = 'swyxweb.tabs.url'
function stored(key: string): string {
try {
return localStorage.getItem(key) ?? ''
} catch {
// Privater Modus o. Ä. dann eben ohne Gedächtnis.
return ''
}
}
function remember(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch {
// absichtlich still
}
}
/**
* Die App nimmt nur absolute http(s)-Adressen an. Der gleiche Test hier erspart
* den Umweg über App und Plugin und erklärt den Fehler an Ort und Stelle.
*/
function isAbsoluteHttpUrl(value: string): boolean {
try {
const parsed = new URL(value)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
}
/**
* Tabs des Firefox-Plugins: anzeigen (`tabs`), öffnen (`opentab`) und
* schließen (`closetab`). Die SwyxTray-App reicht alle drei Kommandos nur an
* das Plugin durch ohne verbundenes Plugin antwortet sie mit einem Fehler.
*/
export default function BrowserTabsPanel({ active, disabled, busy, onList, onOpen, onClose }: Props) {
const [tabs, setTabs] = useState<BrowserTab[] | null>(null)
const [url, setUrl] = useState(() => stored(STORAGE_URL))
const [hint, setHint] = useState<string | null>(null)
const [outcome, setOutcome] = useState<string | null>(null)
// Nach einem Verbindungsabbruch soll beim nächsten Öffnen erneut geladen werden.
const loadedRef = useRef(false)
/**
* Holt die Liste neu. Die Meldung des letzten Vorgangs bleibt dabei stehen
* Öffnen und Schließen laden hinterher nach und würden sie sonst selbst löschen.
*/
const load = useCallback(async () => {
const result = await onList()
// null heißt: Fehler der steht bereits in der Fehleranzeige der Seite.
if (!result) return
setTabs(result.tabs ?? [])
}, [onList])
useEffect(() => {
if (disabled) {
// Ohne Verbindung ist die Liste veraltet; sie wird beim nächsten Mal neu geholt.
loadedRef.current = false
setTabs(null)
return
}
if (!active || loadedRef.current) return
loadedRef.current = true
void load()
}, [active, disabled, load])
async function handleOpen(event: FormEvent) {
event.preventDefault()
const wanted = url.trim()
if (!wanted) return
if (!isAbsoluteHttpUrl(wanted)) {
setHint('Bitte eine vollständige Adresse angeben, z. B. https://example.com/seite.')
return
}
setHint(null)
setOutcome(null)
remember(STORAGE_URL, wanted)
const result = await onOpen(wanted)
if (!result) return
// Die Liste ist jetzt veraltet die App meldet Änderungen nicht von selbst.
await load()
setOutcome(
result.tabId === undefined
? `${wanted} geöffnet.`
: `${wanted} als Tab ${result.tabId} geöffnet.`,
)
}
async function handleClose(tab: BrowserTab) {
setOutcome(null)
const result = await onClose(tab.id)
if (!result) return
await load()
setOutcome(`Tab „${describeTab(tab)}" geschlossen.`)
}
return (
<>
<div className="card__header">
<h2>Tabs (Firefox-Plugin)</h2>
<button
type="button"
className="button button--ghost"
onClick={() => {
setOutcome(null)
void load()
}}
disabled={disabled || busy}
>
{busy ? 'Sende …' : 'Tabs anzeigen'}
</button>
</div>
{disabled ? (
<p className="note">Erst mit der SwyxTray-App verbinden.</p>
) : tabs === null ? (
<p className="note">Noch nicht abgefragt.</p>
) : tabs.length === 0 ? (
<p className="note">Keine offenen Tabs gemeldet.</p>
) : (
<ul className="tablist">
{tabs.map((tab) => (
<li key={tab.id} className={`tablist__item${tab.active ? ' tablist__item--active' : ''}`}>
<span className="tablist__id">{tab.id}</span>
<span className="tablist__info">
<span className="tablist__title">
{describeTab(tab)}
{tab.active && <span className="tablist__flag">aktiv</span>}
</span>
{tab.url && <span className="tablist__url">{tab.url}</span>}
</span>
<button
type="button"
className="button button--small"
onClick={() => void handleClose(tab)}
disabled={busy}
>
Schließen
</button>
</li>
))}
</ul>
)}
<form className="row" onSubmit={handleOpen}>
<label className="field">
<span className="field__label">Neuen Tab öffnen</span>
<input
className="field__input"
type="url"
value={url}
disabled={disabled}
spellCheck={false}
autoComplete="off"
placeholder={disabled ? 'Erst verbinden' : 'https://crm.example.local/kunden/4711'}
onChange={(e) => setUrl(e.target.value)}
/>
</label>
<div className="row__actions">
<button
type="submit"
className="button button--primary"
disabled={disabled || busy || !url.trim()}
>
{busy ? 'Sende …' : 'Tab öffnen'}
</button>
</div>
</form>
<p className="note">
Die Tabs gehören zum Firefox auf dem Rechner der SwyxTray-App. Ohne verbundenes Plugin
beantwortet die App die drei Kommandos nach etwa fünf Sekunden mit einem Fehler.
</p>
{hint && <p className="note note--error">{hint}</p>}
{outcome && <p className="note note--success">{outcome}</p>}
</>
)
}
+85
View File
@@ -0,0 +1,85 @@
import { describeCall, type CallEvent, type CallEventKind } from '../swyx/protocol'
const EVENT_LABELS: Record<CallEventKind, string> = {
incoming: 'klingelt',
outgoing: 'wählt',
connected: 'verbunden',
ended: 'beendet',
}
interface Props {
calls: CallEvent[]
busy: boolean
onAnswer: (line: number) => void
onHangup: (line: number) => void
}
/** Belegte Leitungen mit Aktionen. */
export function ActiveCallList({ calls, busy, onAnswer, onHangup }: Props) {
if (calls.length === 0) {
return <p className="note">Zurzeit keine belegte Leitung.</p>
}
return (
<ul className="calls">
{calls.map((call) => (
<li key={call.line} className={`calls__item calls__item--${call.event}`}>
<span className="calls__line" title={`Leitung ${call.line}`}>
L{call.line}
</span>
<div className="calls__info">
<span className="calls__number">{describeCall(call)}</span>
{/* Klartext der App bevorzugen er ist genauer als unsere vier Zustände. */}
<span className="calls__state">{call.stateText ?? EVENT_LABELS[call.event]}</span>
</div>
<div className="calls__actions">
{call.event === 'incoming' && (
<button
type="button"
className="button button--accept button--small"
disabled={busy}
onClick={() => onAnswer(call.line)}
>
Annehmen
</button>
)}
<button
type="button"
className="button button--reject button--small"
disabled={busy}
onClick={() => onHangup(call.line)}
>
Auflegen
</button>
</div>
</li>
))}
</ul>
)
}
/** Verlauf aller empfangenen Anruf-Ereignisse. */
export function CallHistory({ events }: { events: CallEvent[] }) {
if (events.length === 0) {
return <p className="note">Noch keine Anruf-Ereignisse empfangen.</p>
}
return (
<ul className="history">
{events.map((event, index) => (
<li key={`${event.line}-${event.event}-${index}`} className="history__item">
<span className="history__line">L{event.line}</span>
<span className={`history__badge history__badge--${event.event}`}>
{EVENT_LABELS[event.event]}
</span>
<span className="history__number">{describeCall(event)}</span>
{event.direction && (
<span className="history__meta">
{event.direction === 'incoming' ? 'eingehend' : 'ausgehend'}
</span>
)}
</li>
))}
</ul>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { useState, type FormEvent } from 'react'
interface Props {
disabled: boolean
busy: boolean
onDial: (number: string) => void
}
/** Eingabe für Click-to-Dial (Kommando `call`). */
export default function DialPanel({ disabled, busy, onDial }: Props) {
const [number, setNumber] = useState('')
function handleSubmit(event: FormEvent) {
event.preventDefault()
const target = number.trim()
if (!target) return
onDial(target)
}
return (
<form className="row" onSubmit={handleSubmit}>
<label className="field">
<span className="field__label">Rufnummer</span>
<input
className="field__input"
type="tel"
value={number}
disabled={disabled}
spellCheck={false}
autoComplete="tel"
placeholder={disabled ? 'Erst verbinden' : '+49 30 1234567'}
onChange={(e) => setNumber(e.target.value)}
/>
</label>
<div className="row__actions">
<button
type="submit"
className="button button--primary"
disabled={disabled || busy || !number.trim()}
>
{busy ? 'Wähle …' : 'Anrufen'}
</button>
</div>
</form>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useState, type FormEvent } from 'react'
import type { ResultMessage } from '../swyx/protocol'
interface Props {
disabled: boolean
busy: boolean
onFocus: (title: string, url?: string) => Promise<ResultMessage | null>
}
const STORAGE_TITLE = 'swyxweb.focus.title'
const STORAGE_URL = 'swyxweb.focus.url'
/** Merkt sich die Eingaben über einen Neuladevorgang hinweg. */
function stored(key: string): string {
try {
return localStorage.getItem(key) ?? ''
} catch {
// Privater Modus o. Ä. dann eben ohne Gedächtnis.
return ''
}
}
function remember(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch {
// absichtlich still
}
}
/**
* Eingabe für das Kommando `focus`: Fenster mit dem Titel nach vorn holen,
* ersatzweise die URL im Standardbrowser öffnen.
*/
export default function FocusPanel({ disabled, busy, onFocus }: Props) {
const [title, setTitle] = useState(() => stored(STORAGE_TITLE))
const [url, setUrl] = useState(() => stored(STORAGE_URL))
const [outcome, setOutcome] = useState<string | null>(null)
async function handleSubmit(event: FormEvent) {
event.preventDefault()
const wantedTitle = title.trim()
const wantedUrl = url.trim()
if (!wantedTitle) return
remember(STORAGE_TITLE, wantedTitle)
remember(STORAGE_URL, wantedUrl)
setOutcome(null)
const result = await onFocus(wantedTitle, wantedUrl || undefined)
// null heißt: Fehler der steht bereits in der Fehleranzeige der Seite.
if (!result) return
setOutcome(
result.focused
? `Fenster „${wantedTitle}" in den Vordergrund geholt.`
: wantedUrl
? `Kein Fenster gefunden Browser mit ${wantedUrl} geöffnet.`
: 'Kein Fenster gefunden.',
)
}
return (
<form onSubmit={handleSubmit}>
<div className="row">
<label className="field">
<span className="field__label">Fenstertitel</span>
<input
className="field__input"
type="text"
value={title}
disabled={disabled}
spellCheck={false}
autoComplete="off"
placeholder={disabled ? 'Erst verbinden' : 'Kundenakte Muster GmbH'}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
</div>
<div className="row">
<label className="field">
<span className="field__label">URL (falls kein Fenster gefunden wird)</span>
<input
className="field__input"
type="url"
value={url}
disabled={disabled}
spellCheck={false}
autoComplete="off"
placeholder="https://crm.example.local/kunden/4711"
onChange={(e) => setUrl(e.target.value)}
/>
</label>
<div className="row__actions">
<button
type="submit"
className="button button--primary"
disabled={disabled || busy || !title.trim()}
>
{busy ? 'Sende …' : 'Fenster holen'}
</button>
</div>
</div>
<p className="note">
Ein Teil des Titels genügt, die Schreibweise ist egal. Gibt es kein passendes Fenster,
startet die App den Standardbrowser mit der URL ohne URL meldet sie einen Fehler.
</p>
{outcome && <p className="note note--success">{outcome}</p>}
</form>
)
}
@@ -0,0 +1,48 @@
import { type CallEvent } from '../swyx/protocol'
interface Props {
call: CallEvent
busy: boolean
onAnswer: (line: number) => void
onHangup: (line: number) => void
}
/** Meldung für einen eingehenden, noch klingelnden Anruf. */
export default function IncomingCallCard({ call, busy, onAnswer, onHangup }: Props) {
// Name und Nummer getrennt anzeigen, sonst die fertige Anzeigeform der App.
const title = call.peerName ?? call.peerNumber ?? call.peer ?? `Leitung ${call.line}`
const subtitle = call.peerName ? call.peerNumber : undefined
return (
<section className="card card--ringing" role="alert">
<div className="ringing">
<span className="ringing__icon" aria-hidden="true">
</span>
<div className="ringing__text">
<p className="ringing__label">Eingehender Anruf · Leitung {call.line}</p>
<p className="ringing__number">{title}</p>
{subtitle && <p className="ringing__meta">{subtitle}</p>}
</div>
<div className="ringing__actions">
<button
type="button"
className="button button--accept"
disabled={busy}
onClick={() => onAnswer(call.line)}
>
Annehmen
</button>
<button
type="button"
className="button button--reject"
disabled={busy}
onClick={() => onHangup(call.line)}
>
Ablehnen
</button>
</div>
</div>
</section>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react'
import type { LogEntry } from '../hooks/useSwyxTray'
const PREFIX: Record<LogEntry['direction'], string> = {
in: '←',
out: '→',
system: '•',
}
const timeFormat = new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
export default function MessageLog({ entries }: { entries: LogEntry[] }) {
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ block: 'nearest' })
}, [entries])
if (entries.length === 0) {
return <p className="log log--empty">Noch keine Nachrichten.</p>
}
return (
<div className="log" role="log" aria-live="polite">
{entries.map((entry) => (
<div key={entry.id} className={`log__entry log__entry--${entry.direction}`}>
<span className="log__time">{timeFormat.format(entry.at)}</span>
<span className="log__arrow">{PREFIX[entry.direction]}</span>
<span className="log__text">{entry.text}</span>
</div>
))}
<div ref={endRef} />
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import type { ConnectionStatus } from '../swyx/SwyxTrayClient'
const LABELS: Record<ConnectionStatus, string> = {
idle: 'Nicht verbunden',
connecting: 'Verbinde …',
open: 'Verbunden',
closing: 'Trenne …',
closed: 'Getrennt',
}
export default function StatusBadge({ status }: { status: ConnectionStatus }) {
return (
<span className={`badge badge--${status}`}>
<span className="badge__dot" aria-hidden="true" />
{LABELS[status]}
</span>
)
}
+42
View File
@@ -0,0 +1,42 @@
export interface Tab<Id extends string> {
id: Id
label: string
}
interface Props<Id extends string> {
tabs: Tab<Id>[]
active: Id
onChange: (id: Id) => void
}
/** Kennung des Knopfes zu einem Tab die Panels verweisen mit aria-labelledby darauf. */
export function tabId(id: string): string {
return `tab-${id}`
}
/** Kennung des Panels zu einem Tab. */
export function panelId(id: string): string {
return `panel-${id}`
}
/** Tab-Leiste. Native Knöpfe, damit die Tastaturbedienung von selbst funktioniert. */
export default function Tabs<Id extends string>({ tabs, active, onChange }: Props<Id>) {
return (
<nav className="tabs" role="tablist" aria-label="Bereiche">
{tabs.map((tab) => (
<button
key={tab.id}
id={tabId(tab.id)}
type="button"
role="tab"
className={`tabs__button${tab.id === active ? ' tabs__button--active' : ''}`}
aria-selected={tab.id === active}
aria-controls={panelId(tab.id)}
onClick={() => onChange(tab.id)}
>
{tab.label}
</button>
))}
</nav>
)
}
+30
View File
@@ -0,0 +1,30 @@
/** Fallback-Adresse des WebSocket-Servers, falls weder .env noch /api/config etwas liefern. */
export const DEFAULT_WS_HOST = '192.168.180.135'
export const DEFAULT_WS_PORT = 17654
export const DEFAULT_WS_PATH = '/ws'
export const DEFAULT_WS_URL =
import.meta.env.VITE_WS_URL ?? `ws://${DEFAULT_WS_HOST}:${DEFAULT_WS_PORT}${DEFAULT_WS_PATH}`
export interface ClientConfig {
websocketUrl: string
host: string
port: number
path: string
}
/**
* Holt die WebSocket-Adresse vom Backend. Schlägt der Aufruf fehl (Backend nicht
* erreichbar, rein statisches Hosting), bleibt es beim Default aus der .env.
*/
export async function fetchClientConfig(signal?: AbortSignal): Promise<string> {
const response = await fetch('/api/config', { signal })
if (!response.ok) {
throw new Error(`/api/config antwortete mit HTTP ${response.status}`)
}
const config = (await response.json()) as Partial<ClientConfig>
if (!config.websocketUrl) {
throw new Error('/api/config lieferte keine websocketUrl')
}
return config.websocketUrl
}
@@ -0,0 +1,44 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { describeCall, type CallEvent } from '../swyx/protocol'
type Permission = NotificationPermission | 'unsupported'
function currentPermission(): Permission {
return typeof Notification === 'undefined' ? 'unsupported' : Notification.permission
}
/**
* Zeigt bei einem eingehenden Anruf eine System-Benachrichtigung, damit der
* Anruf auch bemerkt wird, wenn der Tab im Hintergrund liegt.
*/
export function useCallNotifications(ringingCall: CallEvent | null) {
const [permission, setPermission] = useState<Permission>(currentPermission)
// Pro klingelnder Leitung nur einmal benachrichtigen.
const notifiedRef = useRef<number | null>(null)
const requestPermission = useCallback(() => {
if (typeof Notification === 'undefined') return
void Notification.requestPermission().then(setPermission)
}, [])
useEffect(() => {
if (!ringingCall) {
notifiedRef.current = null
return
}
if (permission !== 'granted' || notifiedRef.current === ringingCall.line) return
notifiedRef.current = ringingCall.line
const notification = new Notification(`Eingehender Anruf · Leitung ${ringingCall.line}`, {
body: describeCall(ringingCall),
tag: `swyx-line-${ringingCall.line}`,
})
notification.onclick = () => {
window.focus()
notification.close()
}
return () => notification.close()
}, [ringingCall, permission])
return { permission, requestPermission }
}
+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,
}
}
+597
View File
@@ -0,0 +1,597 @@
:root {
--bg: #0f1116;
--surface: #171a21;
--surface-2: #1f2530;
--border: #2b3341;
--text: #e6e9ef;
--text-muted: #98a2b3;
--accent: #4f8cff;
--ok: #35c98a;
--warn: #e0b341;
--err: #f2585b;
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f5f7fa;
--surface: #ffffff;
--surface-2: #eef1f6;
--border: #d8dee9;
--text: #1b2230;
--text-muted: #5c6779;
color-scheme: light;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
line-height: 1.5;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
}
.page {
max-width: 900px;
margin: 0 auto;
padding: 32px 20px 48px;
display: flex;
flex-direction: column;
gap: 20px;
}
.page__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.page__header h1 {
margin: 0;
font-size: 26px;
letter-spacing: -0.02em;
}
.page__subtitle {
margin: 2px 0 0;
color: var(--text-muted);
}
.page__main {
display: flex;
flex-direction: column;
gap: 16px;
}
.page__footer {
color: var(--text-muted);
font-size: 13px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
.card h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.row {
display: flex;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.row__actions {
display: flex;
gap: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1 1 260px;
min-width: 0;
}
.field__label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.field__input {
width: 100%;
padding: 9px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
font: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 14px;
}
.field__input:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.field__input:disabled {
opacity: 0.55;
}
.button {
padding: 9px 16px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.button:hover:not(:disabled) {
border-color: var(--accent);
}
.button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.button--primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.button--ghost {
background: transparent;
padding: 5px 10px;
font-size: 13px;
}
.note {
margin: 0;
font-size: 13px;
color: var(--text-muted);
}
.note--error {
color: var(--err);
}
.note--success {
color: var(--ok);
}
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
}
.tabs__button {
padding: 9px 16px;
border: 1px solid transparent;
/* Die aktive Kante überdeckt die Linie der Leiste. */
border-bottom: none;
margin-bottom: -1px;
border-radius: 10px 10px 0 0;
background: transparent;
color: var(--text-muted);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.tabs__button:hover:not(.tabs__button--active) {
color: var(--text);
}
.tabs__button--active {
background: var(--surface);
border-color: var(--border);
color: var(--text);
}
[role='tabpanel'] {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Muss nach der Regel oben stehen: display:flex schlägt sonst das hidden-Attribut. */
[role='tabpanel'][hidden] {
display: none;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
.badge__dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--text-muted);
}
.badge--open .badge__dot {
background: var(--ok);
}
.badge--connecting .badge__dot,
.badge--closing .badge__dot {
background: var(--warn);
animation: pulse 1.1s ease-in-out infinite;
}
.badge--error .badge__dot,
.badge--closed .badge__dot {
background: var(--err);
}
@keyframes pulse {
50% {
opacity: 0.25;
}
}
@media (prefers-reduced-motion: reduce) {
.badge__dot {
animation: none;
}
}
/* --- Eingehender Anruf --- */
.card--ringing {
border-color: var(--ok);
box-shadow: 0 0 0 1px var(--ok);
}
.ringing {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.ringing__icon {
font-size: 30px;
animation: shake 1s ease-in-out infinite;
}
.ringing__text {
flex: 1 1 200px;
min-width: 0;
}
.ringing__label {
margin: 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.ringing__number {
margin: 2px 0 0;
font-size: 22px;
font-weight: 600;
word-break: break-all;
}
.ringing__meta {
margin: 2px 0 0;
color: var(--text-muted);
font-size: 13px;
}
.ringing__actions {
display: flex;
gap: 8px;
}
@keyframes shake {
25% {
transform: rotate(-14deg);
}
75% {
transform: rotate(14deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ringing__icon {
animation: none;
}
}
.button--accept {
background: var(--ok);
border-color: var(--ok);
color: #06281c;
}
.button--reject {
background: var(--err);
border-color: var(--err);
color: #fff;
}
.button--small {
padding: 5px 12px;
font-size: 13px;
}
/* --- Anruflisten --- */
.calls,
.history {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.calls__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
}
.calls__item--incoming {
border-color: var(--ok);
}
.calls__line,
.history__line {
flex: 0 0 auto;
padding: 2px 8px;
border-radius: 6px;
background: var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
font-weight: 600;
}
.calls__info {
display: flex;
flex-direction: column;
min-width: 0;
}
.calls__number {
font-weight: 600;
word-break: break-all;
}
.calls__state {
font-size: 13px;
color: var(--text-muted);
}
.calls__actions {
display: flex;
gap: 8px;
}
.history__item {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
font-size: 14px;
}
.history__time {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
color: var(--text-muted);
min-width: 68px;
}
.history__badge {
padding: 1px 8px;
border-radius: 999px;
border: 1px solid var(--border);
font-size: 12px;
white-space: nowrap;
}
.history__badge--incoming {
border-color: var(--ok);
color: var(--ok);
}
.history__badge--connected,
.history__badge--outgoing {
border-color: var(--accent);
color: var(--accent);
}
.history__badge--ended {
border-color: var(--err);
color: var(--err);
}
.history__number {
word-break: break-all;
}
.history__meta {
color: var(--text-muted);
font-size: 13px;
}
/* --- Tabs des Firefox-Plugins --- */
.tablist {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.tablist__item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
}
.tablist__item--active {
border-color: var(--accent);
}
.tablist__id {
flex: 0 0 auto;
padding: 2px 8px;
border-radius: 6px;
background: var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
font-weight: 600;
}
.tablist__info {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.tablist__title {
display: flex;
align-items: baseline;
gap: 8px;
font-weight: 600;
/* Lange Titel dürfen die Zeile nicht sprengen. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tablist__flag {
flex: 0 0 auto;
padding: 1px 8px;
border-radius: 999px;
border: 1px solid var(--accent);
color: var(--accent);
font-size: 12px;
font-weight: 500;
}
.tablist__url {
font-size: 13px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log {
height: 300px;
overflow-y: auto;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
display: flex;
flex-direction: column;
gap: 4px;
}
.log--empty {
align-items: center;
justify-content: center;
color: var(--text-muted);
margin: 0;
}
.log__entry {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 8px;
align-items: baseline;
}
.log__time {
color: var(--text-muted);
}
.log__text {
word-break: break-word;
white-space: pre-wrap;
}
.log__entry--in .log__arrow {
color: var(--ok);
}
.log__entry--out .log__arrow {
color: var(--accent);
}
.log__entry--system {
color: var(--text-muted);
}
+15
View File
@@ -0,0 +1,15 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
const container = document.getElementById('root')
if (!container) {
throw new Error('Root-Element #root nicht gefunden')
}
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
)
+248
View File
@@ -0,0 +1,248 @@
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { DEFAULT_WS_URL, fetchClientConfig } from '../config'
import { useSwyxTray } from '../hooks/useSwyxTray'
import { useCallNotifications } from '../hooks/useCallNotifications'
import StatusBadge from '../components/StatusBadge'
import MessageLog from '../components/MessageLog'
import IncomingCallCard from '../components/IncomingCallCard'
import DialPanel from '../components/DialPanel'
import BrowserTabsPanel from '../components/BrowserTabsPanel'
import Tabs, { panelId, tabId, type Tab } from '../components/Tabs'
import { ActiveCallList, CallHistory } from '../components/CallList'
type TabName = 'calls' | 'browsertabs' | 'connection'
const TABS: Tab<TabName>[] = [
{ id: 'calls', label: 'Anrufe' },
{ id: 'browsertabs', label: 'Tabs' },
{ id: 'connection', label: 'Verbindung' },
]
/** Panel eines Tabs; ausgeblendet statt ausgebaut, damit Eingaben erhalten bleiben. */
function TabPanel({ id, active, children }: { id: TabName; active: boolean; children: ReactNode }) {
return (
<div id={panelId(id)} role="tabpanel" aria-labelledby={tabId(id)} hidden={!active}>
{children}
</div>
)
}
export default function HomePage() {
const [url, setUrl] = useState(DEFAULT_WS_URL)
const [configNote, setConfigNote] = useState<string | null>(null)
const [tab, setTab] = useState<TabName>('calls')
const [draft, setDraft] = useState('')
const tray = useSwyxTray()
const { status, error, log, history, calls, ringingCall, busy, tray: appState } = tray
const isConnected = status === 'open'
const isBusyConnection = status === 'connecting' || status === 'open' || status === 'closing'
const { permission, requestPermission } = useCallNotifications(ringingCall)
// Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren.
const urlTouched = useRef(false)
useEffect(() => {
const controller = new AbortController()
fetchClientConfig(controller.signal)
.then((backendUrl) => {
if (controller.signal.aborted) return
setConfigNote(`Adresse vom Backend übernommen: ${backendUrl}`)
if (!urlTouched.current) setUrl(backendUrl)
})
.catch((e: unknown) => {
if (controller.signal.aborted) return
const message = e instanceof Error ? e.message : String(e)
setConfigNote(`Backend-Konfiguration nicht abrufbar (${message}) Standardadresse wird verwendet.`)
})
return () => controller.abort()
}, [])
function handleConnect(event: FormEvent) {
event.preventDefault()
const target = url.trim()
if (target) tray.connect(target)
}
function handleSendRaw(event: FormEvent) {
event.preventDefault()
const message = draft.trim()
if (message && tray.sendRaw(message)) setDraft('')
}
return (
<div className="page">
<header className="page__header">
<div>
<h1>SwyxWeb</h1>
<p className="page__subtitle">Telefonie über die SwyxTray-App</p>
</div>
<StatusBadge status={status} />
</header>
<main className="page__main">
{/* Steht bewusst über den Tabs: Ein klingelnder Anruf darf nicht davon
abhängen, welcher Bereich gerade offen ist. */}
{ringingCall && (
<IncomingCallCard
call={ringingCall}
busy={busy}
onAnswer={(line) => void tray.answer(line)}
onHangup={(line) => void tray.hangup(line)}
/>
)}
<Tabs tabs={TABS} active={tab} onChange={setTab} />
<TabPanel id="calls" active={tab === 'calls'}>
<section className="card">
<h2>Anrufen</h2>
<DialPanel disabled={!isConnected} busy={busy} onDial={(number) => void tray.dial(number)} />
<p className="note">
Die Quittung bestätigt nur die Annahme des Wählauftrags der Verlauf erscheint
anschließend unter Leitungen".
</p>
{error && <p className="note note--error">{error}</p>}
</section>
<section className="card">
<h2>Leitungen</h2>
<ActiveCallList
calls={calls}
busy={busy}
onAnswer={(line) => void tray.answer(line)}
onHangup={(line) => void tray.hangup(line)}
/>
{isConnected && (appState.statusText || appState.user) && (
<p className={`note${appState.connected === false ? ' note--error' : ''}`}>
SwyxIt!: {appState.statusText ?? appState.overall ?? 'unbekannt'}
{appState.user && ` · ${appState.user}`}
{appState.connected === false && ' · keine Verbindung zu SwyxIt!'}
{appState.serverUp === false && ' · SwyxServer nicht erreichbar'}
</p>
)}
</section>
<section className="card">
<h2>Verlauf</h2>
<CallHistory events={history} />
</section>
</TabPanel>
<TabPanel id="browsertabs" active={tab === 'browsertabs'}>
<section className="card">
<BrowserTabsPanel
active={tab === 'browsertabs'}
disabled={!isConnected}
busy={busy}
// Unverpackt weitergereicht: die Rückrufe sind stabil, damit das
// Panel nicht bei jedem Rendern neu lädt.
onList={tray.listTabs}
onOpen={tray.openTab}
onClose={tray.closeTab}
/>
{error && <p className="note note--error">{error}</p>}
</section>
</TabPanel>
<TabPanel id="connection" active={tab === 'connection'}>
<section className="card">
<h2>Verbindung</h2>
<form className="row" onSubmit={handleConnect}>
<label className="field">
<span className="field__label">SwyxTray-Adresse</span>
<input
className="field__input"
type="text"
value={url}
spellCheck={false}
autoComplete="off"
placeholder="ws://192.168.180.135:17654/ws"
onChange={(e) => {
urlTouched.current = true
setUrl(e.target.value)
}}
/>
</label>
<div className="row__actions">
<button type="submit" className="button button--primary" disabled={!url.trim()}>
{isConnected ? 'Neu verbinden' : 'Verbinden'}
</button>
<button
type="button"
className="button"
onClick={tray.disconnect}
disabled={!isBusyConnection}
>
Trennen
</button>
</div>
</form>
{configNote && <p className="note">{configNote}</p>}
{error && <p className="note note--error">{error}</p>}
{permission !== 'granted' && (
<p className="note">
Für Hinweise bei eingehenden Anrufen auch außerhalb des Tabs:{' '}
<button type="button" className="button button--ghost" onClick={requestPermission}>
Benachrichtigungen erlauben
</button>
</p>
)}
</section>
<section className="card">
<h2>Diagnose</h2>
<MessageLog entries={log} />
<form className="row" onSubmit={handleSendRaw}>
<label className="field">
<span className="field__label">Rohnachricht senden</span>
<input
className="field__input"
type="text"
value={draft}
disabled={!isConnected}
placeholder={'{"id":1,"cmd":"status"}'}
onChange={(e) => setDraft(e.target.value)}
/>
</label>
<div className="row__actions">
<button
type="submit"
className="button button--primary"
disabled={!isConnected || !draft.trim()}
>
Senden
</button>
<button
type="button"
className="button"
onClick={() => void tray.refresh()}
disabled={!isConnected || busy}
>
Status abfragen
</button>
<button
type="button"
className="button button--ghost"
onClick={tray.clearLog}
disabled={log.length === 0}
>
Log leeren
</button>
</div>
</form>
</section>
</TabPanel>
</main>
<footer className="page__footer">
SwyxTray · Kommandos <code>call</code> / <code>answer</code> / <code>hangup</code> /{' '}
<code>tabs</code> / <code>opentab</code> / <code>closetab</code> · Zustand über{' '}
<code>snapshot</code> · Standardziel <code>{DEFAULT_WS_URL}</code>
</footer>
</div>
)
}
+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)
}
}
}
+334
View File
@@ -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')
})
})
+323
View File
@@ -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}`
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_WS_URL?: string
readonly VITE_BACKEND_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}