feat(core): initialize project skeleton with Electron + React + TypeScript
Set up the complete project foundation for ClawX, a graphical AI assistant: - Electron main process with IPC handlers, menu, tray, and gateway management - React renderer with routing, layout components, and page scaffolding - Zustand state management for gateway, settings, channels, skills, chat, and cron - shadcn/ui components with Tailwind CSS and CSS variable theming - Build tooling with Vite, electron-builder, and TypeScript configuration - Testing setup with Vitest and Playwright - Development configurations (ESLint, Prettier, gitignore, env example)
This commit is contained in:
186
electron/gateway/client.ts
Normal file
186
electron/gateway/client.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Gateway WebSocket Client
|
||||
* Provides a typed interface for Gateway RPC calls
|
||||
*/
|
||||
import { GatewayManager } from './manager';
|
||||
|
||||
/**
|
||||
* Channel types supported by OpenClaw
|
||||
*/
|
||||
export type ChannelType = 'whatsapp' | 'telegram' | 'discord' | 'slack' | 'wechat';
|
||||
|
||||
/**
|
||||
* Channel status
|
||||
*/
|
||||
export interface Channel {
|
||||
id: string;
|
||||
type: ChannelType;
|
||||
name: string;
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'error';
|
||||
lastActivity?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill definition
|
||||
*/
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
configurable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat message
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
channel?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call in a message
|
||||
*/
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway Client
|
||||
* Typed wrapper around GatewayManager for making RPC calls
|
||||
*/
|
||||
export class GatewayClient {
|
||||
constructor(private manager: GatewayManager) {}
|
||||
|
||||
// ==================== Channel Methods ====================
|
||||
|
||||
/**
|
||||
* List all channels
|
||||
*/
|
||||
async listChannels(): Promise<Channel[]> {
|
||||
return this.manager.rpc<Channel[]>('channels.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel by ID
|
||||
*/
|
||||
async getChannel(channelId: string): Promise<Channel> {
|
||||
return this.manager.rpc<Channel>('channels.get', { channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a channel
|
||||
*/
|
||||
async connectChannel(channelId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('channels.connect', { channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a channel
|
||||
*/
|
||||
async disconnectChannel(channelId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('channels.disconnect', { channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get QR code for channel connection (e.g., WhatsApp)
|
||||
*/
|
||||
async getChannelQRCode(channelType: ChannelType): Promise<string> {
|
||||
return this.manager.rpc<string>('channels.getQRCode', { channelType });
|
||||
}
|
||||
|
||||
// ==================== Skill Methods ====================
|
||||
|
||||
/**
|
||||
* List all skills
|
||||
*/
|
||||
async listSkills(): Promise<Skill[]> {
|
||||
return this.manager.rpc<Skill[]>('skills.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a skill
|
||||
*/
|
||||
async enableSkill(skillId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.enable', { skillId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a skill
|
||||
*/
|
||||
async disableSkill(skillId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.disable', { skillId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill configuration
|
||||
*/
|
||||
async getSkillConfig(skillId: string): Promise<Record<string, unknown>> {
|
||||
return this.manager.rpc<Record<string, unknown>>('skills.getConfig', { skillId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update skill configuration
|
||||
*/
|
||||
async updateSkillConfig(skillId: string, config: Record<string, unknown>): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.updateConfig', { skillId, config });
|
||||
}
|
||||
|
||||
// ==================== Chat Methods ====================
|
||||
|
||||
/**
|
||||
* Send a chat message
|
||||
*/
|
||||
async sendMessage(content: string, channelId?: string): Promise<ChatMessage> {
|
||||
return this.manager.rpc<ChatMessage>('chat.send', { content, channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat history
|
||||
*/
|
||||
async getChatHistory(limit = 50, offset = 0): Promise<ChatMessage[]> {
|
||||
return this.manager.rpc<ChatMessage[]>('chat.history', { limit, offset });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear chat history
|
||||
*/
|
||||
async clearChatHistory(): Promise<void> {
|
||||
return this.manager.rpc<void>('chat.clear');
|
||||
}
|
||||
|
||||
// ==================== System Methods ====================
|
||||
|
||||
/**
|
||||
* Get Gateway health status
|
||||
*/
|
||||
async getHealth(): Promise<{ status: string; uptime: number }> {
|
||||
return this.manager.rpc<{ status: string; uptime: number }>('system.health');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gateway configuration
|
||||
*/
|
||||
async getConfig(): Promise<Record<string, unknown>> {
|
||||
return this.manager.rpc<Record<string, unknown>>('system.config');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Gateway configuration
|
||||
*/
|
||||
async updateConfig(config: Record<string, unknown>): Promise<void> {
|
||||
return this.manager.rpc<void>('system.updateConfig', config);
|
||||
}
|
||||
}
|
||||
370
electron/gateway/manager.ts
Normal file
370
electron/gateway/manager.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Gateway Process Manager
|
||||
* Manages the OpenClaw Gateway process lifecycle
|
||||
*/
|
||||
import { spawn, ChildProcess, exec } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import WebSocket from 'ws';
|
||||
import { promisify } from 'util';
|
||||
import { PORTS } from '../utils/config';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Gateway connection status
|
||||
*/
|
||||
export interface GatewayStatus {
|
||||
state: 'stopped' | 'starting' | 'running' | 'error';
|
||||
port: number;
|
||||
pid?: number;
|
||||
uptime?: number;
|
||||
error?: string;
|
||||
connectedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway Manager Events
|
||||
*/
|
||||
export interface GatewayManagerEvents {
|
||||
status: (status: GatewayStatus) => void;
|
||||
message: (message: unknown) => void;
|
||||
exit: (code: number | null) => void;
|
||||
error: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway Manager
|
||||
* Handles starting, stopping, and communicating with the OpenClaw Gateway
|
||||
*/
|
||||
export class GatewayManager extends EventEmitter {
|
||||
private process: ChildProcess | null = null;
|
||||
private ws: WebSocket | null = null;
|
||||
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private pingInterval: NodeJS.Timeout | null = null;
|
||||
private pendingRequests: Map<string, {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}> = new Map();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current Gateway status
|
||||
*/
|
||||
getStatus(): GatewayStatus {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Gateway process
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.status.state === 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setStatus({ state: 'starting' });
|
||||
|
||||
try {
|
||||
// Check if Gateway is already running
|
||||
const existing = await this.findExistingGateway();
|
||||
if (existing) {
|
||||
console.log('Found existing Gateway on port', existing.port);
|
||||
await this.connect(existing.port);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start new Gateway process
|
||||
await this.startProcess();
|
||||
|
||||
// Wait for Gateway to be ready
|
||||
await this.waitForReady();
|
||||
|
||||
// Connect WebSocket
|
||||
await this.connect(this.status.port);
|
||||
|
||||
} catch (error) {
|
||||
this.setStatus({ state: 'error', error: String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Gateway process
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
// Clear timers
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
// Kill process
|
||||
if (this.process) {
|
||||
this.process.kill();
|
||||
this.process = null;
|
||||
}
|
||||
|
||||
// Reject all pending requests
|
||||
for (const [id, request] of this.pendingRequests) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error('Gateway stopped'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
|
||||
this.setStatus({ state: 'stopped' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an RPC call to the Gateway
|
||||
*/
|
||||
async rpc<T>(method: string, params?: unknown): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error('Gateway not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
// Set timeout for request
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`RPC timeout: ${method}`));
|
||||
}, 30000);
|
||||
|
||||
// Store pending request
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
|
||||
// Send request
|
||||
const request = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(request));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find existing Gateway process
|
||||
*/
|
||||
private async findExistingGateway(): Promise<{ port: number } | null> {
|
||||
try {
|
||||
// Try to connect to default port
|
||||
const port = PORTS.OPENCLAW_GATEWAY;
|
||||
const response = await fetch(`http://localhost:${port}/health`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { port };
|
||||
}
|
||||
} catch {
|
||||
// Gateway not running
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Gateway process
|
||||
*/
|
||||
private async startProcess(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Find openclaw command
|
||||
const command = 'openclaw';
|
||||
const args = ['gateway', 'run', '--port', String(this.status.port)];
|
||||
|
||||
this.process = spawn(command, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
shell: true,
|
||||
});
|
||||
|
||||
this.process.on('error', (error) => {
|
||||
console.error('Gateway process error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
console.log('Gateway process exited with code:', code);
|
||||
this.emit('exit', code);
|
||||
|
||||
if (this.status.state === 'running') {
|
||||
this.setStatus({ state: 'stopped' });
|
||||
// Attempt to reconnect
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// Log stdout
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
console.log('Gateway:', data.toString());
|
||||
});
|
||||
|
||||
// Log stderr
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
console.error('Gateway error:', data.toString());
|
||||
});
|
||||
|
||||
// Store PID
|
||||
if (this.process.pid) {
|
||||
this.setStatus({ pid: this.process.pid });
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Gateway to be ready
|
||||
*/
|
||||
private async waitForReady(retries = 30, interval = 1000): Promise<void> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${this.status.port}/health`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Gateway not ready yet
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
throw new Error('Gateway failed to start');
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect WebSocket to Gateway
|
||||
*/
|
||||
private async connect(port: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsUrl = `ws://localhost:${port}/ws`;
|
||||
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log('WebSocket connected to Gateway');
|
||||
this.setStatus({
|
||||
state: 'running',
|
||||
port,
|
||||
connectedAt: Date.now(),
|
||||
});
|
||||
this.startPing();
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this.handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
console.log('WebSocket disconnected');
|
||||
if (this.status.state === 'running') {
|
||||
this.setStatus({ state: 'stopped' });
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming WebSocket message
|
||||
*/
|
||||
private handleMessage(message: { id?: string; result?: unknown; error?: unknown }): void {
|
||||
// Check if this is a response to a pending request
|
||||
if (message.id && this.pendingRequests.has(message.id)) {
|
||||
const request = this.pendingRequests.get(message.id)!;
|
||||
clearTimeout(request.timeout);
|
||||
this.pendingRequests.delete(message.id);
|
||||
|
||||
if (message.error) {
|
||||
request.reject(new Error(String(message.error)));
|
||||
} else {
|
||||
request.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit message for other handlers
|
||||
this.emit('message', message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start ping interval to keep connection alive
|
||||
*/
|
||||
private startPing(): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
}
|
||||
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.ping();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection attempt
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(async () => {
|
||||
this.reconnectTimer = null;
|
||||
try {
|
||||
await this.start();
|
||||
} catch (error) {
|
||||
console.error('Reconnection failed:', error);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status and emit event
|
||||
*/
|
||||
private setStatus(update: Partial<GatewayStatus>): void {
|
||||
this.status = { ...this.status, ...update };
|
||||
this.emit('status', this.status);
|
||||
}
|
||||
}
|
||||
200
electron/gateway/protocol.ts
Normal file
200
electron/gateway/protocol.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Gateway Protocol Definitions
|
||||
* JSON-RPC 2.0 protocol types for Gateway communication
|
||||
*/
|
||||
|
||||
/**
|
||||
* JSON-RPC 2.0 Request
|
||||
*/
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC 2.0 Response
|
||||
*/
|
||||
export interface JsonRpcResponse<T = unknown> {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number;
|
||||
result?: T;
|
||||
error?: JsonRpcError;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC 2.0 Error
|
||||
*/
|
||||
export interface JsonRpcError {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC 2.0 Notification (no id, no response expected)
|
||||
*/
|
||||
export interface JsonRpcNotification {
|
||||
jsonrpc: '2.0';
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard JSON-RPC 2.0 Error Codes
|
||||
*/
|
||||
export enum JsonRpcErrorCode {
|
||||
/** Invalid JSON was received */
|
||||
PARSE_ERROR = -32700,
|
||||
/** The JSON sent is not a valid Request object */
|
||||
INVALID_REQUEST = -32600,
|
||||
/** The method does not exist or is not available */
|
||||
METHOD_NOT_FOUND = -32601,
|
||||
/** Invalid method parameter(s) */
|
||||
INVALID_PARAMS = -32602,
|
||||
/** Internal JSON-RPC error */
|
||||
INTERNAL_ERROR = -32603,
|
||||
/** Server error range: -32000 to -32099 */
|
||||
SERVER_ERROR = -32000,
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway-specific error codes
|
||||
*/
|
||||
export enum GatewayErrorCode {
|
||||
/** Gateway not connected */
|
||||
NOT_CONNECTED = -32001,
|
||||
/** Authentication required */
|
||||
AUTH_REQUIRED = -32002,
|
||||
/** Permission denied */
|
||||
PERMISSION_DENIED = -32003,
|
||||
/** Resource not found */
|
||||
NOT_FOUND = -32004,
|
||||
/** Operation timeout */
|
||||
TIMEOUT = -32005,
|
||||
/** Rate limit exceeded */
|
||||
RATE_LIMITED = -32006,
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway event types
|
||||
*/
|
||||
export enum GatewayEventType {
|
||||
/** Gateway status changed */
|
||||
STATUS_CHANGED = 'gateway.status_changed',
|
||||
/** Channel status changed */
|
||||
CHANNEL_STATUS_CHANGED = 'channel.status_changed',
|
||||
/** New chat message received */
|
||||
MESSAGE_RECEIVED = 'chat.message_received',
|
||||
/** Message sent */
|
||||
MESSAGE_SENT = 'chat.message_sent',
|
||||
/** Tool call started */
|
||||
TOOL_CALL_STARTED = 'tool.call_started',
|
||||
/** Tool call completed */
|
||||
TOOL_CALL_COMPLETED = 'tool.call_completed',
|
||||
/** Error occurred */
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway event payload
|
||||
*/
|
||||
export interface GatewayEvent<T = unknown> {
|
||||
type: GatewayEventType;
|
||||
timestamp: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSON-RPC request
|
||||
*/
|
||||
export function createRequest(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
id?: string | number
|
||||
): JsonRpcRequest {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: id ?? crypto.randomUUID(),
|
||||
method,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSON-RPC success response
|
||||
*/
|
||||
export function createSuccessResponse<T>(
|
||||
id: string | number,
|
||||
result: T
|
||||
): JsonRpcResponse<T> {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSON-RPC error response
|
||||
*/
|
||||
export function createErrorResponse(
|
||||
id: string | number,
|
||||
code: number,
|
||||
message: string,
|
||||
data?: unknown
|
||||
): JsonRpcResponse {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is a JSON-RPC request
|
||||
*/
|
||||
export function isRequest(message: unknown): message is JsonRpcRequest {
|
||||
return (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'jsonrpc' in message &&
|
||||
message.jsonrpc === '2.0' &&
|
||||
'method' in message &&
|
||||
typeof message.method === 'string' &&
|
||||
'id' in message
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is a JSON-RPC response
|
||||
*/
|
||||
export function isResponse(message: unknown): message is JsonRpcResponse {
|
||||
return (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'jsonrpc' in message &&
|
||||
message.jsonrpc === '2.0' &&
|
||||
'id' in message &&
|
||||
('result' in message || 'error' in message)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is a JSON-RPC notification
|
||||
*/
|
||||
export function isNotification(message: unknown): message is JsonRpcNotification {
|
||||
return (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'jsonrpc' in message &&
|
||||
message.jsonrpc === '2.0' &&
|
||||
'method' in message &&
|
||||
!('id' in message)
|
||||
);
|
||||
}
|
||||
118
electron/main/index.ts
Normal file
118
electron/main/index.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Electron Main Process Entry
|
||||
* Manages window creation, system tray, and IPC handlers
|
||||
*/
|
||||
import { app, BrowserWindow, ipcMain, shell } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
import { registerIpcHandlers } from './ipc-handlers';
|
||||
import { createTray } from './tray';
|
||||
import { createMenu } from './menu';
|
||||
import { PORTS } from '../utils/config';
|
||||
|
||||
// Disable GPU acceleration for better compatibility
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
// Global references
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
const gatewayManager = new GatewayManager();
|
||||
|
||||
/**
|
||||
* Create the main application window
|
||||
*/
|
||||
function createWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
},
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||
trafficLightPosition: { x: 16, y: 16 },
|
||||
show: false,
|
||||
});
|
||||
|
||||
// Show window when ready to prevent visual flash
|
||||
win.once('ready-to-show', () => {
|
||||
win.show();
|
||||
});
|
||||
|
||||
// Handle external links
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// Load the app
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(process.env.VITE_DEV_SERVER_URL);
|
||||
// Open DevTools in development
|
||||
win.webContents.openDevTools();
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../../dist/index.html'));
|
||||
}
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the application
|
||||
*/
|
||||
async function initialize(): Promise<void> {
|
||||
// Set application menu
|
||||
createMenu();
|
||||
|
||||
// Create the main window
|
||||
mainWindow = createWindow();
|
||||
|
||||
// Create system tray
|
||||
createTray(mainWindow);
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers(gatewayManager, mainWindow);
|
||||
|
||||
// Handle window close
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// Start Gateway automatically (optional based on settings)
|
||||
try {
|
||||
await gatewayManager.start();
|
||||
console.log('Gateway started successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to start Gateway:', error);
|
||||
// Notify renderer about the error
|
||||
mainWindow?.webContents.send('gateway:error', String(error));
|
||||
}
|
||||
}
|
||||
|
||||
// Application lifecycle
|
||||
app.whenReady().then(initialize);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// On macOS, keep the app running in the menu bar
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
// On macOS, re-create window when dock icon is clicked
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
// Clean up Gateway process
|
||||
await gatewayManager.stop();
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
export { mainWindow, gatewayManager };
|
||||
172
electron/main/ipc-handlers.ts
Normal file
172
electron/main/ipc-handlers.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* IPC Handlers
|
||||
* Registers all IPC handlers for main-renderer communication
|
||||
*/
|
||||
import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
|
||||
/**
|
||||
* Register all IPC handlers
|
||||
*/
|
||||
export function registerIpcHandlers(
|
||||
gatewayManager: GatewayManager,
|
||||
mainWindow: BrowserWindow
|
||||
): void {
|
||||
// Gateway handlers
|
||||
registerGatewayHandlers(gatewayManager, mainWindow);
|
||||
|
||||
// Shell handlers
|
||||
registerShellHandlers();
|
||||
|
||||
// Dialog handlers
|
||||
registerDialogHandlers();
|
||||
|
||||
// App handlers
|
||||
registerAppHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway-related IPC handlers
|
||||
*/
|
||||
function registerGatewayHandlers(
|
||||
gatewayManager: GatewayManager,
|
||||
mainWindow: BrowserWindow
|
||||
): void {
|
||||
// Get Gateway status
|
||||
ipcMain.handle('gateway:status', () => {
|
||||
return gatewayManager.getStatus();
|
||||
});
|
||||
|
||||
// Start Gateway
|
||||
ipcMain.handle('gateway:start', async () => {
|
||||
try {
|
||||
await gatewayManager.start();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Stop Gateway
|
||||
ipcMain.handle('gateway:stop', async () => {
|
||||
try {
|
||||
await gatewayManager.stop();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Restart Gateway
|
||||
ipcMain.handle('gateway:restart', async () => {
|
||||
try {
|
||||
await gatewayManager.stop();
|
||||
await gatewayManager.start();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Gateway RPC call
|
||||
ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown) => {
|
||||
try {
|
||||
const result = await gatewayManager.rpc(method, params);
|
||||
return { success: true, result };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Forward Gateway status events to renderer
|
||||
gatewayManager.on('status', (status) => {
|
||||
mainWindow.webContents.send('gateway:status-changed', status);
|
||||
});
|
||||
|
||||
gatewayManager.on('message', (message) => {
|
||||
mainWindow.webContents.send('gateway:message', message);
|
||||
});
|
||||
|
||||
gatewayManager.on('exit', (code) => {
|
||||
mainWindow.webContents.send('gateway:exit', code);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-related IPC handlers
|
||||
*/
|
||||
function registerShellHandlers(): void {
|
||||
// Open external URL
|
||||
ipcMain.handle('shell:openExternal', async (_, url: string) => {
|
||||
await shell.openExternal(url);
|
||||
});
|
||||
|
||||
// Open path in file explorer
|
||||
ipcMain.handle('shell:showItemInFolder', async (_, path: string) => {
|
||||
shell.showItemInFolder(path);
|
||||
});
|
||||
|
||||
// Open path
|
||||
ipcMain.handle('shell:openPath', async (_, path: string) => {
|
||||
return await shell.openPath(path);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog-related IPC handlers
|
||||
*/
|
||||
function registerDialogHandlers(): void {
|
||||
// Show open dialog
|
||||
ipcMain.handle('dialog:open', async (_, options: Electron.OpenDialogOptions) => {
|
||||
const result = await dialog.showOpenDialog(options);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Show save dialog
|
||||
ipcMain.handle('dialog:save', async (_, options: Electron.SaveDialogOptions) => {
|
||||
const result = await dialog.showSaveDialog(options);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Show message box
|
||||
ipcMain.handle('dialog:message', async (_, options: Electron.MessageBoxOptions) => {
|
||||
const result = await dialog.showMessageBox(options);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* App-related IPC handlers
|
||||
*/
|
||||
function registerAppHandlers(): void {
|
||||
// Get app version
|
||||
ipcMain.handle('app:version', () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
// Get app name
|
||||
ipcMain.handle('app:name', () => {
|
||||
return app.getName();
|
||||
});
|
||||
|
||||
// Get app path
|
||||
ipcMain.handle('app:getPath', (_, name: Parameters<typeof app.getPath>[0]) => {
|
||||
return app.getPath(name);
|
||||
});
|
||||
|
||||
// Get platform
|
||||
ipcMain.handle('app:platform', () => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
// Quit app
|
||||
ipcMain.handle('app:quit', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
// Relaunch app
|
||||
ipcMain.handle('app:relaunch', () => {
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
201
electron/main/menu.ts
Normal file
201
electron/main/menu.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Application Menu Configuration
|
||||
* Creates the native application menu for macOS/Windows/Linux
|
||||
*/
|
||||
import { Menu, app, shell, BrowserWindow } from 'electron';
|
||||
|
||||
/**
|
||||
* Create application menu
|
||||
*/
|
||||
export function createMenu(): void {
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
// App menu (macOS only)
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: 'about' as const },
|
||||
{ type: 'separator' as const },
|
||||
{
|
||||
label: 'Preferences...',
|
||||
accelerator: 'Cmd+,',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/settings');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'services' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'hide' as const },
|
||||
{ role: 'hideOthers' as const },
|
||||
{ role: 'unhide' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'quit' as const },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
// File menu
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'New Chat',
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/chat');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
isMac ? { role: 'close' } : { role: 'quit' },
|
||||
],
|
||||
},
|
||||
|
||||
// Edit menu
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
...(isMac
|
||||
? [
|
||||
{ role: 'pasteAndMatchStyle' as const },
|
||||
{ role: 'delete' as const },
|
||||
{ role: 'selectAll' as const },
|
||||
]
|
||||
: [
|
||||
{ role: 'delete' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'selectAll' as const },
|
||||
]),
|
||||
],
|
||||
},
|
||||
|
||||
// View menu
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'reload' },
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
],
|
||||
},
|
||||
|
||||
// Navigate menu
|
||||
{
|
||||
label: 'Navigate',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
accelerator: 'CmdOrCtrl+1',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Chat',
|
||||
accelerator: 'CmdOrCtrl+2',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/chat');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Channels',
|
||||
accelerator: 'CmdOrCtrl+3',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/channels');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Skills',
|
||||
accelerator: 'CmdOrCtrl+4',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/skills');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Cron Tasks',
|
||||
accelerator: 'CmdOrCtrl+5',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/cron');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
accelerator: isMac ? 'Cmd+,' : 'Ctrl+,',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/settings');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Window menu
|
||||
{
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
...(isMac
|
||||
? [
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'front' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'window' as const },
|
||||
]
|
||||
: [{ role: 'close' as const }]),
|
||||
],
|
||||
},
|
||||
|
||||
// Help menu
|
||||
{
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Documentation',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://docs.clawx.app');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Report Issue',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://github.com/clawx/clawx/issues');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'OpenClaw Documentation',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://docs.openclaw.ai');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
}
|
||||
145
electron/main/tray.ts
Normal file
145
electron/main/tray.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* System Tray Management
|
||||
* Creates and manages the system tray icon and menu
|
||||
*/
|
||||
import { Tray, Menu, BrowserWindow, app, nativeImage } from 'electron';
|
||||
import { join } from 'path';
|
||||
|
||||
let tray: Tray | null = null;
|
||||
|
||||
/**
|
||||
* Create system tray icon and menu
|
||||
*/
|
||||
export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
// Create tray icon
|
||||
const iconPath = join(__dirname, '../../resources/icons/tray-icon.png');
|
||||
|
||||
// Create a template image for macOS (adds @2x support automatically)
|
||||
let icon = nativeImage.createFromPath(iconPath);
|
||||
|
||||
// If icon doesn't exist, create a simple placeholder
|
||||
if (icon.isEmpty()) {
|
||||
// Create a simple 16x16 icon as placeholder
|
||||
icon = nativeImage.createEmpty();
|
||||
}
|
||||
|
||||
// On macOS, set as template image for proper dark/light mode support
|
||||
if (process.platform === 'darwin') {
|
||||
icon.setTemplateImage(true);
|
||||
}
|
||||
|
||||
tray = new Tray(icon);
|
||||
|
||||
// Set tooltip
|
||||
tray.setToolTip('ClawX - AI Assistant');
|
||||
|
||||
// Create context menu
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show ClawX',
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Gateway Status',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: ' Running',
|
||||
type: 'checkbox',
|
||||
checked: true,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Quick Actions',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Open Dashboard',
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Chat',
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/chat');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Settings',
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/settings');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Check for Updates...',
|
||||
click: () => {
|
||||
mainWindow.webContents.send('update:check');
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Quit ClawX',
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
// Click to show window (Windows/Linux)
|
||||
tray.on('click', () => {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Double-click to show window (Windows)
|
||||
tray.on('double-click', () => {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
return tray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tray tooltip with Gateway status
|
||||
*/
|
||||
export function updateTrayStatus(status: string): void {
|
||||
if (tray) {
|
||||
tray.setToolTip(`ClawX - ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy tray icon
|
||||
*/
|
||||
export function destroyTray(): void {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
}
|
||||
84
electron/main/window.ts
Normal file
84
electron/main/window.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Window Management Utilities
|
||||
* Handles window state persistence and multi-window management
|
||||
*/
|
||||
import { BrowserWindow, screen } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
|
||||
interface WindowState {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isMaximized: boolean;
|
||||
}
|
||||
|
||||
const store = new Store<{ windowState: WindowState }>({
|
||||
name: 'window-state',
|
||||
defaults: {
|
||||
windowState: {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
isMaximized: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get saved window state with bounds validation
|
||||
*/
|
||||
export function getWindowState(): WindowState {
|
||||
const state = store.get('windowState');
|
||||
|
||||
// Validate that the window is visible on a screen
|
||||
if (state.x !== undefined && state.y !== undefined) {
|
||||
const displays = screen.getAllDisplays();
|
||||
const isVisible = displays.some((display) => {
|
||||
const { x, y, width, height } = display.bounds;
|
||||
return (
|
||||
state.x! >= x &&
|
||||
state.x! < x + width &&
|
||||
state.y! >= y &&
|
||||
state.y! < y + height
|
||||
);
|
||||
});
|
||||
|
||||
if (!isVisible) {
|
||||
// Reset position if not visible
|
||||
delete state.x;
|
||||
delete state.y;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save window state
|
||||
*/
|
||||
export function saveWindowState(win: BrowserWindow): void {
|
||||
const isMaximized = win.isMaximized();
|
||||
|
||||
if (!isMaximized) {
|
||||
const bounds = win.getBounds();
|
||||
store.set('windowState', {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized,
|
||||
});
|
||||
} else {
|
||||
store.set('windowState.isMaximized', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track window state changes
|
||||
*/
|
||||
export function trackWindowState(win: BrowserWindow): void {
|
||||
// Save state on window events
|
||||
['resize', 'move', 'close'].forEach((event) => {
|
||||
win.on(event as any, () => saveWindowState(win));
|
||||
});
|
||||
}
|
||||
159
electron/preload/index.ts
Normal file
159
electron/preload/index.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Preload Script
|
||||
* Exposes safe APIs to the renderer process via contextBridge
|
||||
*/
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
/**
|
||||
* IPC renderer methods exposed to the renderer process
|
||||
*/
|
||||
const electronAPI = {
|
||||
/**
|
||||
* IPC invoke (request-response pattern)
|
||||
*/
|
||||
ipcRenderer: {
|
||||
invoke: (channel: string, ...args: unknown[]) => {
|
||||
const validChannels = [
|
||||
// Gateway
|
||||
'gateway:status',
|
||||
'gateway:start',
|
||||
'gateway:stop',
|
||||
'gateway:restart',
|
||||
'gateway:rpc',
|
||||
// Shell
|
||||
'shell:openExternal',
|
||||
'shell:showItemInFolder',
|
||||
'shell:openPath',
|
||||
// Dialog
|
||||
'dialog:open',
|
||||
'dialog:save',
|
||||
'dialog:message',
|
||||
// App
|
||||
'app:version',
|
||||
'app:name',
|
||||
'app:getPath',
|
||||
'app:platform',
|
||||
'app:quit',
|
||||
'app:relaunch',
|
||||
// Settings
|
||||
'settings:get',
|
||||
'settings:set',
|
||||
'settings:getAll',
|
||||
'settings:reset',
|
||||
// Update
|
||||
'update:check',
|
||||
'update:download',
|
||||
'update:install',
|
||||
'update:getStatus',
|
||||
// Env
|
||||
'env:getConfig',
|
||||
'env:setApiKey',
|
||||
'env:deleteApiKey',
|
||||
// Provider
|
||||
'provider:validateKey',
|
||||
// Cron
|
||||
'cron:list',
|
||||
'cron:create',
|
||||
'cron:update',
|
||||
'cron:delete',
|
||||
'cron:toggle',
|
||||
'cron:trigger',
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
}
|
||||
|
||||
throw new Error(`Invalid IPC channel: ${channel}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Listen for events from main process
|
||||
*/
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||
const validChannels = [
|
||||
'gateway:status-changed',
|
||||
'gateway:message',
|
||||
'gateway:exit',
|
||||
'gateway:error',
|
||||
'navigate',
|
||||
'update:available',
|
||||
'update:downloaded',
|
||||
'update:status',
|
||||
'cron:updated',
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
// Wrap the callback to strip the event
|
||||
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => {
|
||||
callback(...args);
|
||||
};
|
||||
ipcRenderer.on(channel, subscription);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, subscription);
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Invalid IPC channel: ${channel}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Listen for a single event from main process
|
||||
*/
|
||||
once: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||
const validChannels = [
|
||||
'gateway:status-changed',
|
||||
'gateway:message',
|
||||
'gateway:exit',
|
||||
'gateway:error',
|
||||
'navigate',
|
||||
'update:available',
|
||||
'update:downloaded',
|
||||
'update:status',
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid IPC channel: ${channel}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove all listeners for a channel
|
||||
*/
|
||||
off: (channel: string, callback?: (...args: unknown[]) => void) => {
|
||||
if (callback) {
|
||||
ipcRenderer.removeListener(channel, callback as any);
|
||||
} else {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Open external URL in default browser
|
||||
*/
|
||||
openExternal: (url: string) => {
|
||||
return ipcRenderer.invoke('shell:openExternal', url);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current platform
|
||||
*/
|
||||
platform: process.platform,
|
||||
|
||||
/**
|
||||
* Check if running in development
|
||||
*/
|
||||
isDev: process.env.NODE_ENV === 'development' || !!process.env.VITE_DEV_SERVER_URL,
|
||||
};
|
||||
|
||||
// Expose the API to the renderer process
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
|
||||
// Type declarations for the renderer process
|
||||
export type ElectronAPI = typeof electronAPI;
|
||||
84
electron/utils/config.ts
Normal file
84
electron/utils/config.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Application Configuration
|
||||
* Centralized configuration constants and helpers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Port configuration
|
||||
*/
|
||||
export const PORTS = {
|
||||
/** ClawX GUI development server port */
|
||||
CLAWX_DEV: 5173,
|
||||
|
||||
/** ClawX GUI production port (for reference) */
|
||||
CLAWX_GUI: 23333,
|
||||
|
||||
/** OpenClaw Gateway port */
|
||||
OPENCLAW_GATEWAY: 18789,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get port from environment or default
|
||||
*/
|
||||
export function getPort(key: keyof typeof PORTS): number {
|
||||
const envKey = `CLAWX_PORT_${key}`;
|
||||
const envValue = process.env[envKey];
|
||||
return envValue ? parseInt(envValue, 10) : PORTS[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Application paths
|
||||
*/
|
||||
export const APP_PATHS = {
|
||||
/** OpenClaw configuration directory */
|
||||
OPENCLAW_CONFIG: '~/.openclaw',
|
||||
|
||||
/** ClawX configuration directory */
|
||||
CLAWX_CONFIG: '~/.clawx',
|
||||
|
||||
/** Log files directory */
|
||||
LOGS: '~/.clawx/logs',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Update channels
|
||||
*/
|
||||
export const UPDATE_CHANNELS = ['stable', 'beta', 'dev'] as const;
|
||||
export type UpdateChannel = (typeof UPDATE_CHANNELS)[number];
|
||||
|
||||
/**
|
||||
* Default update configuration
|
||||
*/
|
||||
export const UPDATE_CONFIG = {
|
||||
/** Check interval in milliseconds (6 hours) */
|
||||
CHECK_INTERVAL: 6 * 60 * 60 * 1000,
|
||||
|
||||
/** Default update channel */
|
||||
DEFAULT_CHANNEL: 'stable' as UpdateChannel,
|
||||
|
||||
/** Auto download updates */
|
||||
AUTO_DOWNLOAD: false,
|
||||
|
||||
/** Show update notifications */
|
||||
SHOW_NOTIFICATION: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Gateway configuration
|
||||
*/
|
||||
export const GATEWAY_CONFIG = {
|
||||
/** WebSocket reconnection delay (ms) */
|
||||
RECONNECT_DELAY: 5000,
|
||||
|
||||
/** RPC call timeout (ms) */
|
||||
RPC_TIMEOUT: 30000,
|
||||
|
||||
/** Health check interval (ms) */
|
||||
HEALTH_CHECK_INTERVAL: 30000,
|
||||
|
||||
/** Maximum startup retries */
|
||||
MAX_STARTUP_RETRIES: 30,
|
||||
|
||||
/** Startup retry interval (ms) */
|
||||
STARTUP_RETRY_INTERVAL: 1000,
|
||||
};
|
||||
133
electron/utils/logger.ts
Normal file
133
electron/utils/logger.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Logger Utility
|
||||
* Centralized logging with levels and file output
|
||||
*/
|
||||
import { app } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync, appendFileSync } from 'fs';
|
||||
|
||||
/**
|
||||
* Log levels
|
||||
*/
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Current log level (can be changed at runtime)
|
||||
*/
|
||||
let currentLevel = LogLevel.INFO;
|
||||
|
||||
/**
|
||||
* Log file path
|
||||
*/
|
||||
let logFilePath: string | null = null;
|
||||
|
||||
/**
|
||||
* Initialize logger
|
||||
*/
|
||||
export function initLogger(): void {
|
||||
try {
|
||||
const logDir = join(app.getPath('userData'), 'logs');
|
||||
|
||||
if (!existsSync(logDir)) {
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
logFilePath = join(logDir, `clawx-${timestamp}.log`);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize logger:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set log level
|
||||
*/
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
currentLevel = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format log message
|
||||
*/
|
||||
function formatMessage(level: string, message: string, ...args: unknown[]): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedArgs = args.length > 0 ? ' ' + args.map(arg =>
|
||||
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
|
||||
).join(' ') : '';
|
||||
|
||||
return `[${timestamp}] [${level}] ${message}${formattedArgs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to log file
|
||||
*/
|
||||
function writeToFile(formatted: string): void {
|
||||
if (logFilePath) {
|
||||
try {
|
||||
appendFileSync(logFilePath, formatted + '\n');
|
||||
} catch (error) {
|
||||
// Silently fail if we can't write to file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug message
|
||||
*/
|
||||
export function debug(message: string, ...args: unknown[]): void {
|
||||
if (currentLevel <= LogLevel.DEBUG) {
|
||||
const formatted = formatMessage('DEBUG', message, ...args);
|
||||
console.debug(formatted);
|
||||
writeToFile(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message
|
||||
*/
|
||||
export function info(message: string, ...args: unknown[]): void {
|
||||
if (currentLevel <= LogLevel.INFO) {
|
||||
const formatted = formatMessage('INFO', message, ...args);
|
||||
console.info(formatted);
|
||||
writeToFile(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message
|
||||
*/
|
||||
export function warn(message: string, ...args: unknown[]): void {
|
||||
if (currentLevel <= LogLevel.WARN) {
|
||||
const formatted = formatMessage('WARN', message, ...args);
|
||||
console.warn(formatted);
|
||||
writeToFile(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error message
|
||||
*/
|
||||
export function error(message: string, ...args: unknown[]): void {
|
||||
if (currentLevel <= LogLevel.ERROR) {
|
||||
const formatted = formatMessage('ERROR', message, ...args);
|
||||
console.error(formatted);
|
||||
writeToFile(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger namespace export
|
||||
*/
|
||||
export const logger = {
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
error,
|
||||
setLevel: setLogLevel,
|
||||
init: initLogger,
|
||||
};
|
||||
72
electron/utils/paths.ts
Normal file
72
electron/utils/paths.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Path Utilities
|
||||
* Cross-platform path resolution helpers
|
||||
*/
|
||||
import { app } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
export function expandPath(path: string): string {
|
||||
if (path.startsWith('~')) {
|
||||
return path.replace('~', homedir());
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OpenClaw config directory
|
||||
*/
|
||||
export function getOpenClawConfigDir(): string {
|
||||
return join(homedir(), '.openclaw');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ClawX config directory
|
||||
*/
|
||||
export function getClawXConfigDir(): string {
|
||||
return join(homedir(), '.clawx');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ClawX logs directory
|
||||
*/
|
||||
export function getLogsDir(): string {
|
||||
return join(app.getPath('userData'), 'logs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ClawX data directory
|
||||
*/
|
||||
export function getDataDir(): string {
|
||||
return app.getPath('userData');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure directory exists
|
||||
*/
|
||||
export function ensureDir(dir: string): void {
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resources directory (for bundled assets)
|
||||
*/
|
||||
export function getResourcesDir(): string {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, 'resources');
|
||||
}
|
||||
return join(__dirname, '../../resources');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preload script path
|
||||
*/
|
||||
export function getPreloadPath(): string {
|
||||
return join(__dirname, '../preload/index.js');
|
||||
}
|
||||
123
electron/utils/store.ts
Normal file
123
electron/utils/store.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Persistent Storage
|
||||
* Electron-store wrapper for application settings
|
||||
*/
|
||||
import Store from 'electron-store';
|
||||
|
||||
/**
|
||||
* Application settings schema
|
||||
*/
|
||||
export interface AppSettings {
|
||||
// General
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
language: string;
|
||||
startMinimized: boolean;
|
||||
launchAtStartup: boolean;
|
||||
|
||||
// Gateway
|
||||
gatewayAutoStart: boolean;
|
||||
gatewayPort: number;
|
||||
|
||||
// Update
|
||||
updateChannel: 'stable' | 'beta' | 'dev';
|
||||
autoCheckUpdate: boolean;
|
||||
autoDownloadUpdate: boolean;
|
||||
skippedVersions: string[];
|
||||
|
||||
// UI State
|
||||
sidebarCollapsed: boolean;
|
||||
devModeUnlocked: boolean;
|
||||
|
||||
// Presets
|
||||
selectedBundles: string[];
|
||||
enabledSkills: string[];
|
||||
disabledSkills: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default settings
|
||||
*/
|
||||
const defaults: AppSettings = {
|
||||
// General
|
||||
theme: 'system',
|
||||
language: 'en',
|
||||
startMinimized: false,
|
||||
launchAtStartup: false,
|
||||
|
||||
// Gateway
|
||||
gatewayAutoStart: true,
|
||||
gatewayPort: 18789,
|
||||
|
||||
// Update
|
||||
updateChannel: 'stable',
|
||||
autoCheckUpdate: true,
|
||||
autoDownloadUpdate: false,
|
||||
skippedVersions: [],
|
||||
|
||||
// UI State
|
||||
sidebarCollapsed: false,
|
||||
devModeUnlocked: false,
|
||||
|
||||
// Presets
|
||||
selectedBundles: ['productivity', 'developer'],
|
||||
enabledSkills: [],
|
||||
disabledSkills: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Create settings store
|
||||
*/
|
||||
export const settingsStore = new Store<AppSettings>({
|
||||
name: 'settings',
|
||||
defaults,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a setting value
|
||||
*/
|
||||
export function getSetting<K extends keyof AppSettings>(key: K): AppSettings[K] {
|
||||
return settingsStore.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a setting value
|
||||
*/
|
||||
export function setSetting<K extends keyof AppSettings>(
|
||||
key: K,
|
||||
value: AppSettings[K]
|
||||
): void {
|
||||
settingsStore.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all settings
|
||||
*/
|
||||
export function getAllSettings(): AppSettings {
|
||||
return settingsStore.store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset settings to defaults
|
||||
*/
|
||||
export function resetSettings(): void {
|
||||
settingsStore.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export settings to JSON
|
||||
*/
|
||||
export function exportSettings(): string {
|
||||
return JSON.stringify(settingsStore.store, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings from JSON
|
||||
*/
|
||||
export function importSettings(json: string): void {
|
||||
try {
|
||||
const settings = JSON.parse(json);
|
||||
settingsStore.set(settings);
|
||||
} catch (error) {
|
||||
throw new Error('Invalid settings JSON');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user