diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/platform-specific/tauri/tauri-websocket-subject.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/platform-specific/tauri/tauri-websocket-subject.ts index 712c0ec4..97965a79 100644 --- a/desktop/angular/projects/safing/portmaster-api/src/lib/platform-specific/tauri/tauri-websocket-subject.ts +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/platform-specific/tauri/tauri-websocket-subject.ts @@ -1,6 +1,7 @@ -import WebSocket, { Message } from '@tauri-apps/plugin-websocket'; +import WebSocket, { ConnectionConfig, Message } from '@tauri-apps/plugin-websocket'; import { Subject, Observable } from 'rxjs'; import { WebSocketSubject, WebSocketSubjectConfig } from 'rxjs/webSocket'; +import { NgZone } from '@angular/core'; const LOG_PREFIX = '[tauri_ws]'; @@ -9,6 +10,7 @@ const LOG_PREFIX = '[tauri_ws]'; * * @template T - The type of messages sent and received through the WebSocket. * @param {WebSocketSubjectConfig} opts - Configuration options for the WebSocket connection. + * @param {NgZone} ngZone - Angular's NgZone to ensure change detection runs properly. * @returns {WebSocketSubject} - An RxJS WebSocketSubject-compatible object for interacting with the WebSocket. * @throws {Error} If the `serializer` or `deserializer` functions are not provided. * @@ -17,9 +19,9 @@ const LOG_PREFIX = '[tauri_ws]'; * url: 'ws://example.com', * serializer: JSON.stringify, * deserializer: JSON.parse, - * }); + * }, ngZone); */ -export function createTauriWsConnection(opts: WebSocketSubjectConfig): WebSocketSubject { +export function createTauriWsConnection(opts: WebSocketSubjectConfig, ngZone: NgZone): WebSocketSubject { if (!opts.serializer) throw new Error(`${LOG_PREFIX} Messages Serializer not provided!`); if (!opts.deserializer) throw new Error(`${LOG_PREFIX} Messages Deserializer not provided!`); @@ -37,8 +39,12 @@ export function createTauriWsConnection(opts: WebSocketSubjectConfig): Web if (!descriptionToLog) return; if (!error) error = new Error(descriptionToLog); console.error(`${LOG_PREFIX} ${descriptionToLog}:`, error); - // This completes the observable and prevents further messages from being processed. - messageSubject.error(error); + + // Run inside NgZone to ensure Angular detects this change + ngZone.run(() => { + // This completes the observable and prevents further messages from being processed. + messageSubject.error(error); + }); } ////////////////////////////////////////////////////////////// @@ -67,24 +73,36 @@ export function createTauriWsConnection(opts: WebSocketSubjectConfig): Web console.error(`${LOG_PREFIX} Error serializing message:`, error); return; } - - try { - wsConnection.send(serializedMessage).catch((err: Error) => { - notifySubjectError('Error sending text message', err); - }); - } catch (error) { - notifySubjectError('Error sending message', error); - } + + // Run outside NgZone for better performance during send operations + ngZone.runOutsideAngular(() => { + try { + wsConnection!.send(serializedMessage).catch((err: Error) => { + notifySubjectError('Error sending text message', err); + }); + } catch (error) { + notifySubjectError('Error sending message', error); + } + }); }, complete: () => { if (wsConnection) { console.log(`${LOG_PREFIX} Closing connection`); - opts.closingObserver?.next(undefined); - wsConnection.disconnect().catch((err: Error) => console.error(`${LOG_PREFIX} Error closing connection:`, err)); - wsConnection = null; + + // Run inside NgZone to ensure Angular detects this change + ngZone.run(() => { + if (opts.closingObserver?.next) { + opts.closingObserver.next(undefined); + } + + wsConnection!.disconnect().catch((err: Error) => console.error(`${LOG_PREFIX} Error closing connection:`, err)); + wsConnection = null; + messageSubject.complete(); + }); + } else { + messageSubject.complete(); } - messageSubject.complete(); }, // RxJS Observable methods required for compatibility @@ -97,98 +115,105 @@ export function createTauriWsConnection(opts: WebSocketSubjectConfig): Web ////////////////////////////////////////////////////////////// // Connect to WebSocket ////////////////////////////////////////////////////////////// - - const connectOptions: Record = {}; - console.log(`${LOG_PREFIX} Connecting to WebSocket:`, opts.url, connectOptions); - WebSocket.connect(opts.url, connectOptions) - .then((ws) => { - wsConnection = ws; - console.log(`${LOG_PREFIX} Connection established`); - - // Create a mock Event for the openObserver - if (opts.openObserver) { - const mockEvent = new Event('open') as Event; - opts.openObserver.next(mockEvent); - } - - // Send any pending messages - while (pendingMessages.length > 0) { - const message = pendingMessages.shift(); - if (message) webSocketSubject.next(message); - } - - try { - // Add a single listener for ALL message types according to Tauri WebSocket API - // The addListener method takes a single callback function that receives messages - ws.addListener((message: Message) => { - try { - // Handle different message types from Tauri - switch (message.type) { - case 'Text': - const textData = message.data as string; - try { - const deserializedMessage = deserializer({ data: textData } as any) ; - messageSubject.next(deserializedMessage); - } catch (err) { - notifySubjectError('Error deserializing text message', err); - } - break; - - case 'Binary': - const binaryData = message.data as number[]; - try { - const uint8Array = new Uint8Array(binaryData); - const buffer = uint8Array.buffer; - const deserializedMessage = deserializer({ data: buffer } as any) ; - messageSubject.next(deserializedMessage); - } catch (err) { - notifySubjectError('Error deserializing binary message', err); - } - break; - - case 'Close': - // Handle close message - const closeData = message.data as { code: number; reason: string } | null; - console.log(`${LOG_PREFIX} Connection closed by server`, closeData); - - if (opts.closeObserver) { - const closeEvent = { - code: closeData?.code || 1000, - reason: closeData?.reason || '', - wasClean: true, - type: 'close', - target: null - } as unknown as CloseEvent; - - opts.closeObserver.next(closeEvent); - } - - messageSubject.complete(); - wsConnection = null; - break; - - case 'Ping': - console.log(`${LOG_PREFIX} Received ping`); - break; - - case 'Pong': - console.log(`${LOG_PREFIX} Received pong`); - break; - } - } catch (error) { - console.error(`${LOG_PREFIX} Error processing message:`, error); - // Don't error the subject on message processing errors to keep connection alive + console.log(`${LOG_PREFIX} Connecting to WebSocket:`, opts.url); + + // Connect outside of Angular zone for better performance + ngZone.runOutsideAngular(() => { + WebSocket.connect(opts.url) + .then((ws) => { + wsConnection = ws; + console.log(`${LOG_PREFIX} Connection established`); + + // Run inside NgZone to ensure Angular detects this connection event + ngZone.run(() => { + // Create a mock Event for the openObserver + if (opts.openObserver) { + const mockEvent = new Event('open') as Event; + opts.openObserver.next(mockEvent); + } + + // Send any pending messages + while (pendingMessages.length > 0) { + const message = pendingMessages.shift(); + if (message) webSocketSubject.next(message); } }); - - console.log(`${LOG_PREFIX} Listener added successfully`); - } catch (error) { - notifySubjectError('Error adding message listener', error); - } - }) - .catch((error: Error) => { - notifySubjectError('Connection failed', error); + try { + // Add a single listener for ALL message types according to Tauri WebSocket API + ws.addListener((message: Message) => { + // Process message inside ngZone to trigger change detection + ngZone.run(() => { + try { + // Handle different message types from Tauri + switch (message.type) { + case 'Text': + const textData = message.data as string; + try { + const deserializedMessage = deserializer({ data: textData } as any); + messageSubject.next(deserializedMessage); + } catch (err) { + notifySubjectError('Error deserializing text message', err); + } + break; + + case 'Binary': + const binaryData = message.data as number[]; + try { + const uint8Array = new Uint8Array(binaryData); + const buffer = uint8Array.buffer; + const deserializedMessage = deserializer({ data: buffer } as any); + messageSubject.next(deserializedMessage); + } catch (err) { + notifySubjectError('Error deserializing binary message', err); + } + break; + + case 'Close': + // Handle close message + const closeData = message.data as { code: number; reason: string } | null; + console.log(`${LOG_PREFIX} Connection closed by server`, closeData); + + if (opts.closeObserver) { + const closeEvent = { + code: closeData?.code || 1000, + reason: closeData?.reason || '', + wasClean: true, + type: 'close', + target: null + } as unknown as CloseEvent; + + opts.closeObserver.next(closeEvent); + } + + messageSubject.complete(); + wsConnection = null; + break; + + case 'Ping': + console.log(`${LOG_PREFIX} Received ping`); + break; + + case 'Pong': + console.log(`${LOG_PREFIX} Received pong`); + break; + } + } catch (error) { + console.error(`${LOG_PREFIX} Error processing message:`, error); + // Don't error the subject on message processing errors to keep connection alive + } + }); + }); + + console.log(`${LOG_PREFIX} Listener added successfully`); + + } catch (error) { + notifySubjectError('Error adding message listener', error); + } + }) + .catch((error: Error) => { + notifySubjectError('Connection failed', error); + }); }); // Cast to WebSocketSubject diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/websocket.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/websocket.service.ts index 4724687e..eaf7ce95 100644 --- a/desktop/angular/projects/safing/portmaster-api/src/lib/websocket.service.ts +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/websocket.service.ts @@ -1,11 +1,11 @@ -import { Injectable } from '@angular/core'; +import { Injectable, NgZone } from '@angular/core'; import { webSocket, WebSocketSubject, WebSocketSubjectConfig } from 'rxjs/webSocket'; import { createTauriWsConnection } from './platform-specific/tauri/tauri-websocket-subject'; import { IsTauriEnvironment } from './platform-specific/utils'; @Injectable() export class WebsocketService { - constructor() { } + constructor(private ngZone: NgZone) { } /** * createConnection creates a new websocket connection using opts. @@ -13,9 +13,9 @@ export class WebsocketService { * @param opts Options for the websocket connection. */ createConnection(opts: WebSocketSubjectConfig): WebSocketSubject { - if (IsTauriEnvironment()) { + if (IsTauriEnvironment()) { console.log('[portmaster-api] Running under Tauri - Using Tauri WebSocket'); - return createTauriWsConnection(opts); + return createTauriWsConnection(opts, this.ngZone); } console.log('[portmaster-api] Running in browser - Using RxJS WebSocket');