* 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>
153 lines
4.7 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|