/** * IPC Handlers * Registers all IPC handlers for main-renderer communication */ import { ipcMain, BrowserWindow, shell, dialog, app, nativeImage } from 'electron'; import { existsSync, copyFileSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, extname, basename } from 'node:path'; import crypto from 'node:crypto'; 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, ensureDir } 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'; import { getProviderConfig } from '../utils/provider-registry'; /** * 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); // File staging handlers (upload/send separation) registerFileHandlers(); } /** * 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; }) => { 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) => { 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) }; } }); // Chat send with media — reads staged files from disk and builds attachments. // Raster images (png/jpg/gif/webp) are inlined as base64 vision attachments. // All other files are referenced by path in the message text so the model // can access them via tools (the same format channels use). const VISION_MIME_TYPES = new Set([ 'image/png', 'image/jpeg', 'image/bmp', 'image/webp', ]); ipcMain.handle('chat:sendWithMedia', async (_, params: { sessionKey: string; message: string; deliver?: boolean; idempotencyKey: string; media?: Array<{ filePath: string; mimeType: string; fileName: string }>; }) => { try { let message = params.message; // The Gateway processes image attachments through TWO parallel paths: // Path A: `attachments` param → parsed via `parseMessageWithAttachments` → // injected as inline vision content when the model supports images. // Format: { content: base64, mimeType: string, fileName?: string } // Path B: `[media attached: ...]` in message text → Gateway's native image // detection (`detectAndLoadPromptImages`) reads the file from disk and // injects it as inline vision content. Also works for history messages. // We use BOTH paths for maximum reliability. const imageAttachments: Array> = []; const fileReferences: string[] = []; if (params.media && params.media.length > 0) { for (const m of params.media) { logger.info(`[chat:sendWithMedia] Processing file: ${m.fileName} (${m.mimeType}), path: ${m.filePath}, exists: ${existsSync(m.filePath)}, isVision: ${VISION_MIME_TYPES.has(m.mimeType)}`); // Always add file path reference so the model can access it via tools fileReferences.push( `[media attached: ${m.filePath} (${m.mimeType}) | ${m.filePath}]`, ); if (VISION_MIME_TYPES.has(m.mimeType)) { // Send as base64 attachment in the format the Gateway expects: // { content: base64String, mimeType: string, fileName?: string } // The Gateway normalizer looks for `a.content` (NOT `a.source.data`). const fileBuffer = readFileSync(m.filePath); const base64Data = fileBuffer.toString('base64'); logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`); imageAttachments.push({ content: base64Data, mimeType: m.mimeType, fileName: m.fileName, }); } } } // Append file references to message text so the model knows about them if (fileReferences.length > 0) { const refs = fileReferences.join('\n'); message = message ? `${message}\n\n${refs}` : refs; } const rpcParams: Record = { sessionKey: params.sessionKey, message, deliver: params.deliver ?? false, idempotencyKey: params.idempotencyKey, }; if (imageAttachments.length > 0) { rpcParams.attachments = imageAttachments; } logger.info(`[chat:sendWithMedia] Sending: message="${message.substring(0, 100)}", attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`); // Use a longer timeout when images are present (120s vs default 30s) const timeoutMs = imageAttachments.length > 0 ? 120000 : 30000; const result = await gatewayManager.rpc('chat.send', rpcParams, timeoutMs); logger.info(`[chat:sendWithMedia] RPC result: ${JSON.stringify(result)}`); return { success: true, result }; } catch (error) { logger.error(`[chat:sendWithMedia] Error: ${String(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', () => { const dir = getOpenClawSkillsDir(); ensureDir(dir); return dir; }); // 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) => { 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) => { 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, 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. ipcMain.handle( 'provider:validateKey', async ( _, providerId: string, apiKey: string, options?: { baseUrl?: 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; const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; // Prefer caller-supplied baseUrl (live form value) over persisted config. // This ensures Setup/Settings validation reflects unsaved edits immediately. const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl; console.log(`[clawx-validate] validating provider type: ${providerType}`); return await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl }); } catch (error) { console.error('Validation error:', error); return { valid: false, error: String(error) }; } } ); } type ValidationProfile = 'openai-compatible' | 'google-query-key' | 'anthropic-header' | 'none'; /** * Validate API key using lightweight model-listing endpoints (zero token cost). * Providers are grouped into 3 auth styles: * - openai-compatible: Bearer auth + /models * - google-query-key: ?key=... + /models * - anthropic-header: x-api-key + anthropic-version + /models */ async function validateApiKeyWithProvider( providerType: string, apiKey: string, options?: { baseUrl?: string } ): Promise<{ valid: boolean; error?: string }> { const profile = getValidationProfile(providerType); if (profile === 'none') { return { valid: true }; } const trimmedKey = apiKey.trim(); if (!trimmedKey) { return { valid: false, error: 'API key is required' }; } try { switch (profile) { case 'openai-compatible': return await validateOpenAiCompatibleKey(providerType, trimmedKey, options?.baseUrl); case 'google-query-key': return await validateGoogleQueryKey(providerType, trimmedKey, options?.baseUrl); case 'anthropic-header': return await validateAnthropicHeaderKey(providerType, trimmedKey, options?.baseUrl); default: return { valid: false, error: `Unsupported validation profile for provider: ${providerType}` }; } } 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): Record { 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 normalizeBaseUrl(baseUrl: string): string { return baseUrl.trim().replace(/\/+$/, ''); } function buildOpenAiModelsUrl(baseUrl: string): string { return `${normalizeBaseUrl(baseUrl)}/models?limit=1`; } function logValidationRequest( provider: string, method: string, url: string, headers: Record ): void { console.log( `[clawx-validate] ${provider} request ${method} ${sanitizeValidationUrl(url)} headers=${JSON.stringify(sanitizeHeaders(headers))}` ); } function getValidationProfile(providerType: string): ValidationProfile { switch (providerType) { case 'anthropic': return 'anthropic-header'; case 'google': return 'google-query-key'; case 'ollama': return 'none'; default: return 'openai-compatible'; } } async function performProviderValidationRequest( providerLabel: string, url: string, headers: Record ): Promise<{ valid: boolean; error?: string }> { try { logValidationRequest(providerLabel, 'GET', url, headers); const response = await fetch(url, { headers }); logValidationStatus(providerLabel, 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)}`, }; } } /** * 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 }; } async function validateOpenAiCompatibleKey( providerType: string, apiKey: string, baseUrl?: string ): Promise<{ valid: boolean; error?: string }> { const trimmedBaseUrl = baseUrl?.trim(); if (!trimmedBaseUrl) { return { valid: false, error: `Base URL is required for provider "${providerType}" validation` }; } const headers = { Authorization: `Bearer ${apiKey}` }; // Try /models first (standard OpenAI-compatible endpoint) const modelsUrl = buildOpenAiModelsUrl(trimmedBaseUrl); const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers); // If /models returned 404, the provider likely doesn't implement it (e.g. MiniMax). // Fall back to a minimal /chat/completions POST which almost all providers support. if (modelsResult.error?.includes('API error: 404')) { console.log( `[clawx-validate] ${providerType} /models returned 404, falling back to /chat/completions probe` ); const base = normalizeBaseUrl(trimmedBaseUrl); const chatUrl = `${base}/chat/completions`; return await performChatCompletionsProbe(providerType, chatUrl, headers); } return modelsResult; } /** * Fallback validation: send a minimal /chat/completions request. * We intentionally use max_tokens=1 to minimise cost. The goal is only to * distinguish auth errors (401/403) from a working key (200/400/429). * A 400 "invalid model" still proves the key itself is accepted. */ async function performChatCompletionsProbe( providerLabel: string, url: string, headers: Record ): Promise<{ valid: boolean; error?: string }> { try { logValidationRequest(providerLabel, 'POST', url, headers); const response = await fetch(url, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'validation-probe', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1, }), }); logValidationStatus(providerLabel, response.status); const data = await response.json().catch(() => ({})); // 401/403 → invalid key if (response.status === 401 || response.status === 403) { return { valid: false, error: 'Invalid API key' }; } // 200, 400 (bad model but key accepted), 429 → key is valid if ( (response.status >= 200 && response.status < 300) || response.status === 400 || response.status === 429 ) { return { valid: true }; } return classifyAuthResponse(response.status, data); } catch (error) { return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}`, }; } } async function validateGoogleQueryKey( providerType: string, apiKey: string, baseUrl?: string ): Promise<{ valid: boolean; error?: string }> { const trimmedBaseUrl = baseUrl?.trim(); if (!trimmedBaseUrl) { return { valid: false, error: `Base URL is required for provider "${providerType}" validation` }; } const base = normalizeBaseUrl(trimmedBaseUrl); const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`; return await performProviderValidationRequest(providerType, url, {}); } async function validateAnthropicHeaderKey( providerType: string, apiKey: string, baseUrl?: string ): Promise<{ valid: boolean; error?: string }> { const base = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1'); const url = `${base}/models?limit=1`; const headers = { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', }; return await performProviderValidationRequest(providerType, url, headers); } /** * 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[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(); }); } // ── Mime type helpers ──────────────────────────────────────────── const EXT_MIME_MAP: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.avi': 'video/x-msvideo', '.mkv': 'video/x-matroska', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac', '.pdf': 'application/pdf', '.zip': 'application/zip', '.gz': 'application/gzip', '.tar': 'application/x-tar', '.7z': 'application/x-7z-compressed', '.rar': 'application/vnd.rar', '.json': 'application/json', '.xml': 'application/xml', '.csv': 'text/csv', '.txt': 'text/plain', '.md': 'text/markdown', '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.ts': 'text/typescript', '.py': 'text/x-python', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', }; function getMimeType(ext: string): string { return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream'; } function mimeToExt(mimeType: string): string { for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) { if (mime === mimeType) return ext; } return ''; } const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound'); /** * Generate a small preview data URL for image files. * Uses Electron nativeImage to resize large images for thumbnails. */ function generateImagePreview(filePath: string, mimeType: string): string | null { try { const img = nativeImage.createFromPath(filePath); if (img.isEmpty()) return null; const size = img.getSize(); // If image is large, resize for thumbnail if (size.width > 256 || size.height > 256) { const resized = img.resize({ width: 256, height: 256 }); return `data:image/png;base64,${resized.toPNG().toString('base64')}`; } // Small image — use original const buf = readFileSync(filePath); return `data:${mimeType};base64,${buf.toString('base64')}`; } catch { return null; } } /** * File staging IPC handlers * Stage files to ~/.openclaw/media/outbound/ for gateway access */ function registerFileHandlers(): void { // Stage files from real disk paths (used with dialog:open) ipcMain.handle('file:stage', async (_, filePaths: string[]) => { mkdirSync(OUTBOUND_DIR, { recursive: true }); const results = []; for (const filePath of filePaths) { const id = crypto.randomUUID(); const ext = extname(filePath); const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); copyFileSync(filePath, stagedPath); const stat = statSync(stagedPath); const mimeType = getMimeType(ext); const fileName = basename(filePath); // Generate preview for images let preview: string | null = null; if (mimeType.startsWith('image/')) { preview = generateImagePreview(stagedPath, mimeType); } results.push({ id, fileName, mimeType, fileSize: stat.size, stagedPath, preview }); } return results; }); // Stage file from buffer (used for clipboard paste / drag-drop) ipcMain.handle('file:stageBuffer', async (_, payload: { base64: string; fileName: string; mimeType: string; }) => { mkdirSync(OUTBOUND_DIR, { recursive: true }); const id = crypto.randomUUID(); const ext = extname(payload.fileName) || mimeToExt(payload.mimeType); const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); const buffer = Buffer.from(payload.base64, 'base64'); writeFileSync(stagedPath, buffer); const mimeType = payload.mimeType || getMimeType(ext); const fileSize = buffer.length; // Generate preview for images let preview: string | null = null; if (mimeType.startsWith('image/')) { preview = generateImagePreview(stagedPath, mimeType); } return { id, fileName: payload.fileName, mimeType, fileSize, stagedPath, preview }; }); // Load thumbnails for file paths on disk (used to restore previews in history) ipcMain.handle('media:getThumbnails', async (_, paths: Array<{ filePath: string; mimeType: string }>) => { const results: Record = {}; for (const { filePath, mimeType } of paths) { try { if (!existsSync(filePath)) { results[filePath] = { preview: null, fileSize: 0 }; continue; } const stat = statSync(filePath); let preview: string | null = null; if (mimeType.startsWith('image/')) { preview = generateImagePreview(filePath, mimeType); } results[filePath] = { preview, fileSize: stat.size }; } catch { results[filePath] = { preview: null, fileSize: 0 }; } } return results; }); }