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:
Haze
2026-02-05 23:09:17 +08:00
Unverified
parent 9442e5f77a
commit b8ab0208d0
71 changed files with 14086 additions and 3 deletions

186
electron/gateway/client.ts Normal file
View 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
View 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);
}
}

View 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
View 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 };

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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');
}
}