1290 lines
39 KiB
TypeScript
1290 lines
39 KiB
TypeScript
/**
|
|
* IPC Handlers
|
|
* Registers all IPC handlers for main-renderer communication
|
|
*/
|
|
import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron';
|
|
import { existsSync } from 'node:fs';
|
|
import { GatewayManager } from '../gateway/manager';
|
|
import { ClawHubService, ClawHubSearchParams, ClawHubInstallParams, ClawHubUninstallParams } from '../gateway/clawhub';
|
|
import {
|
|
storeApiKey,
|
|
getApiKey,
|
|
deleteApiKey,
|
|
hasApiKey,
|
|
saveProvider,
|
|
getProvider,
|
|
deleteProvider,
|
|
setDefaultProvider,
|
|
getDefaultProvider,
|
|
getAllProvidersWithKeyInfo,
|
|
type ProviderConfig,
|
|
} from '../utils/secure-storage';
|
|
import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir } from '../utils/paths';
|
|
import { getOpenClawCliCommand, installOpenClawCliMac } from '../utils/openclaw-cli';
|
|
import { getSetting } from '../utils/store';
|
|
import {
|
|
saveProviderKeyToOpenClaw,
|
|
removeProviderKeyFromOpenClaw,
|
|
setOpenClawDefaultModel,
|
|
setOpenClawDefaultModelWithOverride,
|
|
} from '../utils/openclaw-auth';
|
|
import { logger } from '../utils/logger';
|
|
import {
|
|
saveChannelConfig,
|
|
getChannelConfig,
|
|
getChannelFormValues,
|
|
deleteChannelConfig,
|
|
listConfiguredChannels,
|
|
setChannelEnabled,
|
|
validateChannelConfig,
|
|
validateChannelCredentials,
|
|
} from '../utils/channel-config';
|
|
import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup';
|
|
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
|
|
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
|
|
|
/**
|
|
* Register all IPC handlers
|
|
*/
|
|
export function registerIpcHandlers(
|
|
gatewayManager: GatewayManager,
|
|
clawHubService: ClawHubService,
|
|
mainWindow: BrowserWindow
|
|
): void {
|
|
// Gateway handlers
|
|
registerGatewayHandlers(gatewayManager, mainWindow);
|
|
|
|
// ClawHub handlers
|
|
registerClawHubHandlers(clawHubService);
|
|
|
|
// OpenClaw handlers
|
|
registerOpenClawHandlers();
|
|
|
|
// Provider handlers
|
|
registerProviderHandlers();
|
|
|
|
// Shell handlers
|
|
registerShellHandlers();
|
|
|
|
// Dialog handlers
|
|
registerDialogHandlers();
|
|
|
|
// App handlers
|
|
registerAppHandlers();
|
|
|
|
// UV handlers
|
|
registerUvHandlers();
|
|
|
|
// Log handlers (for UI to read gateway/app logs)
|
|
registerLogHandlers();
|
|
|
|
// Skill config handlers (direct file access, no Gateway RPC)
|
|
registerSkillConfigHandlers();
|
|
|
|
// Cron task handlers (proxy to Gateway RPC)
|
|
registerCronHandlers(gatewayManager);
|
|
|
|
// Window control handlers (for custom title bar on Windows/Linux)
|
|
registerWindowHandlers(mainWindow);
|
|
|
|
// WhatsApp handlers
|
|
registerWhatsAppHandlers(mainWindow);
|
|
}
|
|
|
|
/**
|
|
* Skill config IPC handlers
|
|
* Direct read/write to ~/.openclaw/openclaw.json (bypasses Gateway RPC)
|
|
*/
|
|
function registerSkillConfigHandlers(): void {
|
|
// Update skill config (apiKey and env)
|
|
ipcMain.handle('skill:updateConfig', async (_, params: {
|
|
skillKey: string;
|
|
apiKey?: string;
|
|
env?: Record<string, string>;
|
|
}) => {
|
|
return updateSkillConfig(params.skillKey, {
|
|
apiKey: params.apiKey,
|
|
env: params.env,
|
|
});
|
|
});
|
|
|
|
// Get skill config
|
|
ipcMain.handle('skill:getConfig', async (_, skillKey: string) => {
|
|
return getSkillConfig(skillKey);
|
|
});
|
|
|
|
// Get all skill configs
|
|
ipcMain.handle('skill:getAllConfigs', async () => {
|
|
return getAllSkillConfigs();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gateway CronJob type (as returned by cron.list RPC)
|
|
*/
|
|
interface GatewayCronJob {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
enabled: boolean;
|
|
createdAtMs: number;
|
|
updatedAtMs: number;
|
|
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
|
|
payload: { kind: string; message?: string; text?: string };
|
|
delivery?: { mode: string; channel?: string; to?: string };
|
|
state: {
|
|
nextRunAtMs?: number;
|
|
lastRunAtMs?: number;
|
|
lastStatus?: string;
|
|
lastError?: string;
|
|
lastDurationMs?: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Transform a Gateway CronJob to the frontend CronJob format
|
|
*/
|
|
function transformCronJob(job: GatewayCronJob) {
|
|
// Extract message from payload
|
|
const message = job.payload?.message || job.payload?.text || '';
|
|
|
|
// Build target from delivery info
|
|
const channelType = job.delivery?.channel || 'unknown';
|
|
const target = {
|
|
channelType,
|
|
channelId: channelType,
|
|
channelName: channelType,
|
|
};
|
|
|
|
// Build lastRun from state
|
|
const lastRun = job.state?.lastRunAtMs
|
|
? {
|
|
time: new Date(job.state.lastRunAtMs).toISOString(),
|
|
success: job.state.lastStatus === 'ok',
|
|
error: job.state.lastError,
|
|
duration: job.state.lastDurationMs,
|
|
}
|
|
: undefined;
|
|
|
|
// Build nextRun from state
|
|
const nextRun = job.state?.nextRunAtMs
|
|
? new Date(job.state.nextRunAtMs).toISOString()
|
|
: undefined;
|
|
|
|
return {
|
|
id: job.id,
|
|
name: job.name,
|
|
message,
|
|
schedule: job.schedule, // Pass the object through; frontend parseCronSchedule handles it
|
|
target,
|
|
enabled: job.enabled,
|
|
createdAt: new Date(job.createdAtMs).toISOString(),
|
|
updatedAt: new Date(job.updatedAtMs).toISOString(),
|
|
lastRun,
|
|
nextRun,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cron task IPC handlers
|
|
* Proxies cron operations to the Gateway RPC service.
|
|
* The frontend works with plain cron expression strings, but the Gateway
|
|
* expects CronSchedule objects ({ kind: "cron", expr: "..." }).
|
|
* These handlers bridge the two formats.
|
|
*/
|
|
function registerCronHandlers(gatewayManager: GatewayManager): void {
|
|
// List all cron jobs — transforms Gateway CronJob format to frontend CronJob format
|
|
ipcMain.handle('cron:list', async () => {
|
|
try {
|
|
const result = await gatewayManager.rpc('cron.list', { includeDisabled: true });
|
|
const data = result as { jobs?: GatewayCronJob[] };
|
|
const jobs = data?.jobs ?? [];
|
|
// Transform Gateway format to frontend format
|
|
return jobs.map(transformCronJob);
|
|
} catch (error) {
|
|
console.error('Failed to list cron jobs:', error);
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
// Create a new cron job
|
|
ipcMain.handle('cron:create', async (_, input: {
|
|
name: string;
|
|
message: string;
|
|
schedule: string;
|
|
target: { channelType: string; channelId: string; channelName: string };
|
|
enabled?: boolean;
|
|
}) => {
|
|
try {
|
|
// Transform frontend input to Gateway cron.add format
|
|
// For Discord, the recipient must be prefixed with "channel:" or "user:"
|
|
const recipientId = input.target.channelId;
|
|
const deliveryTo = input.target.channelType === 'discord' && recipientId
|
|
? `channel:${recipientId}`
|
|
: recipientId;
|
|
|
|
const gatewayInput = {
|
|
name: input.name,
|
|
schedule: { kind: 'cron', expr: input.schedule },
|
|
payload: { kind: 'agentTurn', message: input.message },
|
|
enabled: input.enabled ?? true,
|
|
wakeMode: 'next-heartbeat',
|
|
sessionTarget: 'isolated',
|
|
delivery: {
|
|
mode: 'announce',
|
|
channel: input.target.channelType,
|
|
to: deliveryTo,
|
|
},
|
|
};
|
|
const result = await gatewayManager.rpc('cron.add', gatewayInput);
|
|
// Transform the returned job to frontend format
|
|
if (result && typeof result === 'object') {
|
|
return transformCronJob(result as GatewayCronJob);
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Failed to create cron job:', error);
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
// Update an existing cron job
|
|
ipcMain.handle('cron:update', async (_, id: string, input: Record<string, unknown>) => {
|
|
try {
|
|
// Transform schedule string to CronSchedule object if present
|
|
const patch = { ...input };
|
|
if (typeof patch.schedule === 'string') {
|
|
patch.schedule = { kind: 'cron', expr: patch.schedule };
|
|
}
|
|
// Transform message to payload format if present
|
|
if (typeof patch.message === 'string') {
|
|
patch.payload = { kind: 'agentTurn', message: patch.message };
|
|
delete patch.message;
|
|
}
|
|
const result = await gatewayManager.rpc('cron.update', { id, patch });
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Failed to update cron job:', error);
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
// Delete a cron job
|
|
ipcMain.handle('cron:delete', async (_, id: string) => {
|
|
try {
|
|
const result = await gatewayManager.rpc('cron.remove', { id });
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Failed to delete cron job:', error);
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
// Toggle a cron job enabled/disabled
|
|
ipcMain.handle('cron:toggle', async (_, id: string, enabled: boolean) => {
|
|
try {
|
|
const result = await gatewayManager.rpc('cron.update', { id, patch: { enabled } });
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Failed to toggle cron job:', error);
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
// Trigger a cron job manually
|
|
ipcMain.handle('cron:trigger', async (_, id: string) => {
|
|
try {
|
|
const result = await gatewayManager.rpc('cron.run', { id, mode: 'force' });
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Failed to trigger cron job:', error);
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* UV-related IPC handlers
|
|
*/
|
|
function registerUvHandlers(): void {
|
|
// Check if uv is installed
|
|
ipcMain.handle('uv:check', async () => {
|
|
return await checkUvInstalled();
|
|
});
|
|
|
|
// Install uv and setup managed Python
|
|
ipcMain.handle('uv:install-all', async () => {
|
|
try {
|
|
const isInstalled = await checkUvInstalled();
|
|
if (!isInstalled) {
|
|
await installUv();
|
|
}
|
|
// Always run python setup to ensure it exists in uv's cache
|
|
await setupManagedPython();
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Failed to setup uv/python:', error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Log-related IPC handlers
|
|
* Allows the renderer to read application logs for diagnostics
|
|
*/
|
|
function registerLogHandlers(): void {
|
|
// Get recent logs from memory ring buffer
|
|
ipcMain.handle('log:getRecent', async (_, count?: number) => {
|
|
return logger.getRecentLogs(count);
|
|
});
|
|
|
|
// Read log file content (last N lines)
|
|
ipcMain.handle('log:readFile', async (_, tailLines?: number) => {
|
|
return logger.readLogFile(tailLines);
|
|
});
|
|
|
|
// Get log file path (so user can open in file explorer)
|
|
ipcMain.handle('log:getFilePath', async () => {
|
|
return logger.getLogFilePath();
|
|
});
|
|
|
|
// Get log directory path
|
|
ipcMain.handle('log:getDir', async () => {
|
|
return logger.getLogDir();
|
|
});
|
|
|
|
// List all log files
|
|
ipcMain.handle('log:listFiles', async () => {
|
|
return logger.listLogFiles();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gateway-related IPC handlers
|
|
*/
|
|
function registerGatewayHandlers(
|
|
gatewayManager: GatewayManager,
|
|
mainWindow: BrowserWindow
|
|
): void {
|
|
// Get Gateway status
|
|
ipcMain.handle('gateway:status', () => {
|
|
return gatewayManager.getStatus();
|
|
});
|
|
|
|
// Check if Gateway is connected
|
|
ipcMain.handle('gateway:isConnected', () => {
|
|
return gatewayManager.isConnected();
|
|
});
|
|
|
|
// 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.restart();
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Gateway RPC call
|
|
ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown, timeoutMs?: number) => {
|
|
try {
|
|
const result = await gatewayManager.rpc(method, params, timeoutMs);
|
|
return { success: true, result };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Get the Control UI URL with token for embedding
|
|
ipcMain.handle('gateway:getControlUiUrl', async () => {
|
|
try {
|
|
const status = gatewayManager.getStatus();
|
|
const token = await getSetting('gatewayToken');
|
|
const port = status.port || 18789;
|
|
// Pass token as query param - Control UI will store it in localStorage
|
|
const url = `http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`;
|
|
return { success: true, url, port, token };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Health check
|
|
ipcMain.handle('gateway:health', async () => {
|
|
try {
|
|
const health = await gatewayManager.checkHealth();
|
|
return { success: true, ...health };
|
|
} catch (error) {
|
|
return { success: false, ok: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Forward Gateway events to renderer
|
|
gatewayManager.on('status', (status) => {
|
|
if (!mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('gateway:status-changed', status);
|
|
}
|
|
});
|
|
|
|
gatewayManager.on('message', (message) => {
|
|
if (!mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('gateway:message', message);
|
|
}
|
|
});
|
|
|
|
gatewayManager.on('notification', (notification) => {
|
|
if (!mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('gateway:notification', notification);
|
|
}
|
|
});
|
|
|
|
gatewayManager.on('channel:status', (data) => {
|
|
if (!mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('gateway:channel-status', data);
|
|
}
|
|
});
|
|
|
|
gatewayManager.on('chat:message', (data) => {
|
|
if (!mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('gateway:chat-message', data);
|
|
}
|
|
});
|
|
|
|
gatewayManager.on('exit', (code) => {
|
|
if (!mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('gateway:exit', code);
|
|
}
|
|
});
|
|
|
|
gatewayManager.on('error', (error) => {
|
|
if (!mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('gateway:error', error.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* OpenClaw-related IPC handlers
|
|
* For checking package status and channel configuration
|
|
*/
|
|
function registerOpenClawHandlers(): void {
|
|
|
|
// Get OpenClaw package status
|
|
ipcMain.handle('openclaw:status', () => {
|
|
const status = getOpenClawStatus();
|
|
logger.info('openclaw:status IPC called', status);
|
|
return status;
|
|
});
|
|
|
|
// Check if OpenClaw is ready (package present)
|
|
ipcMain.handle('openclaw:isReady', () => {
|
|
const status = getOpenClawStatus();
|
|
return status.packageExists;
|
|
});
|
|
|
|
// Get the resolved OpenClaw directory path (for diagnostics)
|
|
ipcMain.handle('openclaw:getDir', () => {
|
|
return getOpenClawDir();
|
|
});
|
|
|
|
// Get the OpenClaw config directory (~/.openclaw)
|
|
ipcMain.handle('openclaw:getConfigDir', () => {
|
|
return getOpenClawConfigDir();
|
|
});
|
|
|
|
// Get the OpenClaw skills directory (~/.openclaw/skills)
|
|
ipcMain.handle('openclaw:getSkillsDir', () => {
|
|
return getOpenClawSkillsDir();
|
|
});
|
|
|
|
// Get a shell command to run OpenClaw CLI without modifying PATH
|
|
ipcMain.handle('openclaw:getCliCommand', () => {
|
|
try {
|
|
const status = getOpenClawStatus();
|
|
if (!status.packageExists) {
|
|
return { success: false, error: `OpenClaw package not found at: ${status.dir}` };
|
|
}
|
|
if (!existsSync(status.entryPath)) {
|
|
return { success: false, error: `OpenClaw entry script not found at: ${status.entryPath}` };
|
|
}
|
|
return { success: true, command: getOpenClawCliCommand() };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Install a system-wide openclaw command on macOS (requires admin prompt)
|
|
ipcMain.handle('openclaw:installCliMac', async () => {
|
|
return installOpenClawCliMac();
|
|
});
|
|
|
|
// ==================== Channel Configuration Handlers ====================
|
|
|
|
// Save channel configuration
|
|
ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record<string, unknown>) => {
|
|
try {
|
|
logger.info('channel:saveConfig', { channelType, keys: Object.keys(config || {}) });
|
|
saveChannelConfig(channelType, config);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Failed to save channel config:', error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Get channel configuration
|
|
ipcMain.handle('channel:getConfig', async (_, channelType: string) => {
|
|
try {
|
|
const config = getChannelConfig(channelType);
|
|
return { success: true, config };
|
|
} catch (error) {
|
|
console.error('Failed to get channel config:', error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Get channel form values (reverse-transformed for UI pre-fill)
|
|
ipcMain.handle('channel:getFormValues', async (_, channelType: string) => {
|
|
try {
|
|
const values = getChannelFormValues(channelType);
|
|
return { success: true, values };
|
|
} catch (error) {
|
|
console.error('Failed to get channel form values:', error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Delete channel configuration
|
|
ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => {
|
|
try {
|
|
deleteChannelConfig(channelType);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Failed to delete channel config:', error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// List configured channels
|
|
ipcMain.handle('channel:listConfigured', async () => {
|
|
try {
|
|
const channels = listConfiguredChannels();
|
|
return { success: true, channels };
|
|
} catch (error) {
|
|
console.error('Failed to list channels:', error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Enable or disable a channel
|
|
ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => {
|
|
try {
|
|
setChannelEnabled(channelType, enabled);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Failed to set channel enabled:', error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Validate channel configuration
|
|
ipcMain.handle('channel:validate', async (_, channelType: string) => {
|
|
try {
|
|
const result = await validateChannelConfig(channelType);
|
|
return { success: true, ...result };
|
|
} catch (error) {
|
|
console.error('Failed to validate channel:', error);
|
|
return { success: false, valid: false, errors: [String(error)], warnings: [] };
|
|
}
|
|
});
|
|
|
|
// Validate channel credentials by calling actual service APIs (before saving)
|
|
ipcMain.handle('channel:validateCredentials', async (_, channelType: string, config: Record<string, string>) => {
|
|
try {
|
|
const result = await validateChannelCredentials(channelType, config);
|
|
return { success: true, ...result };
|
|
} catch (error) {
|
|
console.error('Failed to validate channel credentials:', error);
|
|
return { success: false, valid: false, errors: [String(error)], warnings: [] };
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* WhatsApp Login Handlers
|
|
*/
|
|
function registerWhatsAppHandlers(mainWindow: BrowserWindow): void {
|
|
// Request WhatsApp QR code
|
|
ipcMain.handle('channel:requestWhatsAppQr', async (_, accountId: string) => {
|
|
try {
|
|
logger.info('channel:requestWhatsAppQr', { accountId });
|
|
await whatsAppLoginManager.start(accountId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('channel:requestWhatsAppQr failed', error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Cancel WhatsApp login
|
|
ipcMain.handle('channel:cancelWhatsAppQr', async () => {
|
|
try {
|
|
await whatsAppLoginManager.stop();
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('channel:cancelWhatsAppQr failed', error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Check WhatsApp status (is it active?)
|
|
// ipcMain.handle('channel:checkWhatsAppStatus', ...)
|
|
|
|
// Forward events to renderer
|
|
whatsAppLoginManager.on('qr', (data) => {
|
|
if (!mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('channel:whatsapp-qr', data);
|
|
}
|
|
});
|
|
|
|
whatsAppLoginManager.on('success', (data) => {
|
|
if (!mainWindow.isDestroyed()) {
|
|
logger.info('whatsapp:login-success', data);
|
|
mainWindow.webContents.send('channel:whatsapp-success', data);
|
|
}
|
|
});
|
|
|
|
whatsAppLoginManager.on('error', (error) => {
|
|
if (!mainWindow.isDestroyed()) {
|
|
logger.error('whatsapp:login-error', error);
|
|
mainWindow.webContents.send('channel:whatsapp-error', error);
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* Provider-related IPC handlers
|
|
*/
|
|
function registerProviderHandlers(): void {
|
|
// Get all providers with key info
|
|
ipcMain.handle('provider:list', async () => {
|
|
return await getAllProvidersWithKeyInfo();
|
|
});
|
|
|
|
// Get a specific provider
|
|
ipcMain.handle('provider:get', async (_, providerId: string) => {
|
|
return await getProvider(providerId);
|
|
});
|
|
|
|
// Save a provider configuration
|
|
ipcMain.handle('provider:save', async (_, config: ProviderConfig, apiKey?: string) => {
|
|
try {
|
|
// Save the provider config
|
|
await saveProvider(config);
|
|
|
|
// Store the API key if provided
|
|
if (apiKey) {
|
|
await storeApiKey(config.id, apiKey);
|
|
|
|
// Also write to OpenClaw auth-profiles.json so the gateway can use it
|
|
try {
|
|
saveProviderKeyToOpenClaw(config.type, apiKey);
|
|
} catch (err) {
|
|
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Delete a provider
|
|
ipcMain.handle('provider:delete', async (_, providerId: string) => {
|
|
try {
|
|
const existing = await getProvider(providerId);
|
|
await deleteProvider(providerId);
|
|
|
|
// Best-effort cleanup in OpenClaw auth profiles
|
|
if (existing?.type) {
|
|
try {
|
|
removeProviderKeyFromOpenClaw(existing.type);
|
|
} catch (err) {
|
|
console.warn('Failed to remove key from OpenClaw auth-profiles:', err);
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Update API key for a provider
|
|
ipcMain.handle('provider:setApiKey', async (_, providerId: string, apiKey: string) => {
|
|
try {
|
|
await storeApiKey(providerId, apiKey);
|
|
|
|
// Also write to OpenClaw auth-profiles.json
|
|
// Resolve provider type from stored config, or use providerId as type
|
|
const provider = await getProvider(providerId);
|
|
const providerType = provider?.type || providerId;
|
|
try {
|
|
saveProviderKeyToOpenClaw(providerType, apiKey);
|
|
} catch (err) {
|
|
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Atomically update provider config and API key
|
|
ipcMain.handle(
|
|
'provider:updateWithKey',
|
|
async (
|
|
_,
|
|
providerId: string,
|
|
updates: Partial<ProviderConfig>,
|
|
apiKey?: string
|
|
) => {
|
|
const existing = await getProvider(providerId);
|
|
if (!existing) {
|
|
return { success: false, error: 'Provider not found' };
|
|
}
|
|
|
|
const previousKey = await getApiKey(providerId);
|
|
const previousProviderType = existing.type;
|
|
|
|
try {
|
|
const nextConfig: ProviderConfig = {
|
|
...existing,
|
|
...updates,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await saveProvider(nextConfig);
|
|
|
|
if (apiKey !== undefined) {
|
|
const trimmedKey = apiKey.trim();
|
|
if (trimmedKey) {
|
|
await storeApiKey(providerId, trimmedKey);
|
|
saveProviderKeyToOpenClaw(nextConfig.type, trimmedKey);
|
|
} else {
|
|
await deleteApiKey(providerId);
|
|
removeProviderKeyFromOpenClaw(nextConfig.type);
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
// Best-effort rollback to keep config/key consistent.
|
|
try {
|
|
await saveProvider(existing);
|
|
if (previousKey) {
|
|
await storeApiKey(providerId, previousKey);
|
|
saveProviderKeyToOpenClaw(previousProviderType, previousKey);
|
|
} else {
|
|
await deleteApiKey(providerId);
|
|
removeProviderKeyFromOpenClaw(previousProviderType);
|
|
}
|
|
} catch (rollbackError) {
|
|
console.warn('Failed to rollback provider updateWithKey:', rollbackError);
|
|
}
|
|
|
|
return { success: false, error: String(error) };
|
|
}
|
|
}
|
|
);
|
|
|
|
// Delete API key for a provider
|
|
ipcMain.handle('provider:deleteApiKey', async (_, providerId: string) => {
|
|
try {
|
|
await deleteApiKey(providerId);
|
|
|
|
// Keep OpenClaw auth-profiles.json in sync with local key storage
|
|
const provider = await getProvider(providerId);
|
|
const providerType = provider?.type || providerId;
|
|
try {
|
|
removeProviderKeyFromOpenClaw(providerType);
|
|
} catch (err) {
|
|
console.warn('Failed to remove key from OpenClaw auth-profiles:', err);
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Check if a provider has an API key
|
|
ipcMain.handle('provider:hasApiKey', async (_, providerId: string) => {
|
|
return await hasApiKey(providerId);
|
|
});
|
|
|
|
// Get the actual API key (for internal use only - be careful!)
|
|
ipcMain.handle('provider:getApiKey', async (_, providerId: string) => {
|
|
return await getApiKey(providerId);
|
|
});
|
|
|
|
// Set default provider and update OpenClaw default model
|
|
ipcMain.handle('provider:setDefault', async (_, providerId: string) => {
|
|
try {
|
|
await setDefaultProvider(providerId);
|
|
|
|
// Update OpenClaw config to use this provider's default model
|
|
const provider = await getProvider(providerId);
|
|
if (provider) {
|
|
try {
|
|
// If the provider has a user-specified model (e.g. siliconflow),
|
|
// build the full model string: "providerType/modelId"
|
|
const modelOverride = provider.model
|
|
? `${provider.type}/${provider.model}`
|
|
: undefined;
|
|
|
|
if (provider.type === 'custom' || provider.type === 'ollama') {
|
|
// For runtime-configured providers, use user-entered base URL/api.
|
|
setOpenClawDefaultModelWithOverride(provider.type, modelOverride, {
|
|
baseUrl: provider.baseUrl,
|
|
api: 'openai-completions',
|
|
});
|
|
} else {
|
|
setOpenClawDefaultModel(provider.type, modelOverride);
|
|
}
|
|
|
|
// Keep auth-profiles in sync with the default provider instance.
|
|
// This is especially important when multiple custom providers exist.
|
|
const providerKey = await getApiKey(providerId);
|
|
if (providerKey) {
|
|
saveProviderKeyToOpenClaw(provider.type, providerKey);
|
|
}
|
|
} catch (err) {
|
|
console.warn('Failed to set OpenClaw default model:', err);
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Get default provider
|
|
ipcMain.handle('provider:getDefault', async () => {
|
|
return await getDefaultProvider();
|
|
});
|
|
|
|
// Validate API key by making a real test request to the provider
|
|
// providerId can be either a stored provider ID or a provider type (e.g., 'openrouter', 'anthropic')
|
|
ipcMain.handle('provider:validateKey', async (_, providerId: string, apiKey: string) => {
|
|
try {
|
|
// First try to get existing provider
|
|
const provider = await getProvider(providerId);
|
|
|
|
// Use provider.type if provider exists, otherwise use providerId as the type
|
|
// This allows validation during setup when provider hasn't been saved yet
|
|
const providerType = provider?.type || providerId;
|
|
|
|
console.log(`[clawx-validate] validating provider type: ${providerType}`);
|
|
return await validateApiKeyWithProvider(providerType, apiKey);
|
|
} catch (error) {
|
|
console.error('Validation error:', error);
|
|
return { valid: false, error: String(error) };
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validate API key using lightweight model-listing endpoints (zero token cost).
|
|
* Falls back to accepting the key for unknown/custom provider types.
|
|
*/
|
|
async function validateApiKeyWithProvider(
|
|
providerType: string,
|
|
apiKey: string
|
|
): Promise<{ valid: boolean; error?: string }> {
|
|
const trimmedKey = apiKey.trim();
|
|
if (!trimmedKey) {
|
|
return { valid: false, error: 'API key is required' };
|
|
}
|
|
|
|
try {
|
|
switch (providerType) {
|
|
case 'anthropic':
|
|
return await validateAnthropicKey(trimmedKey);
|
|
case 'openai':
|
|
return await validateOpenAIKey(trimmedKey);
|
|
case 'google':
|
|
return await validateGoogleKey(trimmedKey);
|
|
case 'openrouter':
|
|
return await validateOpenRouterKey(trimmedKey);
|
|
case 'moonshot':
|
|
return await validateMoonshotKey(trimmedKey);
|
|
case 'siliconflow':
|
|
return await validateSiliconFlowKey(trimmedKey);
|
|
case 'ollama':
|
|
// Ollama doesn't require API key validation
|
|
return { valid: true };
|
|
default:
|
|
// For custom providers, just check the key is not empty
|
|
console.log(`[clawx-validate] ${providerType} uses local non-empty validation only`);
|
|
return { valid: true };
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { valid: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
function logValidationStatus(provider: string, status: number): void {
|
|
console.log(`[clawx-validate] ${provider} HTTP ${status}`);
|
|
}
|
|
|
|
function maskSecret(secret: string): string {
|
|
if (!secret) return '';
|
|
if (secret.length <= 8) return `${secret.slice(0, 2)}***`;
|
|
return `${secret.slice(0, 4)}***${secret.slice(-4)}`;
|
|
}
|
|
|
|
function sanitizeValidationUrl(rawUrl: string): string {
|
|
try {
|
|
const url = new URL(rawUrl);
|
|
const key = url.searchParams.get('key');
|
|
if (key) url.searchParams.set('key', maskSecret(key));
|
|
return url.toString();
|
|
} catch {
|
|
return rawUrl;
|
|
}
|
|
}
|
|
|
|
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
|
|
const next = { ...headers };
|
|
if (next.Authorization?.startsWith('Bearer ')) {
|
|
const token = next.Authorization.slice('Bearer '.length);
|
|
next.Authorization = `Bearer ${maskSecret(token)}`;
|
|
}
|
|
if (next['x-api-key']) {
|
|
next['x-api-key'] = maskSecret(next['x-api-key']);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function logValidationRequest(
|
|
provider: string,
|
|
method: string,
|
|
url: string,
|
|
headers: Record<string, string>
|
|
): void {
|
|
console.log(
|
|
`[clawx-validate] ${provider} request ${method} ${sanitizeValidationUrl(url)} headers=${JSON.stringify(sanitizeHeaders(headers))}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Helper: classify an HTTP response as valid / invalid / error.
|
|
* 200 / 429 → valid (key works, possibly rate-limited).
|
|
* 401 / 403 → invalid.
|
|
* Everything else → return the API error message.
|
|
*/
|
|
function classifyAuthResponse(
|
|
status: number,
|
|
data: unknown
|
|
): { valid: boolean; error?: string } {
|
|
if (status >= 200 && status < 300) return { valid: true };
|
|
if (status === 429) return { valid: true }; // rate-limited but key is valid
|
|
if (status === 401 || status === 403) return { valid: false, error: 'Invalid API key' };
|
|
|
|
// Try to extract an error message
|
|
const obj = data as { error?: { message?: string }; message?: string } | null;
|
|
const msg = obj?.error?.message || obj?.message || `API error: ${status}`;
|
|
return { valid: false, error: msg };
|
|
}
|
|
|
|
/**
|
|
* Validate Anthropic API key via GET /v1/models (zero cost)
|
|
*/
|
|
async function validateAnthropicKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
|
try {
|
|
const url = 'https://api.anthropic.com/v1/models?limit=1';
|
|
const headers = {
|
|
'x-api-key': apiKey,
|
|
'anthropic-version': '2023-06-01',
|
|
};
|
|
logValidationRequest('anthropic', 'GET', url, headers);
|
|
const response = await fetch(url, { headers });
|
|
logValidationStatus('anthropic', response.status);
|
|
const data = await response.json().catch(() => ({}));
|
|
return classifyAuthResponse(response.status, data);
|
|
} catch (error) {
|
|
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate OpenAI API key via GET /v1/models (zero cost)
|
|
*/
|
|
async function validateOpenAIKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
|
try {
|
|
const url = 'https://api.openai.com/v1/models?limit=1';
|
|
const headers = { Authorization: `Bearer ${apiKey}` };
|
|
logValidationRequest('openai', 'GET', url, headers);
|
|
const response = await fetch(url, { headers });
|
|
logValidationStatus('openai', response.status);
|
|
const data = await response.json().catch(() => ({}));
|
|
return classifyAuthResponse(response.status, data);
|
|
} catch (error) {
|
|
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate Google (Gemini) API key via GET /v1beta/models (zero cost)
|
|
*/
|
|
async function validateGoogleKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
|
try {
|
|
const url = `https://generativelanguage.googleapis.com/v1beta/models?pageSize=1&key=${apiKey}`;
|
|
logValidationRequest('google', 'GET', url, {});
|
|
const response = await fetch(url);
|
|
logValidationStatus('google', response.status);
|
|
const data = await response.json().catch(() => ({}));
|
|
return classifyAuthResponse(response.status, data);
|
|
} catch (error) {
|
|
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate OpenRouter API key via GET /api/v1/models (zero cost)
|
|
*/
|
|
async function validateOpenRouterKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
|
try {
|
|
const url = 'https://openrouter.ai/api/v1/models';
|
|
const headers = { Authorization: `Bearer ${apiKey}` };
|
|
logValidationRequest('openrouter', 'GET', url, headers);
|
|
const response = await fetch(url, { headers });
|
|
logValidationStatus('openrouter', response.status);
|
|
const data = await response.json().catch(() => ({}));
|
|
return classifyAuthResponse(response.status, data);
|
|
} catch (error) {
|
|
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate Moonshot API key via GET /v1/models (zero cost)
|
|
*/
|
|
async function validateMoonshotKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
|
try {
|
|
const url = 'https://api.moonshot.cn/v1/models';
|
|
const headers = { Authorization: `Bearer ${apiKey}` };
|
|
logValidationRequest('moonshot', 'GET', url, headers);
|
|
const response = await fetch(url, { headers });
|
|
logValidationStatus('moonshot', response.status);
|
|
const data = await response.json().catch(() => ({}));
|
|
return classifyAuthResponse(response.status, data);
|
|
} catch (error) {
|
|
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate SiliconFlow API key via GET /v1/models (zero cost)
|
|
*/
|
|
async function validateSiliconFlowKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
|
try {
|
|
const url = 'https://api.siliconflow.com/v1/models';
|
|
const headers = { Authorization: `Bearer ${apiKey}` };
|
|
logValidationRequest('siliconflow', 'GET', url, headers);
|
|
const response = await fetch(url, { headers });
|
|
logValidationStatus('siliconflow', response.status);
|
|
const data = await response.json().catch(() => ({}));
|
|
return classifyAuthResponse(response.status, data);
|
|
} catch (error) {
|
|
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* ClawHub-related IPC handlers
|
|
*/
|
|
function registerClawHubHandlers(clawHubService: ClawHubService): void {
|
|
// Search skills
|
|
ipcMain.handle('clawhub:search', async (_, params: ClawHubSearchParams) => {
|
|
try {
|
|
const results = await clawHubService.search(params);
|
|
return { success: true, results };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Install skill
|
|
ipcMain.handle('clawhub:install', async (_, params: ClawHubInstallParams) => {
|
|
try {
|
|
await clawHubService.install(params);
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Uninstall skill
|
|
ipcMain.handle('clawhub:uninstall', async (_, params: ClawHubUninstallParams) => {
|
|
try {
|
|
await clawHubService.uninstall(params);
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// List installed skills
|
|
ipcMain.handle('clawhub:list', async () => {
|
|
try {
|
|
const results = await clawHubService.listInstalled();
|
|
return { success: true, results };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Open skill readme
|
|
ipcMain.handle('clawhub:openSkillReadme', async (_, slug: string) => {
|
|
try {
|
|
await clawHubService.openSkillReadme(slug);
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Window control handlers (for custom title bar on Windows/Linux)
|
|
*/
|
|
function registerWindowHandlers(mainWindow: BrowserWindow): void {
|
|
ipcMain.handle('window:minimize', () => {
|
|
mainWindow.minimize();
|
|
});
|
|
|
|
ipcMain.handle('window:maximize', () => {
|
|
if (mainWindow.isMaximized()) {
|
|
mainWindow.unmaximize();
|
|
} else {
|
|
mainWindow.maximize();
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('window:close', () => {
|
|
mainWindow.close();
|
|
});
|
|
|
|
ipcMain.handle('window:isMaximized', () => {
|
|
return mainWindow.isMaximized();
|
|
});
|
|
}
|