/** * Application state — receives live updates from the proxy via WebSocket. */ export interface IProviderStatus { id: string; displayName: string; registered: boolean; publicIp: string | null; } export interface IDeviceStatus { id: string; displayName: string; contact: { address: string; port: number } | null; aor: string; connected: boolean; isBrowser: boolean; } export interface ILegStatus { id: string; type: 'sip-device' | 'sip-provider' | 'webrtc'; state: string; remoteMedia: { address: string; port: number } | null; rtpPort: number | null; pktSent: number; pktReceived: number; codec: string | null; transcoding: boolean; } export interface ICallStatus { id: string; state: string; direction: 'inbound' | 'outbound' | 'internal'; callerNumber: string | null; calleeNumber: string | null; providerUsed: string | null; createdAt: number; duration: number; legs: ILegStatus[]; } export interface ICallHistoryEntry { id: string; direction: 'inbound' | 'outbound' | 'internal'; callerNumber: string | null; calleeNumber: string | null; providerUsed: string | null; startedAt: number; duration: number; } export interface IContact { id: string; name: string; number: string; company?: string; notes?: string; starred?: boolean; } export interface IAppState { connected: boolean; browserDeviceId: string | null; uptime: number; providers: IProviderStatus[]; devices: IDeviceStatus[]; calls: ICallStatus[]; callHistory: ICallHistoryEntry[]; contacts: IContact[]; selectedContact: IContact | null; logLines: string[]; } const MAX_LOG = 200; let knownInstanceId: string | null = null; class AppStateManager { private state: IAppState = { connected: false, browserDeviceId: null, uptime: 0, providers: [], devices: [], calls: [], callHistory: [], contacts: [], selectedContact: null, logLines: [], }; private listeners = new Set<(state: IAppState) => void>(); private ws: WebSocket | null = null; getState(): IAppState { return this.state; } subscribe(listener: (state: IAppState) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } private update(partial: Partial): void { this.state = { ...this.state, ...partial }; for (const fn of this.listeners) fn(this.state); } addLog(msg: string): void { const lines = [...this.state.logLines, msg]; if (lines.length > MAX_LOG) lines.splice(0, lines.length - MAX_LOG); this.update({ logLines: lines }); } connect(): void { // Guard against duplicate connections. if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { return; } const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(`${proto}//${location.host}/ws`); ws.onopen = () => { this.ws = ws; (window as any).__sipRouterWs = ws; this.update({ connected: true }); }; ws.onclose = () => { this.ws = null; this.update({ connected: false }); setTimeout(() => this.connect(), 2000); }; ws.onerror = () => ws.close(); ws.onmessage = (ev) => { try { const m = JSON.parse(ev.data); if (m.type === 'status') { // Auto-reload if backend restarted (different instance). if (m.data.instanceId) { if (knownInstanceId && knownInstanceId !== m.data.instanceId) { location.reload(); return; } knownInstanceId = m.data.instanceId; } this.update({ uptime: m.data.uptime, providers: m.data.providers || [], devices: m.data.devices || [], calls: m.data.calls || [], callHistory: m.data.callHistory || [], contacts: m.data.contacts || [], }); } else if (m.type === 'log') { this.addLog(`${m.ts} ${m.data.message}`); } else if (m.type === 'call-update') { // Real-time call state update — will be picked up by next status snapshot. } else if (m.type?.startsWith('webrtc-')) { // Route to any registered WebRTC signaling handler. const handler = (window as any).__sipRouterWebRtcHandler; if (handler) handler(m); } } catch { /* ignore */ } }; } setBrowserDeviceId(deviceId: string): void { this.update({ browserDeviceId: deviceId }); } selectContact(contact: IContact | null): void { this.update({ selectedContact: contact }); } clearSelectedContact(): void { this.update({ selectedContact: null }); } async apiCall(number: string, deviceId?: string, providerId?: string): Promise<{ ok: boolean; callId?: string; error?: string }> { const res = await fetch('/api/call', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ number, deviceId, providerId }), }); return res.json(); } async apiHangup(callId: string): Promise<{ ok: boolean }> { const res = await fetch('/api/hangup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ callId }), }); return res.json(); } async apiAddLeg(callId: string, deviceId: string): Promise<{ ok: boolean }> { const res = await fetch(`/api/call/${callId}/addleg`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ deviceId }), }); return res.json(); } async apiAddExternal(callId: string, number: string, providerId?: string): Promise<{ ok: boolean }> { const res = await fetch(`/api/call/${callId}/addexternal`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ number, providerId }), }); return res.json(); } async apiRemoveLeg(callId: string, legId: string): Promise<{ ok: boolean }> { const res = await fetch(`/api/call/${callId}/removeleg`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ legId }), }); return res.json(); } async apiTransfer(sourceCallId: string, legId: string, targetCallId: string): Promise<{ ok: boolean }> { const res = await fetch('/api/transfer', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceCallId, legId, targetCallId }), }); return res.json(); } async apiGetConfig(): Promise { const res = await fetch('/api/config'); return res.json(); } async apiSaveConfig(updates: any): Promise<{ ok: boolean }> { const res = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }); return res.json(); } } export const appState = new AppStateManager();