615 lines
25 KiB
TypeScript
615 lines
25 KiB
TypeScript
/**
|
|
* IPC Handlers
|
|
* Registers the renderer channels used by the opencode-first desktop app.
|
|
*/
|
|
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron';
|
|
import type { OpencodeManager } from '../opencode/manager';
|
|
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 { createLearningCourseLibrary, LearningCourseLibraryError } from '../services/learning-course-library';
|
|
import {
|
|
assertLearningCoursePackageRegistered,
|
|
closeLearningPlayerServer,
|
|
evictLearningCoursePackagesForAccount,
|
|
getLearningPlayerServer,
|
|
} from '../services/learning-player-server';
|
|
import {
|
|
getWorksSquareAccountBinding,
|
|
isCurrentWorksSquareAccountBinding,
|
|
subscribeWorksSquareSession,
|
|
type WorksSquareAccountBinding,
|
|
} from '../services/works-square-session';
|
|
import { createLearningAgentClient, type LearningAgentRequest } from '../services/learning-agent-client';
|
|
import { createLearningSpeechClient, type LearningSpeechRequest } from '../services/learning-speech-client';
|
|
import { createLearningGenerationClient } from '../services/learning-generation-client';
|
|
import { createLearningRuntimeBridge } from '../services/learning-runtime-bridge';
|
|
import type { LearningGenerationUploadRequest, LearningRuntimeBridgeRequest } from '../../shared/learning';
|
|
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;
|
|
};
|
|
};
|
|
|
|
const LEARNING_COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
const LEARNING_MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/;
|
|
const LEARNING_SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
|
|
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}`);
|
|
}
|
|
|
|
async function withLearningBinding<T>(
|
|
operation: (
|
|
binding: WorksSquareAccountBinding,
|
|
assertCurrentAccount: () => void,
|
|
) => T | Promise<T>,
|
|
): Promise<T> {
|
|
const binding = getWorksSquareAccountBinding();
|
|
if (!binding) {
|
|
throw new LearningCourseLibraryError('LEARNING_AUTH_REQUIRED', '请先登录');
|
|
}
|
|
const assertCurrentAccount = () => {
|
|
if (!isCurrentWorksSquareAccountBinding(binding)) {
|
|
throw new LearningCourseLibraryError('LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重试');
|
|
}
|
|
};
|
|
assertCurrentAccount();
|
|
const result = await operation(binding, assertCurrentAccount);
|
|
assertCurrentAccount();
|
|
return result;
|
|
}
|
|
|
|
function assertLearningCoursePackageActive(
|
|
binding: WorksSquareAccountBinding,
|
|
courseId: string,
|
|
contentHash: string,
|
|
): void {
|
|
try {
|
|
assertLearningCoursePackageRegistered(binding.accountKey, courseId, contentHash);
|
|
} catch {
|
|
throw new LearningCourseLibraryError('LEARNING_COURSE_IDENTITY_INVALID', '课程身份无效');
|
|
}
|
|
}
|
|
|
|
function assertLearningCourseIdentity(
|
|
requested: { courseId: string; contentHash: string; moduleId?: string | null; moduleContentHash?: string },
|
|
authoritative: { courseId: string; courseContentHash: string; moduleId: string | null; moduleContentHash: string },
|
|
): void {
|
|
if (requested.courseId !== authoritative.courseId
|
|
|| requested.contentHash !== authoritative.courseContentHash
|
|
|| (requested.moduleId !== undefined && requested.moduleId !== authoritative.moduleId)
|
|
|| (requested.moduleContentHash !== undefined
|
|
&& requested.moduleContentHash !== authoritative.moduleContentHash)) {
|
|
throw new LearningCourseLibraryError('LEARNING_COURSE_IDENTITY_INVALID', '课程身份无效');
|
|
}
|
|
}
|
|
|
|
function learningIdentityError(): LearningCourseLibraryError {
|
|
return new LearningCourseLibraryError('LEARNING_COURSE_IDENTITY_INVALID', '课程身份无效');
|
|
}
|
|
|
|
function requireLearningAgentIdentity(request: unknown): {
|
|
courseId: string;
|
|
contentHash: string;
|
|
moduleId?: string | null;
|
|
moduleContentHash?: string;
|
|
} {
|
|
if (!request || typeof request !== 'object' || Array.isArray(request)) throw learningIdentityError();
|
|
const input = request as Record<string, unknown>;
|
|
if (typeof input.courseId !== 'string' || !LEARNING_COURSE_ID_PATTERN.test(input.courseId)
|
|
|| typeof input.contentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(input.contentHash)) {
|
|
throw learningIdentityError();
|
|
}
|
|
if (input.anchor === undefined) return { courseId: input.courseId, contentHash: input.contentHash };
|
|
if (!input.anchor || typeof input.anchor !== 'object' || Array.isArray(input.anchor)) throw learningIdentityError();
|
|
const anchor = input.anchor as Record<string, unknown>;
|
|
if (anchor.moduleId !== undefined && anchor.moduleId !== null
|
|
&& (typeof anchor.moduleId !== 'string' || !LEARNING_MODULE_ID_PATTERN.test(anchor.moduleId))) {
|
|
throw learningIdentityError();
|
|
}
|
|
if (anchor.moduleContentHash !== undefined
|
|
&& (typeof anchor.moduleContentHash !== 'string'
|
|
|| !LEARNING_SHA256_PATTERN.test(anchor.moduleContentHash))) {
|
|
throw learningIdentityError();
|
|
}
|
|
return {
|
|
courseId: input.courseId,
|
|
contentHash: input.contentHash,
|
|
...(anchor.moduleId === undefined ? {} : { moduleId: anchor.moduleId as string | null }),
|
|
...(anchor.moduleContentHash === undefined ? {} : { moduleContentHash: anchor.moduleContentHash as string }),
|
|
};
|
|
}
|
|
|
|
function requireLearningRuntimeIdentity(request: unknown): {
|
|
courseId: string;
|
|
contentHash: string;
|
|
moduleId: string | null;
|
|
moduleContentHash: string;
|
|
} {
|
|
if (!request || typeof request !== 'object' || Array.isArray(request)) throw learningIdentityError();
|
|
const input = request as Record<string, unknown>;
|
|
const context = input.context;
|
|
if (typeof input.courseId !== 'string' || !LEARNING_COURSE_ID_PATTERN.test(input.courseId)
|
|
|| typeof input.contentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(input.contentHash)
|
|
|| !context || typeof context !== 'object' || Array.isArray(context)) throw learningIdentityError();
|
|
const runtimeContext = context as Record<string, unknown>;
|
|
if ((runtimeContext.moduleId !== null
|
|
&& (typeof runtimeContext.moduleId !== 'string'
|
|
|| !LEARNING_MODULE_ID_PATTERN.test(runtimeContext.moduleId)))
|
|
|| typeof runtimeContext.moduleContentHash !== 'string'
|
|
|| !LEARNING_SHA256_PATTERN.test(runtimeContext.moduleContentHash)) throw learningIdentityError();
|
|
return {
|
|
courseId: input.courseId,
|
|
contentHash: input.contentHash,
|
|
moduleId: runtimeContext.moduleId as string | null,
|
|
moduleContentHash: runtimeContext.moduleContentHash,
|
|
};
|
|
}
|
|
|
|
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,
|
|
opencodeManager: OpencodeManager,
|
|
): 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 === 'opencode') {
|
|
if (action === 'status') return ok(id, opencodeManager.getStatus());
|
|
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(
|
|
_legacyRuntimeManager: unknown,
|
|
opencodeManager: OpencodeManager,
|
|
_legacyMarketplaceService: unknown,
|
|
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);
|
|
}
|
|
const learningCourseLibrary = createLearningCourseLibrary({ rootDirectory: app.getPath('userData') });
|
|
let learningAgentClient = createLearningAgentClient();
|
|
const learningSpeechClient = createLearningSpeechClient();
|
|
const learningGenerationClient = createLearningGenerationClient();
|
|
const learningRuntimeBridge = createLearningRuntimeBridge();
|
|
let learningPlayerClose = Promise.resolve();
|
|
let activeLearningBinding = getWorksSquareAccountBinding();
|
|
subscribeWorksSquareSession(() => {
|
|
const nextBinding = getWorksSquareAccountBinding();
|
|
const bindingChanged = activeLearningBinding?.accountKey !== nextBinding?.accountKey
|
|
|| activeLearningBinding?.epoch !== nextBinding?.epoch;
|
|
if (bindingChanged) {
|
|
if (activeLearningBinding) {
|
|
evictLearningCoursePackagesForAccount(activeLearningBinding.accountKey);
|
|
}
|
|
learningPlayerClose = closeLearningPlayerServer().catch(() => undefined);
|
|
learningAgentClient = createLearningAgentClient();
|
|
}
|
|
activeLearningBinding = nextBinding;
|
|
});
|
|
|
|
ipcMain.handle('learning:startGeneration', (_event, request: LearningGenerationUploadRequest) => (
|
|
withLearningBinding((_binding, assertCurrentAccount) => (
|
|
learningGenerationClient.start(request, assertCurrentAccount)
|
|
))
|
|
));
|
|
ipcMain.handle('learning:download', (_event, courseId: string) => (
|
|
withLearningBinding(() => learningCourseLibrary.download(courseId))
|
|
));
|
|
ipcMain.handle('learning:listInstalled', () => (
|
|
withLearningBinding(() => learningCourseLibrary.listInstalled())
|
|
));
|
|
ipcMain.handle('learning:readClassroom', (_event, courseId: string, moduleId?: string) => (
|
|
withLearningBinding(() => learningCourseLibrary.readClassroom(courseId, moduleId))
|
|
));
|
|
ipcMain.handle('learning:playerUrl', () => withLearningBinding(async (binding, assertCurrentAccount) => {
|
|
await learningPlayerClose;
|
|
assertCurrentAccount();
|
|
return (await getLearningPlayerServer(binding.accountKey)).url;
|
|
}));
|
|
ipcMain.handle('learning:closePlayer', () => closeLearningPlayerServer());
|
|
ipcMain.handle('learning:agentAsk', (_event, request: unknown) => withLearningBinding(
|
|
async (binding, assertCurrentAccount) => {
|
|
const identity = requireLearningAgentIdentity(request);
|
|
const agentRequest = request as LearningAgentRequest;
|
|
assertLearningCoursePackageActive(binding, identity.courseId, identity.contentHash);
|
|
const classroom = await learningCourseLibrary.resolveClassroom(identity.courseId, identity.moduleId ?? undefined);
|
|
assertCurrentAccount();
|
|
assertLearningCourseIdentity({
|
|
courseId: identity.courseId,
|
|
contentHash: identity.contentHash,
|
|
moduleId: identity.moduleId,
|
|
moduleContentHash: identity.moduleContentHash,
|
|
}, classroom);
|
|
return learningAgentClient.ask({
|
|
...agentRequest,
|
|
contentHash: classroom.courseContentHash,
|
|
anchor: {
|
|
...agentRequest.anchor,
|
|
moduleId: classroom.moduleId,
|
|
moduleContentHash: classroom.moduleContentHash,
|
|
},
|
|
}, assertCurrentAccount);
|
|
},
|
|
));
|
|
ipcMain.handle('learning:agentReset', (_event, courseId?: string, contentHash?: string) => withLearningBinding(
|
|
async (binding, assertCurrentAccount) => {
|
|
if (courseId === undefined && contentHash === undefined) {
|
|
return learningAgentClient.reset(undefined, undefined, assertCurrentAccount);
|
|
}
|
|
if (typeof courseId !== 'string' || !LEARNING_COURSE_ID_PATTERN.test(courseId)
|
|
|| typeof contentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(contentHash)) {
|
|
throw learningIdentityError();
|
|
}
|
|
assertLearningCoursePackageActive(binding, courseId, contentHash);
|
|
const classroom = await learningCourseLibrary.resolveClassroom(courseId);
|
|
assertCurrentAccount();
|
|
assertLearningCourseIdentity({
|
|
courseId,
|
|
contentHash,
|
|
}, classroom);
|
|
return learningAgentClient.reset(courseId, classroom.courseContentHash, assertCurrentAccount);
|
|
},
|
|
));
|
|
ipcMain.handle('learning:transcribe', (_event, request: LearningSpeechRequest) => (
|
|
withLearningBinding((_binding, assertCurrentAccount) => (
|
|
learningSpeechClient.transcribe(request, assertCurrentAccount)
|
|
))
|
|
));
|
|
ipcMain.handle('learning:runtimeRequest', (event, request: unknown) => withLearningBinding(
|
|
async (binding, assertCurrentAccount) => {
|
|
const identity = requireLearningRuntimeIdentity(request);
|
|
const runtimeRequest = request as LearningRuntimeBridgeRequest;
|
|
assertLearningCoursePackageActive(binding, identity.courseId, identity.contentHash);
|
|
const classroom = await learningCourseLibrary.resolveClassroom(
|
|
identity.courseId,
|
|
identity.moduleId ?? undefined,
|
|
);
|
|
assertCurrentAccount();
|
|
assertLearningCourseIdentity({
|
|
courseId: identity.courseId,
|
|
contentHash: identity.contentHash,
|
|
moduleId: identity.moduleId,
|
|
moduleContentHash: identity.moduleContentHash,
|
|
}, classroom);
|
|
await learningRuntimeBridge.request({
|
|
...runtimeRequest,
|
|
contentHash: classroom.courseContentHash,
|
|
context: {
|
|
...runtimeRequest.context,
|
|
moduleId: classroom.moduleId,
|
|
moduleContentHash: classroom.moduleContentHash,
|
|
},
|
|
}, (payload) => {
|
|
if (isCurrentWorksSquareAccountBinding(binding) && !event.sender.isDestroyed()) {
|
|
event.sender.send('learning:runtime-event', payload);
|
|
}
|
|
}, assertCurrentAccount);
|
|
},
|
|
));
|
|
|
|
ipcMain.handle('app:request', async (_event, request: UnifiedRequest) => (
|
|
handleUnifiedRequest(request, opencodeManager)
|
|
));
|
|
|
|
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('opencode:status', () => opencodeManager.getStatus());
|
|
ipcMain.handle('opencode:start', () => opencodeManager.start());
|
|
ipcMain.handle('opencode:stop', async () => {
|
|
await opencodeManager.stop();
|
|
return opencodeManager.getStatus();
|
|
});
|
|
ipcMain.handle('opencode:restart', () => opencodeManager.restart());
|
|
ipcMain.handle('opencode:health', () => opencodeManager.checkHealth());
|
|
|
|
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());
|
|
}
|