From af1543135d42adc2e97dc5243aeef7418cd3b00d Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 08:39:26 +0100
Subject: [PATCH] feat: dual address auto-switch, custom icon, notifications, image support

---
 services/websocket.ts |  139 ++++++++++++++++++++++++++++-----------------
 1 files changed, 86 insertions(+), 53 deletions(-)

diff --git a/services/websocket.ts b/services/websocket.ts
index 2b24eef..03f1a59 100644
--- a/services/websocket.ts
+++ b/services/websocket.ts
@@ -1,5 +1,6 @@
-import { WebSocketMessage } from "../types";
+import { WsOutgoing } from "../types";
 
+type WebSocketMessage = Record<string, unknown>;
 type MessageCallback = (data: WebSocketMessage) => void;
 type StatusCallback = () => void;
 type ErrorCallback = (error: Event) => void;
@@ -14,97 +15,125 @@
 const INITIAL_RECONNECT_DELAY = 1000;
 const MAX_RECONNECT_DELAY = 30000;
 const RECONNECT_MULTIPLIER = 2;
+const LOCAL_TIMEOUT = 2500;
 
 export class WebSocketClient {
   private ws: WebSocket | null = null;
-  private url: string = "";
+  private urls: string[] = [];
+  private urlIndex: number = 0;
   private reconnectDelay: number = INITIAL_RECONNECT_DELAY;
   private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
+  private localTimer: ReturnType<typeof setTimeout> | null = null;
   private shouldReconnect: boolean = false;
+  private connected: boolean = false;
   private callbacks: WebSocketClientOptions = {};
 
   setCallbacks(callbacks: WebSocketClientOptions) {
     this.callbacks = callbacks;
   }
 
-  connect(url: string) {
-    this.url = url;
+  connect(urls: string[]) {
+    this.urls = urls.filter(Boolean);
+    if (this.urls.length === 0) return;
     this.shouldReconnect = true;
     this.reconnectDelay = INITIAL_RECONNECT_DELAY;
-    this.openConnection();
+    this.urlIndex = 0;
+    this.connected = false;
+    this.tryUrl();
   }
 
-  private openConnection() {
+  private cleanup() {
+    if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
+    if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
     if (this.ws) {
-      this.ws.close();
+      const old = this.ws;
       this.ws = null;
+      old.onopen = null;
+      old.onclose = null;
+      old.onerror = null;
+      old.onmessage = null;
+      try { old.close(); } catch { /* ignore */ }
+    }
+  }
+
+  private tryUrl() {
+    this.cleanup();
+
+    const url = this.urls[this.urlIndex];
+    if (!url) return;
+
+    const ws = new WebSocket(url);
+    this.ws = ws;
+
+    // If trying local (index 0) and we have a remote fallback,
+    // give local 2.5s before switching to remote
+    if (this.urlIndex === 0 && this.urls.length > 1) {
+      this.localTimer = setTimeout(() => {
+        this.localTimer = null;
+        if (this.connected) return; // already connected, ignore
+        // Local didn't connect in time — try remote
+        this.urlIndex = 1;
+        this.tryUrl();
+      }, LOCAL_TIMEOUT);
     }
 
-    try {
-      this.ws = new WebSocket(this.url);
+    ws.onopen = () => {
+      if (ws !== this.ws) return; // stale
+      this.connected = true;
+      if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
+      this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+      this.callbacks.onOpen?.();
+    };
 
-      this.ws.onopen = () => {
-        this.reconnectDelay = INITIAL_RECONNECT_DELAY;
-        this.callbacks.onOpen?.();
-      };
+    ws.onmessage = (event) => {
+      if (ws !== this.ws) return; // stale
+      try {
+        const data = JSON.parse(event.data) as WebSocketMessage;
+        this.callbacks.onMessage?.(data);
+      } catch {
+        this.callbacks.onMessage?.({ type: "text", content: String(event.data) });
+      }
+    };
 
-      this.ws.onmessage = (event) => {
-        try {
-          const data = JSON.parse(event.data) as WebSocketMessage;
-          this.callbacks.onMessage?.(data);
-        } catch {
-          // Non-JSON message — treat as plain text
-          const data: WebSocketMessage = {
-            type: "text",
-            content: String(event.data),
-          };
-          this.callbacks.onMessage?.(data);
-        }
-      };
-
-      this.ws.onclose = () => {
-        this.callbacks.onClose?.();
-        if (this.shouldReconnect) {
-          this.scheduleReconnect();
-        }
-      };
-
-      this.ws.onerror = (error) => {
-        this.callbacks.onError?.(error);
-      };
-    } catch {
+    ws.onclose = () => {
+      if (ws !== this.ws) return; // stale
+      this.connected = false;
+      this.callbacks.onClose?.();
       if (this.shouldReconnect) {
         this.scheduleReconnect();
       }
-    }
+    };
+
+    ws.onerror = () => {
+      if (ws !== this.ws) return; // stale
+      // Don't do anything here — onclose always fires after onerror
+      // and handles reconnect. Just swallow the error event.
+    };
   }
 
   private scheduleReconnect() {
-    if (this.reconnectTimer) {
-      clearTimeout(this.reconnectTimer);
-    }
+    if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
     this.reconnectTimer = setTimeout(() => {
+      this.reconnectTimer = null;
       this.reconnectDelay = Math.min(
         this.reconnectDelay * RECONNECT_MULTIPLIER,
         MAX_RECONNECT_DELAY
       );
-      this.openConnection();
+      // Alternate between URLs on each reconnect attempt
+      if (this.urls.length > 1) {
+        this.urlIndex = this.urlIndex === 0 ? 1 : 0;
+      }
+      this.tryUrl();
     }, this.reconnectDelay);
   }
 
   disconnect() {
     this.shouldReconnect = false;
-    if (this.reconnectTimer) {
-      clearTimeout(this.reconnectTimer);
-      this.reconnectTimer = null;
-    }
-    if (this.ws) {
-      this.ws.close();
-      this.ws = null;
-    }
+    this.connected = false;
+    this.cleanup();
   }
 
-  send(message: WebSocketMessage) {
+  send(message: WsOutgoing) {
     if (this.ws && this.ws.readyState === WebSocket.OPEN) {
       this.ws.send(JSON.stringify(message));
       return true;
@@ -117,7 +146,11 @@
   }
 
   get isConnected(): boolean {
-    return this.ws?.readyState === WebSocket.OPEN;
+    return this.connected;
+  }
+
+  get currentUrl(): string {
+    return this.urls[this.urlIndex] ?? "";
   }
 }
 

--
Gitblit v1.3.1