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

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