zeroclaw/web/src/lib/ws.ts
Argenis addba118e9
feat: add macOS desktop menu bar app (Tauri) (#4454)
* feat(tauri): add desktop app scaffolding with Tauri commands

Add Tauri v2 workspace member under apps/tauri with gateway client,
shared state, and Tauri command handlers for status, health, channels,
pairing, and agent messaging. The desktop app communicates with the
ZeroClaw gateway over HTTP and is buildable independently via
`cargo check -p zeroclaw-desktop`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(tauri): add system tray icon, menu, and events

Add tray module with menu construction (Show Dashboard, Status, Open
Gateway, Quit) and event handlers. Wire tray setup into the Tauri
builder's setup hook. Add icons/ placeholder directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tauri): add mobile entry, platform capabilities, icons, and tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(tauri): add auto-pairing, gradient icons, and WebView integration

Desktop app now auto-pairs with the gateway on startup using localhost
admin endpoints, injects the token into the WebView localStorage, and
loads the dashboard from the gateway URL. Adds blue gradient Z tray
icons (idle/working/error/disconnected states), proper .icns app icon,
health polling with tray updates, and Tauri-aware API/WS/SSE paths in
the web frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(tauri): add dock icon, keep-alive for tray mode, and install preflight

- Set macOS dock icon programmatically via NSApplication API so it shows
  the blue gradient Z even in dev builds without a .app bundle
- Add RunEvent::ExitRequested handler to keep app alive as a menu bar app
  when all windows are closed
- Add desktop app preflight checks to install.sh (Rust, Xcode CLT,
  cargo-tauri, Node.js) with automatic build on macOS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 15:37:03 +03:00

153 lines
4.7 KiB
TypeScript

import type { WsMessage } from '../types/api';
import { getToken } from './auth';
import { apiOrigin, basePath } from './basePath';
import { isTauri } from './tauri';
import { generateUUID } from './uuid';
export type WsMessageHandler = (msg: WsMessage) => void;
export type WsOpenHandler = () => void;
export type WsCloseHandler = (ev: CloseEvent) => void;
export type WsErrorHandler = (ev: Event) => void;
export interface WebSocketClientOptions {
/** Base URL override. Defaults to current host with ws(s) protocol. */
baseUrl?: string;
/** Delay in ms before attempting reconnect. Doubles on each failure up to maxReconnectDelay. */
reconnectDelay?: number;
/** Maximum reconnect delay in ms. */
maxReconnectDelay?: number;
/** Set to false to disable auto-reconnect. Default true. */
autoReconnect?: boolean;
}
const DEFAULT_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 30000;
const SESSION_STORAGE_KEY = 'zeroclaw_session_id';
/** Return a stable session ID, persisted in sessionStorage across reconnects. */
function getOrCreateSessionId(): string {
let id = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!id) {
id = generateUUID();
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
}
return id;
}
export class WebSocketClient {
private ws: WebSocket | null = null;
private currentDelay: number;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private intentionallyClosed = false;
public onMessage: WsMessageHandler | null = null;
public onOpen: WsOpenHandler | null = null;
public onClose: WsCloseHandler | null = null;
public onError: WsErrorHandler | null = null;
private readonly baseUrl: string;
private readonly reconnectDelay: number;
private readonly maxReconnectDelay: number;
private readonly autoReconnect: boolean;
constructor(options: WebSocketClientOptions = {}) {
let defaultBase: string;
if (isTauri() && apiOrigin) {
// In Tauri, derive ws URL from the gateway origin.
defaultBase = apiOrigin.replace(/^http/, 'ws');
} else {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
defaultBase = `${protocol}//${window.location.host}`;
}
this.baseUrl = options.baseUrl ?? defaultBase;
this.reconnectDelay = options.reconnectDelay ?? DEFAULT_RECONNECT_DELAY;
this.maxReconnectDelay = options.maxReconnectDelay ?? MAX_RECONNECT_DELAY;
this.autoReconnect = options.autoReconnect ?? true;
this.currentDelay = this.reconnectDelay;
}
/** Open the WebSocket connection. */
connect(): void {
this.intentionallyClosed = false;
this.clearReconnectTimer();
const token = getToken();
const sessionId = getOrCreateSessionId();
const params = new URLSearchParams();
if (token) params.set('token', token);
params.set('session_id', sessionId);
const url = `${this.baseUrl}${basePath}/ws/chat?${params.toString()}`;
const protocols: string[] = ['zeroclaw.v1'];
if (token) protocols.push(`bearer.${token}`);
this.ws = new WebSocket(url, protocols);
this.ws.onopen = () => {
this.currentDelay = this.reconnectDelay;
this.onOpen?.();
};
this.ws.onmessage = (ev: MessageEvent) => {
try {
const msg = JSON.parse(ev.data) as WsMessage;
this.onMessage?.(msg);
} catch {
// Ignore non-JSON frames
}
};
this.ws.onclose = (ev: CloseEvent) => {
this.onClose?.(ev);
this.scheduleReconnect();
};
this.ws.onerror = (ev: Event) => {
this.onError?.(ev);
};
}
/** Send a chat message to the agent. */
sendMessage(content: string): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket is not connected');
}
this.ws.send(JSON.stringify({ type: 'message', content }));
}
/** Close the connection without auto-reconnecting. */
disconnect(): void {
this.intentionallyClosed = true;
this.clearReconnectTimer();
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
/** Returns true if the socket is open. */
get connected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
// ---------------------------------------------------------------------------
// Reconnection logic
// ---------------------------------------------------------------------------
private scheduleReconnect(): void {
if (this.intentionallyClosed || !this.autoReconnect) return;
this.reconnectTimer = setTimeout(() => {
this.currentDelay = Math.min(this.currentDelay * 2, this.maxReconnectDelay);
this.connect();
}, this.currentDelay);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}