Files
makelore/electron/main/ipc-handlers.ts
brother7 5a275b93a7 feat: remove legacy OpenCode runtime
Cut product flows over to Coding/Pi and retain only the migration-owned v1 boundary. Promote supported native optional packages because electron-builder omitted pnpm transitive optional closure from the packaged ASAR.
2026-08-24 12:17:43 +08:00

345 lines
13 KiB
TypeScript

/**
* IPC Handlers
* Registers the renderer channels used by the desktop app.
*/
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron';
import { registerHostApiProxyHandlers } from './ipc/host-api-proxy';
import { registerTranscriptExportHandler } from './ipc/transcript-export';
import { applyProxySettings } from './proxy';
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../utils/store';
import { getProviderService } from '../services/providers/provider-service';
import type { ProviderConfig } from '../utils/secure-storage';
import type { ProviderAccount } from '../shared/providers/types';
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
import {
isAdminSessionUnlocked,
lockAdminSession,
verifyAdminPassword,
} from './admin-access';
import type { BackgroundLifecycleController, DesktopActivity } from './background-lifecycle';
import { collectPerformanceSnapshot } from './performance-diagnostics';
import type { HostApiContext } from '../api/context';
type UnifiedRequest = {
id?: string;
module?: string;
action?: string;
payload?: unknown;
};
type UnifiedResponse = {
id?: string;
ok: boolean;
data?: unknown;
error?: {
code?: string;
message?: string;
details?: unknown;
};
};
function ok(id: string | undefined, data: unknown): UnifiedResponse {
return { id, ok: true, data };
}
function fail(id: string | undefined, code: string, message: string, details?: unknown): UnifiedResponse {
return {
id,
ok: false,
error: { code, message, details },
};
}
function unsupported(id: string | undefined, module: string, action: string): UnifiedResponse {
return fail(id, 'UNSUPPORTED', `APP_REQUEST_UNSUPPORTED:${module}:${action}`);
}
function payloadArray(payload: unknown): unknown[] {
return Array.isArray(payload) ? payload : payload === undefined ? [] : [payload];
}
async function applySettingsPatch(patch: Partial<AppSettings>): Promise<void> {
for (const [key, value] of Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>) {
await setSetting(key, value);
}
if (
Object.prototype.hasOwnProperty.call(patch, 'proxyEnabled')
|| Object.prototype.hasOwnProperty.call(patch, 'proxyServer')
|| Object.prototype.hasOwnProperty.call(patch, 'proxyHttpServer')
|| Object.prototype.hasOwnProperty.call(patch, 'proxyHttpsServer')
|| Object.prototype.hasOwnProperty.call(patch, 'proxyAllServer')
|| Object.prototype.hasOwnProperty.call(patch, 'proxyBypassRules')
) {
await applyProxySettings(await getAllSettings());
}
if (Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup')) {
await syncLaunchAtStartupSettingFromStore();
}
}
function normalizeProviderValidationPayload(payload: unknown): {
providerId: string;
apiKey: string;
options?: { baseUrl?: string; apiProtocol?: string };
} {
const args = payloadArray(payload);
if (args.length > 1) {
return {
providerId: String(args[0] ?? ''),
apiKey: String(args[1] ?? ''),
options: args[2] as { baseUrl?: string; apiProtocol?: string } | undefined,
};
}
const record = (args[0] && typeof args[0] === 'object' ? args[0] : {}) as Record<string, unknown>;
return {
providerId: String(record.providerId ?? record.accountId ?? ''),
apiKey: String(record.apiKey ?? ''),
options: record.options as { baseUrl?: string; apiProtocol?: string } | undefined,
};
}
async function handleUnifiedProvider(action: string, payload: unknown): Promise<unknown> {
const providerService = getProviderService();
const args = payloadArray(payload);
if (action === 'list') return providerService._listProvidersWithKeyInfoInternal();
if (action === 'listVendors') return providerService.listVendors();
if (action === 'listAccounts') return providerService.listAccounts();
if (action === 'getDefault') return providerService._getDefaultProviderInternal();
if (action === 'get') return providerService._getProviderInternal(String(args[0] ?? ''));
if (action === 'hasApiKey') return providerService._hasProviderApiKeyInternal(String(args[0] ?? ''));
if (action === 'getApiKey') return providerService._getProviderApiKeyInternal(String(args[0] ?? ''));
if (action === 'save') {
const body = (args[0] && typeof args[0] === 'object' ? args[0] : {}) as { config?: ProviderConfig; apiKey?: string };
if (!body.config) throw new Error('Missing provider config');
await providerService._saveProviderInternal(body.config);
if (body.apiKey !== undefined && body.apiKey.trim()) {
await providerService._setProviderApiKeyInternal(body.config.id, body.apiKey.trim());
}
return { success: true };
}
if (action === 'delete') {
await providerService._deleteProviderInternal(String(args[0] ?? ''));
return { success: true };
}
if (action === 'setApiKey') {
await providerService._setProviderApiKeyInternal(String(args[0] ?? ''), String(args[1] ?? ''));
return { success: true };
}
if (action === 'updateWithKey') {
const providerId = String(args[0] ?? '');
const updates = (args[1] ?? {}) as Partial<ProviderConfig>;
const apiKey = typeof args[2] === 'string' ? args[2] : undefined;
const existing = await providerService._getProviderInternal(providerId);
if (!existing) throw new Error('Provider not found');
const nextConfig = { ...existing, ...updates, updatedAt: new Date().toISOString() };
await providerService._saveProviderInternal(nextConfig);
if (apiKey !== undefined) {
if (apiKey.trim()) {
await providerService._setProviderApiKeyInternal(providerId, apiKey.trim());
} else {
await providerService._deleteProviderApiKeyInternal(providerId);
}
}
return { success: true };
}
if (action === 'deleteApiKey') {
await providerService._deleteProviderApiKeyInternal(String(args[0] ?? ''));
return { success: true };
}
if (action === 'setDefault') {
await providerService._setDefaultProviderInternal(String(args[0] ?? ''));
return { success: true };
}
if (action === 'validateKey') {
const validation = normalizeProviderValidationPayload(payload);
const provider = await providerService._getProviderInternal(validation.providerId);
const providerType = provider?.type || validation.providerId;
return validateApiKeyWithProvider(providerType, validation.apiKey, {
baseUrl: validation.options?.baseUrl || provider?.baseUrl,
apiProtocol: validation.options?.apiProtocol || provider?.apiProtocol,
});
}
if (action === 'createAccount') {
const body = (args[0] && typeof args[0] === 'object' ? args[0] : {}) as { account?: ProviderAccount; apiKey?: string };
if (!body.account) throw new Error('Missing provider account');
return providerService.createAccount(body.account, body.apiKey);
}
throw new Error(`Unsupported provider action: ${action}`);
}
async function handleUnifiedRequest(
request: UnifiedRequest,
): Promise<UnifiedResponse> {
const id = request.id;
const module = request.module || '';
const action = request.action || '';
try {
if (module === 'app') {
if (action === 'version') return ok(id, app.getVersion());
if (action === 'name') return ok(id, app.getName());
if (action === 'platform') return ok(id, process.platform);
return unsupported(id, module, action);
}
if (module === 'settings') {
if (action === 'getAll') return ok(id, await getAllSettings());
if (action === 'get') {
const key = ((request.payload && typeof request.payload === 'object')
? (request.payload as { key?: keyof AppSettings }).key
: undefined) as keyof AppSettings | undefined;
return ok(id, key ? await getSetting(key) : undefined);
}
if (action === 'set') {
const body = (request.payload && typeof request.payload === 'object'
? request.payload
: {}) as { key?: keyof AppSettings; value?: AppSettings[keyof AppSettings] };
if (!body.key) throw new Error('Missing settings key');
await applySettingsPatch({ [body.key]: body.value } as Partial<AppSettings>);
return ok(id, { success: true });
}
if (action === 'setMany') {
await applySettingsPatch((request.payload ?? {}) as Partial<AppSettings>);
return ok(id, { success: true });
}
if (action === 'reset') {
await resetSettings();
return ok(id, { success: true, settings: await getAllSettings() });
}
return unsupported(id, module, action);
}
if (module === 'provider') {
return ok(id, await handleUnifiedProvider(action, request.payload));
}
return unsupported(id, module, action);
} catch (error) {
return fail(id, 'INTERNAL_ERROR', error instanceof Error ? error.message : String(error), error);
}
}
export function registerIpcHandlers(
mainWindow: BrowserWindow,
lifecycle?: BackgroundLifecycleController,
hostApiContext?: HostApiContext,
): void {
registerHostApiProxyHandlers(hostApiContext);
registerTranscriptExportHandler(mainWindow);
const rendererLeases = new Set<string>();
const releaseRendererLeases = (): void => {
if (rendererLeases.size === 0) return;
for (const id of rendererLeases) lifecycle?.releaseLease(id);
rendererLeases.clear();
};
const rendererWebContents = mainWindow.webContents;
if (rendererWebContents && typeof rendererWebContents.on === 'function') {
rendererWebContents.on('render-process-gone', releaseRendererLeases);
rendererWebContents.on('destroyed', releaseRendererLeases);
}
ipcMain.handle('app:request', async (_event, request: UnifiedRequest) => (
handleUnifiedRequest(request)
));
ipcMain.handle('app:version', () => app.getVersion());
ipcMain.handle('app:name', () => app.getName());
ipcMain.handle('app:platform', () => process.platform);
ipcMain.handle('app:getPath', (_event, name: Parameters<typeof app.getPath>[0]) => app.getPath(name));
ipcMain.handle('app:quit', () => app.quit());
ipcMain.handle('app:relaunch', () => {
app.relaunch();
app.exit(0);
});
ipcMain.handle('app:performance', () => collectPerformanceSnapshot(lifecycle));
ipcMain.handle('lifecycle:activity', (_event, activity: DesktopActivity) => {
const nextActivity = activity && typeof activity === 'object'
? activity
: { visible: true, module: null };
lifecycle?.setActivity(nextActivity);
if ((!nextActivity.visible || nextActivity.module !== 'painting')
&& !mainWindow.isDestroyed()
&& !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send('lifecycle:pause');
}
return lifecycle?.getActivity() ?? nextActivity;
});
ipcMain.handle('lifecycle:lease', (_event, input: { id: string; kind: string; active: boolean }) => {
if (!lifecycle) return { count: 0 };
const id = typeof input?.id === 'string' ? input.id.trim() : '';
if (!id) return { count: lifecycle.getLeaseCount() };
if (input?.active) {
lifecycle.acquireLease({
id,
kind: typeof input.kind === 'string' && input.kind.trim() ? input.kind.trim() : 'unknown',
});
rendererLeases.add(id);
} else {
lifecycle.releaseLease(id);
rendererLeases.delete(id);
}
return { count: lifecycle.getLeaseCount() };
});
ipcMain.handle('settings:getAll', () => getAllSettings());
ipcMain.handle('settings:get', (_event, payload: { key?: keyof AppSettings } | keyof AppSettings) => {
const key = typeof payload === 'string' ? payload : payload?.key;
return key ? getSetting(key) : undefined;
});
ipcMain.handle('settings:set', async (_event, payload: { key: keyof AppSettings; value: AppSettings[keyof AppSettings] }) => {
await applySettingsPatch({ [payload.key]: payload.value } as Partial<AppSettings>);
return { success: true };
});
ipcMain.handle('settings:setMany', async (_event, patch: Partial<AppSettings>) => {
await applySettingsPatch(patch);
return { success: true };
});
ipcMain.handle('settings:reset', async () => {
await resetSettings();
return { success: true, settings: await getAllSettings() };
});
ipcMain.handle('admin:verifyPassword', (_event, password: unknown) => ({
success: verifyAdminPassword(password),
}));
ipcMain.handle('admin:isUnlocked', () => isAdminSessionUnlocked());
ipcMain.handle('admin:lock', () => {
lockAdminSession();
return { success: true };
});
ipcMain.handle('shell:openExternal', (_event, url: string) => shell.openExternal(url));
ipcMain.handle('shell:showItemInFolder', (_event, path: string) => shell.showItemInFolder(path));
ipcMain.handle('shell:openPath', (_event, path: string) => shell.openPath(path));
ipcMain.handle('dialog:open', (_event, options: Electron.OpenDialogOptions) => dialog.showOpenDialog(mainWindow, options));
ipcMain.handle('dialog:save', (_event, options: Electron.SaveDialogOptions) => dialog.showSaveDialog(mainWindow, options));
ipcMain.handle('dialog:message', (_event, options: Electron.MessageBoxOptions) => dialog.showMessageBox(mainWindow, options));
ipcMain.handle('window:minimize', () => mainWindow.minimize());
ipcMain.handle('window:maximize', () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
return false;
}
mainWindow.maximize();
return true;
});
ipcMain.handle('window:close', () => mainWindow.close());
ipcMain.handle('window:isMaximized', () => mainWindow.isMaximized());
}