1112 lines
33 KiB
TypeScript
1112 lines
33 KiB
TypeScript
/**
|
|
* IPC Handlers
|
|
* Registers all IPC handlers for main-renderer communication
|
|
*/
|
|
import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron';
|
|
import { GatewayManager } from '../gateway/manager';
|
|
import { ClawHubService, ClawHubSearchParams, ClawHubInstallParams, ClawHubUninstallParams } from '../gateway/clawhub';
|
|
import {
|
|
storeApiKey,
|
|
getApiKey,
|
|
deleteApiKey,
|
|
hasApiKey,
|
|
saveProvider,
|
|
getProvider,
|
|
|
|
deleteProvider,
|
|
setDefaultProvider,
|
|
getDefaultProvider,
|
|
getAllProvidersWithKeyInfo,
|
|
isEncryptionAvailable,
|
|
type ProviderConfig,
|
|
} from '../utils/secure-storage';
|
|
import { getOpenClawStatus } from '../utils/paths';
|
|
import { getSetting } from '../utils/store';
|
|
import { saveProviderKeyToOpenClaw, setOpenClawDefaultModel } from '../utils/openclaw-auth';
|
|
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';
|
|
|
|
/**
|
|
* 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();
|
|
|
|
// Skill config handlers (direct file access, no Gateway RPC)
|
|
registerSkillConfigHandlers();
|
|
|
|
// Cron task handlers (proxy to Gateway RPC)
|
|
registerCronHandlers(gatewayManager);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
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,
|
|
},
|
|
};
|
|
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) };
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 submodule status and channel configuration
|
|
*/
|
|
function registerOpenClawHandlers(): void {
|
|
|
|
// Get OpenClaw submodule status
|
|
ipcMain.handle('openclaw:status', () => {
|
|
return getOpenClawStatus();
|
|
});
|
|
|
|
// Check if OpenClaw is ready (submodule present and dependencies installed)
|
|
ipcMain.handle('openclaw:isReady', () => {
|
|
const status = getOpenClawStatus();
|
|
return status.submoduleExists && status.isInstalled;
|
|
});
|
|
|
|
// ==================== Channel Configuration Handlers ====================
|
|
|
|
// Save channel configuration
|
|
ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record<string, unknown>) => {
|
|
try {
|
|
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: [] };
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* Provider-related IPC handlers
|
|
*/
|
|
function registerProviderHandlers(): void {
|
|
// Check if encryption is available
|
|
ipcMain.handle('provider:encryptionAvailable', () => {
|
|
return isEncryptionAvailable();
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// Set the default model in OpenClaw config based on provider type
|
|
try {
|
|
setOpenClawDefaultModel(config.type);
|
|
} catch (err) {
|
|
console.warn('Failed to set OpenClaw default model:', err);
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Delete a provider
|
|
ipcMain.handle('provider:delete', async (_, providerId: string) => {
|
|
try {
|
|
await deleteProvider(providerId);
|
|
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) };
|
|
}
|
|
});
|
|
|
|
// Delete API key for a provider
|
|
ipcMain.handle('provider:deleteApiKey', async (_, providerId: string) => {
|
|
try {
|
|
await deleteApiKey(providerId);
|
|
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
|
|
ipcMain.handle('provider:setDefault', async (_, providerId: string) => {
|
|
try {
|
|
await setDefaultProvider(providerId);
|
|
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(`Validating API key for provider type: ${providerType}`);
|
|
return await validateApiKeyWithProvider(providerType, apiKey);
|
|
} catch (error) {
|
|
console.error('Validation error:', error);
|
|
return { valid: false, error: String(error) };
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validate API key by making a real chat completion API call to the provider
|
|
* This sends a minimal "hi" message to verify the key works
|
|
*/
|
|
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 'ollama':
|
|
// Ollama doesn't require API key validation
|
|
return { valid: true };
|
|
default:
|
|
// For custom providers, just check the key is not empty
|
|
return { valid: true };
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { valid: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse error message from API response
|
|
*/
|
|
function parseApiError(data: unknown): string {
|
|
if (!data || typeof data !== 'object') return 'Unknown error';
|
|
|
|
// Anthropic format: { error: { message: "..." } }
|
|
// OpenAI format: { error: { message: "..." } }
|
|
// Google format: { error: { message: "..." } }
|
|
const obj = data as { error?: { message?: string; type?: string }; message?: string };
|
|
|
|
if (obj.error?.message) return obj.error.message;
|
|
if (obj.message) return obj.message;
|
|
|
|
return 'Unknown error';
|
|
}
|
|
|
|
/**
|
|
* Validate Anthropic API key by making a minimal chat completion request
|
|
*/
|
|
async function validateAnthropicKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
|
try {
|
|
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': apiKey,
|
|
'anthropic-version': '2023-06-01',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'claude-3-haiku-20240307',
|
|
max_tokens: 1,
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
}),
|
|
});
|
|
|
|
const data = await response.json().catch(() => ({}));
|
|
|
|
if (response.ok) {
|
|
return { valid: true };
|
|
}
|
|
|
|
// Authentication error
|
|
if (response.status === 401) {
|
|
return { valid: false, error: 'Invalid API key' };
|
|
}
|
|
|
|
// Permission error (invalid key format, etc.)
|
|
if (response.status === 403) {
|
|
return { valid: false, error: parseApiError(data) };
|
|
}
|
|
|
|
// Rate limit or overloaded - key is valid but service is busy
|
|
if (response.status === 429 || response.status === 529) {
|
|
return { valid: true };
|
|
}
|
|
|
|
// Model not found or bad request but auth passed - key is valid
|
|
if (response.status === 400 || response.status === 404) {
|
|
const errorType = (data as { error?: { type?: string } })?.error?.type;
|
|
if (errorType === 'authentication_error' || errorType === 'invalid_api_key') {
|
|
return { valid: false, error: 'Invalid API key' };
|
|
}
|
|
// Other errors like invalid_request_error mean the key is valid
|
|
return { valid: true };
|
|
}
|
|
|
|
return { valid: false, error: parseApiError(data) || `API error: ${response.status}` };
|
|
} catch (error) {
|
|
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate OpenAI API key by making a minimal chat completion request
|
|
*/
|
|
async function validateOpenAIKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
|
try {
|
|
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'gpt-4o-mini',
|
|
max_tokens: 1,
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
}),
|
|
});
|
|
|
|
const data = await response.json().catch(() => ({}));
|
|
|
|
if (response.ok) {
|
|
return { valid: true };
|
|
}
|
|
|
|
// Authentication error
|
|
if (response.status === 401) {
|
|
return { valid: false, error: 'Invalid API key' };
|
|
}
|
|
|
|
// Rate limit - key is valid
|
|
if (response.status === 429) {
|
|
return { valid: true };
|
|
}
|
|
|
|
// Model not found or bad request but auth passed - key is valid
|
|
if (response.status === 400 || response.status === 404) {
|
|
const errorCode = (data as { error?: { code?: string } })?.error?.code;
|
|
if (errorCode === 'invalid_api_key') {
|
|
return { valid: false, error: 'Invalid API key' };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
return { valid: false, error: parseApiError(data) || `API error: ${response.status}` };
|
|
} catch (error) {
|
|
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate Google (Gemini) API key by making a minimal generate content request
|
|
*/
|
|
async function validateGoogleKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
|
try {
|
|
const response = await fetch(
|
|
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
contents: [{ parts: [{ text: 'hi' }] }],
|
|
generationConfig: { maxOutputTokens: 1 },
|
|
}),
|
|
}
|
|
);
|
|
|
|
const data = await response.json().catch(() => ({}));
|
|
|
|
if (response.ok) {
|
|
return { valid: true };
|
|
}
|
|
|
|
// Authentication error
|
|
if (response.status === 400 || response.status === 401 || response.status === 403) {
|
|
const errorStatus = (data as { error?: { status?: string } })?.error?.status;
|
|
if (errorStatus === 'UNAUTHENTICATED' || errorStatus === 'PERMISSION_DENIED') {
|
|
return { valid: false, error: 'Invalid API key' };
|
|
}
|
|
// Check if it's actually an auth error
|
|
const errorMessage = parseApiError(data).toLowerCase();
|
|
if (errorMessage.includes('api key') || errorMessage.includes('invalid') || errorMessage.includes('unauthorized')) {
|
|
return { valid: false, error: parseApiError(data) };
|
|
}
|
|
// Other errors mean key is valid
|
|
return { valid: true };
|
|
}
|
|
|
|
// Rate limit - key is valid
|
|
if (response.status === 429) {
|
|
return { valid: true };
|
|
}
|
|
|
|
return { valid: false, error: parseApiError(data) || `API error: ${response.status}` };
|
|
} catch (error) {
|
|
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate OpenRouter API key by making a minimal chat completion request
|
|
*/
|
|
async function validateOpenRouterKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
|
try {
|
|
// Use a popular free model for validation
|
|
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'HTTP-Referer': 'https://clawx.app',
|
|
'X-Title': 'ClawX',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'meta-llama/llama-3.2-3b-instruct:free',
|
|
max_tokens: 1,
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
}),
|
|
});
|
|
|
|
const data = await response.json().catch(() => ({}));
|
|
console.log('OpenRouter validation response:', response.status, JSON.stringify(data));
|
|
|
|
// Helper to check if error message indicates auth failure
|
|
const isAuthError = (d: unknown): boolean => {
|
|
const errorObj = (d as { error?: { message?: string; code?: number | string; type?: string } })?.error;
|
|
if (!errorObj) return false;
|
|
|
|
const message = (errorObj.message || '').toLowerCase();
|
|
const code = errorObj.code;
|
|
const type = (errorObj.type || '').toLowerCase();
|
|
|
|
// Check for explicit auth-related errors
|
|
if (code === 401 || code === '401' || code === 403 || code === '403') return true;
|
|
if (type.includes('auth') || type.includes('invalid')) return true;
|
|
if (message.includes('invalid api key') || message.includes('invalid key') ||
|
|
message.includes('unauthorized') || message.includes('authentication') ||
|
|
message.includes('invalid credentials') || message.includes('api key is not valid')) {
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (response.ok) {
|
|
return { valid: true };
|
|
}
|
|
|
|
// Always check for auth errors in the response body first
|
|
if (isAuthError(data)) {
|
|
// Return user-friendly message instead of raw API errors like "User not found."
|
|
return { valid: false, error: 'Invalid API key' };
|
|
}
|
|
|
|
// Authentication error status codes - always return user-friendly message
|
|
if (response.status === 401 || response.status === 403) {
|
|
return { valid: false, error: 'Invalid API key' };
|
|
}
|
|
|
|
// Rate limit - key is valid
|
|
if (response.status === 429) {
|
|
return { valid: true };
|
|
}
|
|
|
|
// Payment required or insufficient credits - key format is valid
|
|
if (response.status === 402) {
|
|
return { valid: true };
|
|
}
|
|
|
|
// For 400/404, we must be very careful - only consider valid if clearly not an auth issue
|
|
if (response.status === 400 || response.status === 404) {
|
|
// If we got here without detecting auth error, it might be a model issue
|
|
// But be conservative - require explicit success indication
|
|
const errorObj = (data as { error?: { message?: string; code?: number } })?.error;
|
|
const message = (errorObj?.message || '').toLowerCase();
|
|
|
|
// Only consider valid if the error is clearly about the model, not the key
|
|
if (message.includes('model') && !message.includes('key') && !message.includes('auth')) {
|
|
return { valid: true };
|
|
}
|
|
|
|
// Default to invalid for ambiguous 400/404 errors
|
|
return { valid: false, error: parseApiError(data) || 'Invalid API key or request' };
|
|
}
|
|
|
|
return { valid: false, error: parseApiError(data) || `API error: ${response.status}` };
|
|
} 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();
|
|
});
|
|
}
|