From ef1b8dfa4e660b4e4c6948297531b2b83dd0c544 Mon Sep 17 00:00:00 2001 From: Butterfly Dev Date: Tue, 7 Apr 2026 04:01:35 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20remote=20display=20now=20uses=20per-inst?= =?UTF-8?q?ance=20WebSocket=20(was=20shared=20singleton=20=E2=80=94=20brok?= =?UTF-8?q?e=20multi-session)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../remote-display.component.ts | 238 ++++++++++++------ 1 file changed, 167 insertions(+), 71 deletions(-) diff --git a/desktop/src/app/components/remote-display/remote-display.component.ts b/desktop/src/app/components/remote-display/remote-display.component.ts index 61cede4..bf752e3 100644 --- a/desktop/src/app/components/remote-display/remote-display.component.ts +++ b/desktop/src/app/components/remote-display/remote-display.component.ts @@ -1,18 +1,48 @@ import { Component, Input, - Output, - EventEmitter, OnInit, OnDestroy, ViewChild, ElementRef, signal, - computed, AfterViewInit, } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { WebSocketService } from '../../services/websocket.service'; +import { Subject, filter, takeUntil } from 'rxjs'; + +// ── Types matching the Rust WsMessage enum ───────────────────────────────── + +interface FrameBroadcast { + msg_type: 'frame_broadcast'; + data: string; + content_type: string; +} + +interface AudioBroadcast { + msg_type: 'audio_broadcast'; + data: string; + content_type: string; +} + +interface SessionUpdate { + msg_type: 'session_update'; + session_id: string; + status: 'waiting' | 'active' | 'disconnected'; + resolution: string | null; +} + +interface WsError { + msg_type: 'error'; + message: string; +} + +interface WsAck { + msg_type: 'ack'; + message: string; +} + +type ServerMessage = FrameBroadcast | AudioBroadcast | SessionUpdate | WsError | WsAck; @Component({ selector: 'app-remote-display', @@ -24,8 +54,6 @@ import { WebSocketService } from '../../services/websocket.service'; export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy { @Input({ required: true }) sessionId!: string; - @Output() statusChange = new EventEmitter<'connecting' | 'connected' | 'disconnected' | 'error'>(); - @ViewChild('canvas') canvasRef!: ElementRef; @ViewChild('displayContainer') containerRef!: ElementRef; @@ -49,73 +77,145 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy /** Session status from server. */ readonly sessionStatus = signal('waiting'); - constructor(private ws: WebSocketService) {} + /** Per-instance WebSocket connection (not the shared singleton). */ + private socket: WebSocket | null = null; + private messagesSubject = new Subject(); + private destroy$ = new Subject(); + private heartbeatTimer: ReturnType | null = null; + private resizeObserver: ResizeObserver | null = null; ngOnInit(): void { - // Connect to the session via WebSocket. - this.ws.connect(this.sessionId); - - // Subscribe to display frames. - this.ws.displayFrames.subscribe({ - next: (frame) => { - if (!this.canvas || !this.ctx) return; - this.img.onload = () => { - this.ctx!.drawImage(this.img, 0, 0, this.canvas!.width, this.canvas!.height); - this.hasFrame = true; - }; - this.img.src = 'data:' + frame.content_type + ';base64,' + frame.data; - // FPS counter. - this.frameCount++; - const now = performance.now(); - if (now - this.lastFpsTime >= 1000) { - this.fps.set(this.frameCount); - this.frameCount = 0; - this.lastFpsTime = now; - } - }, - error: () => { - this.status.set('error'); - this.statusChange.emit('error'); - }, - }); - - // Subscribe to session state updates. - this.ws.sessionUpdates.subscribe((update) => { - if (update.session_id === this.sessionId) { - this.sessionStatus.set(update.status); - this.resolution.set(update.resolution); - if (update.status === 'active') { - this.status.set('connected'); - this.statusChange.emit('connected'); - } else if (update.status === 'disconnected') { - this.status.set('disconnected'); - this.statusChange.emit('disconnected'); - } - } - }); - - // Connection state. - this.ws.connected.subscribe((connected) => { - if (connected) { - this.status.set('connecting'); - } else { - this.status.set('disconnected'); - this.statusChange.emit('disconnected'); - } - }); + this.connectWebSocket(); + this.setupSubscriptions(); } ngAfterViewInit(): void { this.canvas = this.canvasRef.nativeElement; this.ctx = this.canvas.getContext('2d'); this.resizeCanvas(); - // Start render loop. - this.renderLoop(); + + // Watch for container size changes (window resize, maximize, etc.). + this.resizeObserver = new ResizeObserver(() => this.resizeCanvas()); + this.resizeObserver.observe(this.containerRef.nativeElement); } ngOnDestroy(): void { - this.ws.disconnect(); + this.destroy$.next(); + this.destroy$.complete(); + this.disconnectWebSocket(); if (this.animFrameId) cancelAnimationFrame(this.animFrameId); + this.resizeObserver?.disconnect(); + } + + // ── WebSocket connection (per-instance) ───────────────────────────────── + + private connectWebSocket(): void { + const wsUrl = + window.location.origin.replace(/^http/, 'ws') + + `/ws/${this.sessionId}?client_type=viewer`; + + this.socket = new WebSocket(wsUrl); + + this.socket.onopen = () => { + console.log('[RemoteDisplay] connected to session', this.sessionId); + this.status.set('connecting'); + this.heartbeatTimer = setInterval(() => this.sendRaw({ msg_type: 'heartbeat' }), 15000); + }; + + this.socket.onmessage = (event) => { + try { + const msg: ServerMessage = JSON.parse(event.data); + this.messagesSubject.next(msg); + } catch (e) { + console.warn('[RemoteDisplay] failed to parse message', event.data, e); + } + }; + + this.socket.onclose = (event) => { + console.log('[RemoteDisplay] disconnected', event.code, event.reason); + this.status.set('disconnected'); + this.stopHeartbeat(); + }; + + this.socket.onerror = () => { + this.status.set('error'); + }; + } + + private disconnectWebSocket(): void { + this.stopHeartbeat(); + if (this.socket) { + this.socket.close(); + this.socket = null; + } + } + + private setupSubscriptions(): void { + // Display frames + this.messagesSubject.pipe( + filter((m): m is FrameBroadcast => m.msg_type === 'frame_broadcast'), + takeUntil(this.destroy$), + ).subscribe((frame) => { + if (!this.canvas || !this.ctx) return; + this.img.onload = () => { + this.ctx!.drawImage(this.img, 0, 0, this.canvas!.width, this.canvas!.height); + this.hasFrame = true; + }; + this.img.src = 'data:' + frame.content_type + ';base64,' + frame.data; + this.frameCount++; + const now = performance.now(); + if (now - this.lastFpsTime >= 1000) { + this.fps.set(this.frameCount); + this.frameCount = 0; + this.lastFpsTime = now; + } + }); + + // Session state updates + this.messagesSubject.pipe( + filter((m): m is SessionUpdate => m.msg_type === 'session_update'), + takeUntil(this.destroy$), + ).subscribe((update) => { + if (update.session_id === this.sessionId) { + this.sessionStatus.set(update.status); + this.resolution.set(update.resolution); + if (update.status === 'active') { + this.status.set('connected'); + } else if (update.status === 'disconnected') { + this.status.set('disconnected'); + } + } + }); + + // Error messages + this.messagesSubject.pipe( + filter((m): m is WsError => m.msg_type === 'error'), + takeUntil(this.destroy$), + ).subscribe((err) => { + console.warn('[RemoteDisplay] server error:', err.message); + }); + } + + private sendRaw(msg: Record): void { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(msg)); + } + } + + private sendHudCommand(command: string, params: Record = {}): void { + this.sendRaw({ + msg_type: 'hud_command', + session_id: this.sessionId, + command, + params, + }); + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } } private resizeCanvas(): void { @@ -125,15 +225,11 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy this.canvas.height = rect.height; } - private renderLoop = (): void => { - this.animFrameId = requestAnimationFrame(this.renderLoop); - }; - // ── Mouse events (HUD forwarding) ─────────────────────────────────────── onMouseDown(event: MouseEvent): void { const pos = this.getCanvasPos(event); - this.ws.sendHudCommand('mouse_down', { + this.sendHudCommand('mouse_down', { button: event.button, x: pos.x, y: pos.y, @@ -143,7 +239,7 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy onMouseUp(event: MouseEvent): void { const pos = this.getCanvasPos(event); - this.ws.sendHudCommand('mouse_up', { + this.sendHudCommand('mouse_up', { button: event.button, x: pos.x, y: pos.y, @@ -152,11 +248,11 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy onMouseMove(event: MouseEvent): void { const pos = this.getCanvasPos(event); - this.ws.sendHudCommand('mouse_move', { x: pos.x, y: pos.y }); + this.sendHudCommand('mouse_move', { x: pos.x, y: pos.y }); } onWheel(event: WheelEvent): void { - this.ws.sendHudCommand('scroll', { + this.sendHudCommand('scroll', { deltaX: event.deltaX, deltaY: event.deltaY, }); @@ -167,7 +263,7 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy } onKeyDown(event: KeyboardEvent): void { - this.ws.sendHudCommand('key_down', { + this.sendHudCommand('key_down', { key: event.key, code: event.code, ctrl: event.ctrlKey, @@ -182,7 +278,7 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy } onKeyUp(event: KeyboardEvent): void { - this.ws.sendHudCommand('key_up', { + this.sendHudCommand('key_up', { key: event.key, code: event.code, ctrl: event.ctrlKey,