/** * RVS (Rendezvous Server) - WebSocket-Verbindungsmanager * * Verwaltet die persistente WebSocket-Verbindung zwischen der ARIA Cockpit App * und dem Rendezvous Server. Unterstützt Auto-Reconnect, Heartbeat und * typisierte Nachrichten. */ import AsyncStorage from '@react-native-async-storage/async-storage'; import { NativeModules, Platform } from 'react-native'; const TlsTrust: any = NativeModules.TlsTrust; // --- Typen --- export type ConnectionState = 'connecting' | 'connected' | 'disconnected'; export type MessageType = 'chat' | 'audio' | 'file' | 'location' | 'mode' | 'log' | 'event' | 'update_available' | string; export interface RVSMessage { type: MessageType; payload: Record; timestamp: number; } export interface ConnectionConfig { host: string; port: number; token: string; useTLS: boolean; } /** * Wie sicher ist die Verbindung? Fuer das Schloss-Symbol in der Tab-Leiste. * secure — verschluesselt, Zertifikat in Ordnung * expired — verschluesselt, aber das Zertifikat ist abgelaufen (per Schalter erlaubt) * cert_error— keine Verbindung, weil das Zertifikat abgelehnt wurde * fallback — verbunden OHNE Verschluesselung, weil TLS fehlschlug * plain — verbunden ohne Verschluesselung, so konfiguriert * unknown — (noch) nichts Genaues */ export type SecurityKind = 'secure' | 'expired' | 'cert_error' | 'fallback' | 'plain' | 'unknown'; export interface SecurityState { kind: SecurityKind; /** Pruefergebnis der App: ok | expired_accepted | expired | invalid | unknown */ certStatus: string; /** Ablaufdatum des Server-Zertifikats (ms), 0 = unbekannt */ notAfter: number; /** Bei fallback/cert_error: warum TLS nicht ging */ reason: string; } type SecurityCallback = (state: SecurityState) => void; type MessageCallback = (message: RVSMessage) => void; type StateCallback = (state: ConnectionState) => void; /** Einzelner Eintrag im Verbindungslog */ export interface ConnectionLogEntry { timestamp: number; level: 'info' | 'warn' | 'error'; message: string; } type LogCallback = (entry: ConnectionLogEntry) => void; // --- Konstanten --- const HEARTBEAT_INTERVAL_MS = 25_000; const INITIAL_RECONNECT_DELAY_MS = 1_000; const MAX_RECONNECT_DELAY_MS = 30_000; const RECONNECT_BACKOFF_FACTOR = 2; const MAX_LOG_ENTRIES = 100; // --- RVS-Klasse --- class RVSConnection { private ws: WebSocket | null = null; private config: ConnectionConfig | null = null; private state: ConnectionState = 'disconnected'; private heartbeatTimer: ReturnType | null = null; private reconnectTimer: ReturnType | null = null; private reconnectDelay: number = INITIAL_RECONNECT_DELAY_MS; private shouldReconnect: boolean = false; private messageListeners: MessageCallback[] = []; private stateListeners: StateCallback[] = []; private logListeners: LogCallback[] = []; private connectionLog: ConnectionLogEntry[] = []; private usingTLSFallback: boolean = false; private securityListeners: SecurityCallback[] = []; private security: SecurityState = { kind: 'unknown', certStatus: 'unknown', notAfter: 0, reason: '' }; /** Letzter Zertifikats-Fehler beim wss://-Versuch (bleibt bis wss:// wieder klappt) */ private certFailure: { status: string; notAfter: number; detail: string } | null = null; // --- Konfiguration --- /** Verbindungsdaten setzen (z.B. nach QR-Scan) */ setConfig(config: ConnectionConfig): void { this.config = config; this.saveConfig(config); } getConfig(): ConnectionConfig | null { return this.config; } getState(): ConnectionState { return this.state; } // --- Verbindung --- /** Verbindung zum RVS aufbauen. force=true: bestehende Connection hart * schliessen + neu verbinden (auch wenn JS denkt readyState=OPEN — kann * nach Hintergrund-Pause ein Zombie-WS sein wo TCP tot ist aber JS-State * noch OPEN zeigt; in dem Fall war "Bereits verbunden" ein No-Op und * Stefan musste manuell zigmal klicken). */ connect(force: boolean = false): void { if (!this.config) { this.log('warn', 'Keine Verbindungskonfiguration vorhanden'); return; } if (!force && this.ws?.readyState === WebSocket.OPEN) { this.log('info', 'Bereits verbunden'); return; } // Wenn ein WS-Objekt da ist (Zombie oder lebend), sauber abreissen // bevor wir einen neuen aufbauen — sonst gibt's zwei parallele // Verbindungen + doppelte Events. if (this.ws) { this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect'); this.detachSocket(this.ws); this.ws = null; } this.shouldReconnect = true; this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS; this.usingTLSFallback = false; this.clearTimers(); this.log('info', `Verbindungsaufbau zu ${this.config.host}:${this.config.port} (TLS: ${this.config.useTLS ? 'ja' : 'nein'})`); this.establishConnection(); } /** Verbindung trennen (kein Auto-Reconnect) */ disconnect(): void { this.shouldReconnect = false; this.clearTimers(); if (this.ws) { this.ws.close(1000, 'Benutzer hat getrennt'); this.ws = null; } this.log('info', 'Verbindung getrennt (manuell)'); this.setState('disconnected'); } /** * Ist die Verbindung WIRKLICH offen? Fragt den Socket selbst, nicht den * gemerkten Zustand — der kann (z.B. nach einem Zombie) falsch sein. Wer * davon abhaengig macht, ob ueberhaupt gesendet wird, muss das Echte wissen. */ isSocketOpen(): boolean { return !!this.ws && this.ws.readyState === WebSocket.OPEN; } /** Nachricht an den RVS senden */ send(type: MessageType, payload: Record): void { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { console.warn('[RVS] Kann nicht senden - nicht verbunden'); return; } const message: RVSMessage = { type, payload, timestamp: Date.now(), }; this.ws.send(JSON.stringify(message)); } // --- Event-Listener --- /** Callback fuer eingehende Nachrichten registrieren */ onMessage(callback: MessageCallback): () => void { this.messageListeners.push(callback); // Gibt Unsubscribe-Funktion zurueck return () => { this.messageListeners = this.messageListeners.filter(cb => cb !== callback); }; } /** Callback fuer Verbindungsstatus-Aenderungen registrieren */ onStateChange(callback: StateCallback): () => void { this.stateListeners.push(callback); return () => { this.stateListeners = this.stateListeners.filter(cb => cb !== callback); }; } /** Callback fuer Verbindungslog-Eintraege registrieren */ onLog(callback: LogCallback): () => void { this.logListeners.push(callback); return () => { this.logListeners = this.logListeners.filter(cb => cb !== callback); }; } /** Gesamten Verbindungslog abrufen */ getConnectionLog(): ConnectionLogEntry[] { return [...this.connectionLog]; } // --- Interne Methoden --- /** Eintrag ins Verbindungslog schreiben */ private log(level: ConnectionLogEntry['level'], message: string): void { const entry: ConnectionLogEntry = { timestamp: Date.now(), level, message }; this.connectionLog = [...this.connectionLog.slice(-(MAX_LOG_ENTRIES - 1)), entry]; this.logListeners.forEach(cb => cb(entry)); const prefix = level === 'error' ? 'ERROR' : level === 'warn' ? 'WARN' : 'INFO'; console.log(`[RVS] [${prefix}] ${message}`); } private establishConnection(): void { if (!this.config) return; this.setState('connecting'); const useTLS = this.config.useTLS && !this.usingTLSFallback; const protocol = useTLS ? 'wss' : 'ws'; const url = `${protocol}://${this.config.host}:${this.config.port}?token=${this.config.token}`; this.log('info', `Verbinde: ${protocol}://${this.config.host}:${this.config.port}`); try { this.ws = new WebSocket(url); this.ws.onopen = () => { const tlsInfo = this.usingTLSFallback ? ' (TLS-Fallback: ws://)' : ''; this.log('info', `Verbunden${tlsInfo}`); this.setState('connected'); this.updateSecurityOnOpen(useTLS); this.sendClientHello(); this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS; this.startHeartbeat(); }; const socket = this.ws; // genau DIESE Verbindung (nicht 'this.ws' spaeter) this.ws.onmessage = (event: WebSocketMessageEvent) => { // Nur die AKTUELLE Verbindung darf Nachrichten einspeisen. Sonst // koennte eine alte, laengst aufgegebene Verbindung weiter Daten // liefern und vortaeuschen, es sei alles in Ordnung. if (socket !== this.ws) return; try { const message: RVSMessage = JSON.parse(event.data as string); this.notifyMessageListeners(message); } catch (err) { this.log('error', `Nachricht parsen fehlgeschlagen: ${err}`); } }; this.ws.onclose = (event) => { this.log('info', `Verbindung geschlossen (Code: ${event.code}, Reason: ${event.reason || '-'})`); this.clearTimers(); this.ws = null; this.setState('disconnected'); this.setSecurity(this.certFailure ? { kind: 'cert_error', certStatus: this.certFailure.status, notAfter: this.certFailure.notAfter, reason: this.certFailure.detail } : { kind: 'unknown', certStatus: 'unknown', notAfter: 0, reason: '' }); // Sticky-Fallback-Reset: beim naechsten Reconnect wieder primary // (wss://) versuchen statt fuer immer auf ws:// zu kleben. War // der Hauptgrund warum die App nach Hintergrund-Rueckkehr nicht // mehr verband — TLS-Handshake-Timeout in einem Reconnect → Fallback // auf ws:// → Caddy refused → endlos im Fallback haengen. if (this.usingTLSFallback) { this.log('info', 'Reset TLS-Fallback fuer naechsten Reconnect (zurueck zu wss://)'); this.usingTLSFallback = false; } if (this.shouldReconnect) { this.scheduleReconnect(); } }; this.ws.onerror = (error) => { const errorMsg = (error as any)?.message || 'Unbekannter Fehler'; this.log('error', `WebSocket-Fehler: ${errorMsg}`); if (useTLS) this.checkCertFailure(errorMsg); // TLS-Fallback: Wenn wss:// fehlschlaegt, auf ws:// wechseln if (this.config?.useTLS && !this.usingTLSFallback) { this.usingTLSFallback = true; // shouldReconnect kurz deaktivieren damit onclose keinen // parallelen Reconnect ausloest — wir machen das selbst this.shouldReconnect = false; this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)'); this.clearTimers(); if (this.ws) { this.detachSocket(this.ws); // inkl. onmessage — sonst bleibt ein Zombie, der weiter empfaengt } this.ws = null; this.shouldReconnect = true; this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS; this.establishConnection(); return; } }; } catch (err) { this.log('error', `Verbindungsfehler: ${err}`); this.setState('disconnected'); if (this.shouldReconnect) { this.scheduleReconnect(); } } } /** Beim RVS vorstellen — Diagnostic zeigt so, welches Handy im Raum ist. */ private sendClientHello(): void { try { const c: any = (Platform as any).constants || {}; const device = [c.Manufacturer, c.Model].filter(Boolean).join(' ') || Platform.OS; let version = ''; try { version = require('../../package.json').version || ''; } catch (_) {} this.send('client_hello', { kind: 'App', name: device, detail: `Android ${c.Release || Platform.Version}`, version }); } catch (_) {} } // --- Verbindungs-Sicherheit (Schloss-Symbol) --- getSecurity(): SecurityState { return this.security; } onSecurityChange(callback: SecurityCallback): () => void { this.securityListeners.push(callback); return () => { this.securityListeners = this.securityListeners.filter(cb => cb !== callback); }; } private setSecurity(next: SecurityState): void { const cur = this.security; if (cur.kind === next.kind && cur.certStatus === next.certStatus && cur.notAfter === next.notAfter && cur.reason === next.reason) return; this.security = next; this.securityListeners.forEach(cb => cb(next)); } private async nativeCertStatus(): Promise<{ status: string; notAfter: number; detail: string }> { try { const r = await TlsTrust?.getStatus(); return { status: r?.status || 'unknown', notAfter: Number(r?.notAfter) || 0, detail: r?.detail || '' }; } catch (_) { return { status: 'unknown', notAfter: 0, detail: '' }; } } private async updateSecurityOnOpen(viaTLS: boolean): Promise { if (viaTLS) { this.certFailure = null; const c = await this.nativeCertStatus(); if (c.status === 'expired_accepted') { this.log('warn', `Zertifikat abgelaufen (${new Date(c.notAfter).toLocaleDateString()}) — per Einstellung akzeptiert`); } this.setSecurity({ kind: c.status === 'expired_accepted' ? 'expired' : 'secure', certStatus: c.status, notAfter: c.notAfter, reason: '' }); } else if (this.config?.useTLS) { // wss:// ging nicht, ws:// schon → unverschluesselt per Fallback this.setSecurity({ kind: 'fallback', certStatus: this.certFailure?.status || 'unknown', notAfter: this.certFailure?.notAfter || 0, reason: this.certFailure?.detail || '' }); } else { this.setSecurity({ kind: 'plain', certStatus: 'unknown', notAfter: 0, reason: '' }); } } /** Lag der wss://-Fehler am Zertifikat? Dann merken (fuer Schloss + Hinweis). */ private async checkCertFailure(errorMsg: string): Promise { if (!/cert|trust anchor|chain validation|handshake|ssl/i.test(errorMsg)) return; const c = await this.nativeCertStatus(); if (c.status !== 'expired' && c.status !== 'invalid') return; this.certFailure = { status: c.status, notAfter: c.notAfter, detail: c.detail || errorMsg }; this.log('error', c.status === 'expired' ? `Server-Zertifikat abgelaufen seit ${new Date(c.notAfter).toLocaleString()}` : 'Server-Zertifikat ungueltig'); if (this.state !== 'connected') { this.setSecurity({ kind: 'cert_error', certStatus: c.status, notAfter: c.notAfter, reason: this.certFailure.detail }); } else if (this.security.kind === 'fallback') { // ws://-Fallback war schneller verbunden als diese Pruefung — Grund nachtragen this.setSecurity({ ...this.security, certStatus: c.status, notAfter: c.notAfter, reason: this.certFailure.detail }); } } /** Schalter "Abgelaufenes Zertifikat akzeptieren" */ async getAllowExpiredCert(): Promise { try { return !!(await TlsTrust?.getStatus())?.allowExpired; } catch (_) { return false; } } async setAllowExpiredCert(value: boolean): Promise { try { await TlsTrust?.setAllowExpired(value); } catch (_) {} this.log('info', `Abgelaufene Zertifikate ${value ? 'werden akzeptiert' : 'werden abgelehnt'}`); } /** Reconnect mit exponentiellem Backoff planen */ private scheduleReconnect(): void { this.log('info', `Reconnect in ${this.reconnectDelay / 1000}s...`); this.reconnectTimer = setTimeout(() => { this.establishConnection(); }, this.reconnectDelay); // Exponentieller Backoff: 1s -> 2s -> 4s -> 8s -> ... -> max 30s this.reconnectDelay = Math.min( this.reconnectDelay * RECONNECT_BACKOFF_FACTOR, MAX_RECONNECT_DELAY_MS, ); } /** Heartbeat starten (alle 25 Sekunden) */ private startHeartbeat(): void { this.heartbeatTimer = setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() })); } }, HEARTBEAT_INTERVAL_MS); } /** * Klemmt eine Verbindung VOLLSTAENDIG ab, bevor sie verworfen wird. * * Wichtig: frueher wurden nur onclose/onerror entfernt — onmessage blieb * haengen. Eine aufgegebene (Zombie-)Verbindung lieferte dadurch weiter * Nachrichten an die App, waehrend gesendet wurde ueber die neue/tote. * Symptom: die App zeigt "getrennt", ARIAs Sprachausgabe kommt aber trotzdem * an — und nichts, was man schreibt oder spricht, erreicht den Server. */ private detachSocket(ws: WebSocket): void { try { ws.onopen = null; ws.onmessage = null; // <- das fehlte ws.onclose = null; // verhindert doppelten Reconnect ws.onerror = null; ws.close(); } catch (_) {} } private clearTimers(): void { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } private setState(state: ConnectionState): void { if (this.state !== state) { this.state = state; this.stateListeners.forEach(cb => cb(state)); } } private notifyMessageListeners(message: RVSMessage): void { this.messageListeners.forEach(cb => cb(message)); } // --- Persistenz --- private static readonly STORAGE_KEY = 'rvs_config'; private async saveConfig(config: ConnectionConfig): Promise { try { await AsyncStorage.setItem(RVSConnection.STORAGE_KEY, JSON.stringify(config)); console.log('[RVS] Konfiguration gespeichert'); } catch (err) { console.error('[RVS] Fehler beim Speichern:', err); } } async loadConfig(): Promise { try { const data = await AsyncStorage.getItem(RVSConnection.STORAGE_KEY); if (data) { this.config = JSON.parse(data); console.log('[RVS] Konfiguration geladen'); return this.config; } return null; } catch (err) { console.error('[RVS] Fehler beim Laden:', err); return null; } } } // Singleton-Instanz const rvs = new RVSConnection(); export default rvs;