desktop: services/websocket.service.ts — WebSocket client matching Rust WsMessage protocol
This commit is contained in:
parent
2b0537395d
commit
710560d300
175
desktop/src/app/services/websocket.service.ts
Normal file
175
desktop/src/app/services/websocket.service.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, Subject, timer } from 'rxjs';
|
||||
import { filter, takeUntil, tap } from 'rxjs/operators';
|
||||
|
||||
// ── Types matching the Rust WsMessage enum ─────────────────────────────────
|
||||
|
||||
export interface FrameBroadcast {
|
||||
msg_type: 'frame_broadcast';
|
||||
data: string;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export interface AudioBroadcast {
|
||||
msg_type: 'audio_broadcast';
|
||||
data: string;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export interface SessionUpdate {
|
||||
msg_type: 'session_update';
|
||||
session_id: string;
|
||||
status: 'waiting' | 'active' | 'disconnected';
|
||||
resolution: string | null;
|
||||
}
|
||||
|
||||
export interface WsError {
|
||||
msg_type: 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WsAck {
|
||||
msg_type: 'ack';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ServerMessage = FrameBroadcast | AudioBroadcast | SessionUpdate | WsError | WsAck;
|
||||
|
||||
// Messages the viewer sends to the server.
|
||||
|
||||
export interface HudCommandMsg {
|
||||
msg_type: 'hud_command';
|
||||
session_id: string;
|
||||
command: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ResizeMsg {
|
||||
msg_type: 'resize';
|
||||
session_id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type ClientMessage = HudCommandMsg | ResizeMsg;
|
||||
|
||||
// ── Service ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class WebSocketService implements OnDestroy {
|
||||
private socket: WebSocket | null = null;
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
/** All incoming server messages. */
|
||||
private messages$ = new Subject<ServerMessage>();
|
||||
/** Connection state. */
|
||||
private connected$ = new BehaviorSubject<boolean>(false);
|
||||
/** Current session id we're subscribed to. */
|
||||
private sessionId: string | null = null;
|
||||
/** Heartbeat interval. */
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Observable of all server messages, typed. */
|
||||
readonly messages: Observable<ServerMessage> = this.messages$.asObservable();
|
||||
|
||||
/** Whether we are currently connected to the server. */
|
||||
readonly connected: Observable<boolean> = this.connected$.asObservable();
|
||||
|
||||
/** Stream of display frames only. */
|
||||
readonly displayFrames: Observable<FrameBroadcast> = this.messages$.pipe(
|
||||
filter((m): m is FrameBroadcast => m.msg_type === 'frame_broadcast'),
|
||||
);
|
||||
|
||||
/** Stream of audio chunks only. */
|
||||
readonly audioChunks: Observable<AudioBroadcast> = this.messages$.pipe(
|
||||
filter((m): m is AudioBroadcast => m.msg_type === 'audio_broadcast'),
|
||||
);
|
||||
|
||||
/** Stream of session state updates. */
|
||||
readonly sessionUpdates: Observable<SessionUpdate> = this.messages$.pipe(
|
||||
filter((m): m is SessionUpdate => m.msg_type === 'session_update'),
|
||||
);
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a session as a viewer.
|
||||
* @param sessionId The session to watch.
|
||||
* @param serverUrl Base server URL (default: current origin).
|
||||
*/
|
||||
connect(sessionId: string, serverUrl: string = window.location.origin): void {
|
||||
this.disconnect();
|
||||
this.sessionId = sessionId;
|
||||
|
||||
const wsUrl = serverUrl.replace(/^http/, 'ws') + `/ws/${sessionId}?client_type=viewer`;
|
||||
this.socket = new WebSocket(wsUrl);
|
||||
|
||||
this.socket.onopen = () => {
|
||||
console.log('[WS] connected to session', sessionId);
|
||||
this.connected$.next(true);
|
||||
// Start heartbeat every 15 seconds.
|
||||
this.heartbeatTimer = setInterval(() => this.sendRaw({ msg_type: 'heartbeat' }), 15000);
|
||||
};
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
try {
|
||||
const msg: ServerMessage = JSON.parse(event.data);
|
||||
this.messages$.next(msg);
|
||||
} catch (e) {
|
||||
console.warn('[WS] failed to parse message', event.data, e);
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onclose = (event) => {
|
||||
console.log('[WS] disconnected', event.code, event.reason);
|
||||
this.connected$.next(false);
|
||||
this.stopHeartbeat();
|
||||
};
|
||||
|
||||
this.socket.onerror = (event) => {
|
||||
console.error('[WS] error', event);
|
||||
this.connected$.next(false);
|
||||
};
|
||||
}
|
||||
|
||||
/** Disconnect from the current session. */
|
||||
disconnect(): void {
|
||||
this.stopHeartbeat();
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
this.connected$.next(false);
|
||||
this.sessionId = null;
|
||||
}
|
||||
|
||||
/** Send a HUD command to the agent (mouse, keyboard, etc.). */
|
||||
sendHudCommand(command: string, params: Record<string, unknown> = {}): void {
|
||||
if (!this.sessionId) return;
|
||||
this.sendRaw({ msg_type: 'hud_command', session_id: this.sessionId, command, params });
|
||||
}
|
||||
|
||||
/** Send a resize request to the agent. */
|
||||
sendResize(width: number, height: number): void {
|
||||
if (!this.sessionId) return;
|
||||
this.sendRaw({ msg_type: 'resize', session_id: this.sessionId, width, height });
|
||||
}
|
||||
|
||||
/** Raw JSON send. */
|
||||
private sendRaw(msg: Record<string, unknown>): void {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user