mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 19:16:29 +08:00
refactor(electron): nestjsfy
This commit is contained in:
@@ -5,6 +5,7 @@ import { getBuildConfig } from '@affine-tools/utils/build-config';
|
||||
import { Package } from '@affine-tools/utils/workspace';
|
||||
import { sentryEsbuildPlugin } from '@sentry/esbuild-plugin';
|
||||
import type { BuildOptions, Plugin } from 'esbuild';
|
||||
import esbuildPluginTsc from 'esbuild-plugin-tsc';
|
||||
|
||||
export const electronDir = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
@@ -38,7 +39,12 @@ export const config = (): BuildOptions => {
|
||||
),
|
||||
};
|
||||
|
||||
const plugins: Plugin[] = [];
|
||||
const plugins: Plugin[] = [
|
||||
// ensures nestjs decorators are working (emitDecoratorMetadata not supported in esbuild)
|
||||
esbuildPluginTsc({
|
||||
tsconfigPath: resolve(electronDir, 'tsconfig.json'),
|
||||
}),
|
||||
];
|
||||
|
||||
if (
|
||||
process.env.SENTRY_AUTH_TOKEN &&
|
||||
@@ -78,16 +84,30 @@ export const config = (): BuildOptions => {
|
||||
|
||||
return {
|
||||
entryPoints: [
|
||||
resolve(electronDir, './src/main/index.ts'),
|
||||
resolve(electronDir, './src/preload/index.ts'),
|
||||
resolve(electronDir, './src/helper/index.ts'),
|
||||
resolve(electronDir, './src/entries/main/index.ts'),
|
||||
resolve(electronDir, './src/entries/preload/index.ts'),
|
||||
resolve(electronDir, './src/entries/helper/index.ts'),
|
||||
],
|
||||
entryNames: '[dir]',
|
||||
outdir: resolve(electronDir, './dist'),
|
||||
bundle: true,
|
||||
target: `node${NODE_MAJOR_VERSION}`,
|
||||
platform: 'node',
|
||||
external: ['electron', 'electron-updater', 'yjs', 'semver'],
|
||||
external: [
|
||||
'electron',
|
||||
'electron-updater',
|
||||
'yjs',
|
||||
'semver',
|
||||
// nestjs related:
|
||||
'@nestjs/platform-express',
|
||||
'@nestjs/microservices',
|
||||
'@nestjs/websockets/socket-module',
|
||||
'@apollo/subgraph',
|
||||
'@apollo/gateway',
|
||||
'ts-morph',
|
||||
'class-validator',
|
||||
'class-transformer',
|
||||
],
|
||||
format: 'cjs',
|
||||
loader: {
|
||||
'.node': 'copy',
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { Project } from 'ts-morph';
|
||||
|
||||
import { parseIpcEvents } from './ipc-generator/events-parser';
|
||||
import { parseIpcHandlers } from './ipc-generator/handlers-parser';
|
||||
import {
|
||||
type CollectedApisMap,
|
||||
type CollectedEventsMap,
|
||||
type OutputPaths,
|
||||
} from './ipc-generator/types';
|
||||
import {
|
||||
generateApiTypesFile,
|
||||
generateCombinedMetaFile,
|
||||
generateEventTypesFile,
|
||||
} from './ipc-generator/utils';
|
||||
|
||||
// ES module equivalent for __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const electronRoot = path.resolve(__dirname, '../');
|
||||
const rootDir = path.resolve(electronRoot, '..', '..', '..', '..');
|
||||
|
||||
// Configure output paths
|
||||
const paths: OutputPaths = {
|
||||
// Generate api types under @affine/electron-api
|
||||
apiTypes: path.resolve(
|
||||
rootDir,
|
||||
'packages/frontend/electron-api/src/ipc-api-types.gen.ts'
|
||||
),
|
||||
ipcMeta: path.resolve(electronRoot, 'src/entries/preload/ipc-meta.gen.ts'),
|
||||
// Event type definitions
|
||||
eventTypes: path.resolve(
|
||||
rootDir,
|
||||
'packages/frontend/electron-api/src/ipc-event-types.gen.ts'
|
||||
),
|
||||
};
|
||||
|
||||
function ensureDirectories(paths: OutputPaths): void {
|
||||
Object.values(paths)
|
||||
.filter(Boolean) // Skip empty paths
|
||||
.forEach(filePath => {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
} catch (e: any) {
|
||||
// Only warn about errors other than "directory already exists"
|
||||
if (e.code !== 'EEXIST') {
|
||||
console.warn(`[WARN] Could not create directory: ${e.message}`);
|
||||
throw new Error(
|
||||
`Failed to create directory for ${filePath}: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function writeGeneratedFiles(
|
||||
collectedApiHandlers: CollectedApisMap,
|
||||
collectedEvents: CollectedEventsMap,
|
||||
paths: OutputPaths
|
||||
): void {
|
||||
// Write API handler types file
|
||||
fs.writeFileSync(paths.apiTypes, generateApiTypesFile(collectedApiHandlers));
|
||||
console.log(`IPC API type definitions generated at: ${paths.apiTypes}`);
|
||||
|
||||
// Write event types file if events were found
|
||||
if (collectedEvents.size > 0) {
|
||||
fs.writeFileSync(paths.eventTypes, generateEventTypesFile(collectedEvents));
|
||||
console.log(`IPC Event type definitions generated at: ${paths.eventTypes}`);
|
||||
} else {
|
||||
console.log('No IPC Events found. Skipping event types generation.');
|
||||
}
|
||||
|
||||
// Write combined metadata file
|
||||
fs.writeFileSync(
|
||||
paths.ipcMeta,
|
||||
generateCombinedMetaFile(collectedApiHandlers, collectedEvents)
|
||||
);
|
||||
console.log(`IPC combined metadata generated at: ${paths.ipcMeta}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to generate IPC definitions
|
||||
*/
|
||||
function generateIpcDefinitions() {
|
||||
// Initialize ts-morph project
|
||||
const project = new Project({
|
||||
tsConfigFilePath: path.resolve(electronRoot, 'tsconfig.json'),
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
|
||||
// Add relevant source files
|
||||
project.addSourceFilesAtPaths([
|
||||
path.resolve(electronRoot, 'src/**/*.ts'),
|
||||
// Add other paths where IPC handlers might be defined
|
||||
]);
|
||||
|
||||
// Parse handlers and events from the source files
|
||||
const { apis: collectedApiHandlers } = parseIpcHandlers(project);
|
||||
|
||||
const { events: collectedEvents } = parseIpcEvents(project);
|
||||
|
||||
if (collectedApiHandlers.size === 0) {
|
||||
console.log(
|
||||
'No IPC handlers found. Generated files will be empty or contain minimal structure.'
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure directories exist
|
||||
ensureDirectories(paths);
|
||||
|
||||
// Write generated files
|
||||
writeGeneratedFiles(collectedApiHandlers, collectedEvents, paths);
|
||||
}
|
||||
|
||||
// Run the generator
|
||||
generateIpcDefinitions();
|
||||
@@ -0,0 +1,277 @@
|
||||
import { Node, Project, PropertyDeclaration } from 'ts-morph';
|
||||
|
||||
import { type CollectedEventsMap, type ParsedEventInfo } from './types';
|
||||
import { determineEntry } from './utils';
|
||||
|
||||
export const IpcEventDecoratorName = 'IpcEvent';
|
||||
|
||||
/**
|
||||
* Parses the @IpcEvent decorator and extracts relevant information
|
||||
*/
|
||||
export function parseIpcEventDecorator(
|
||||
propertyDeclaration: PropertyDeclaration
|
||||
): Omit<ParsedEventInfo, 'description'> | { error: string } {
|
||||
const decorator = propertyDeclaration
|
||||
.getDecorators()
|
||||
.find(d => d.getName() === IpcEventDecoratorName);
|
||||
if (!decorator) return { error: 'Decorator not found' };
|
||||
|
||||
const args = decorator.getArguments();
|
||||
const sourceFile = propertyDeclaration.getSourceFile();
|
||||
const propertyNameInCode = propertyDeclaration.getName();
|
||||
|
||||
if (args.length === 0) {
|
||||
return {
|
||||
error: `@${IpcEventDecoratorName} on ${propertyNameInCode} in ${sourceFile.getFilePath()} is missing arguments.`,
|
||||
};
|
||||
}
|
||||
const optionsArg = args[0];
|
||||
if (!Node.isObjectLiteralExpression(optionsArg)) {
|
||||
return {
|
||||
error: `@${IpcEventDecoratorName} on ${propertyNameInCode} in ${sourceFile.getFilePath()} requires an object argument.`,
|
||||
};
|
||||
}
|
||||
|
||||
let scopeValue: string | undefined;
|
||||
const scopeProperty = optionsArg.getProperty('scope');
|
||||
if (scopeProperty && Node.isPropertyAssignment(scopeProperty)) {
|
||||
const initializer = scopeProperty.getInitializer();
|
||||
if (initializer) {
|
||||
if (Node.isStringLiteral(initializer))
|
||||
scopeValue = initializer.getLiteralValue();
|
||||
else if (Node.isPropertyAccessExpression(initializer)) {
|
||||
const type = initializer.getType();
|
||||
if (type.isStringLiteral())
|
||||
scopeValue = type.getLiteralValue() as string;
|
||||
else scopeValue = initializer.getNameNode().getText();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!scopeValue)
|
||||
return {
|
||||
error: `@${IpcEventDecoratorName} on ${propertyNameInCode}: missing valid 'scope'.`,
|
||||
};
|
||||
|
||||
let declaredName: string | undefined;
|
||||
const nameProperty = optionsArg.getProperty('name');
|
||||
if (nameProperty && Node.isPropertyAssignment(nameProperty)) {
|
||||
const initializer = nameProperty.getInitializer();
|
||||
if (initializer && Node.isStringLiteral(initializer))
|
||||
declaredName = initializer.getLiteralValue();
|
||||
else if (initializer)
|
||||
return {
|
||||
error: `@${IpcEventDecoratorName} on ${propertyNameInCode}: 'name' must be a string literal.`,
|
||||
};
|
||||
}
|
||||
|
||||
const eventName = declaredName ?? propertyNameInCode.replace(/\$$/, '');
|
||||
const ipcChannel = `${scopeValue}:${eventName}`;
|
||||
|
||||
let payloadType = 'any[]'; // Default
|
||||
const propertyTypeNode = propertyDeclaration.getTypeNode();
|
||||
const propertyType = propertyDeclaration.getType(); // Get the full Type object
|
||||
|
||||
// Attempt 1: Regex on TypeNode text (faster, good for common cases)
|
||||
if (propertyTypeNode) {
|
||||
const typeNodeText = propertyTypeNode.getText();
|
||||
// Consolidated regex for known stream types including Observable
|
||||
const knownStreamTypesRegex =
|
||||
/(?:BehaviorSubject|ReplaySubject|Subject|Observable|EventEmitter)<([^>]+)>/;
|
||||
const typeMatch = typeNodeText.match(knownStreamTypesRegex);
|
||||
|
||||
if (typeMatch && typeMatch[1]) {
|
||||
payloadType = typeMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt 2: If regex failed or resulted in default, try more robust Type object inspection
|
||||
if (payloadType === 'any[]') {
|
||||
// Only if not found by Attempt 1 (explicit type annotation)
|
||||
const typesToInspect: import('ts-morph').Type[] = [propertyType];
|
||||
if (propertyType.isIntersection()) {
|
||||
typesToInspect.push(...propertyType.getIntersectionTypes());
|
||||
}
|
||||
|
||||
// Consider base types if the primary type itself is not a directly recognized Observable symbol
|
||||
// This helps with classes extending Observable<T>
|
||||
const primarySymbolName = propertyType.getSymbol()?.getName();
|
||||
const isPrimaryRecognizedObservable = [
|
||||
'Subject',
|
||||
'BehaviorSubject',
|
||||
'ReplaySubject',
|
||||
'EventEmitter',
|
||||
'Observable',
|
||||
].includes(primarySymbolName || '');
|
||||
|
||||
if (!isPrimaryRecognizedObservable && propertyType.isClassOrInterface()) {
|
||||
typesToInspect.push(...propertyType.getBaseTypes());
|
||||
}
|
||||
|
||||
for (const typeToInspect of typesToInspect) {
|
||||
// Ensure we are dealing with a type that can have generics and a symbol (class/interface)
|
||||
// isAnonymous handles cases within intersections that might not be directly isClassOrInterface
|
||||
if (
|
||||
!typeToInspect.isClassOrInterface() &&
|
||||
!typeToInspect.isAnonymous() &&
|
||||
!typeToInspect.isObject()
|
||||
)
|
||||
continue;
|
||||
|
||||
const typeName = typeToInspect.getSymbol()?.getName();
|
||||
if (
|
||||
typeName === 'Subject' ||
|
||||
typeName === 'BehaviorSubject' ||
|
||||
typeName === 'ReplaySubject' ||
|
||||
typeName === 'EventEmitter' ||
|
||||
typeName === 'Observable'
|
||||
) {
|
||||
const typeArguments = typeToInspect.getTypeArguments();
|
||||
if (typeArguments.length > 0) {
|
||||
const argText = typeArguments[0].getText(sourceFile).trim();
|
||||
// Prioritize more specific types over 'any' or 'unknown' if multiple paths yield a type.
|
||||
if (
|
||||
payloadType === 'any[]' ||
|
||||
((payloadType === 'any' || payloadType === 'unknown') &&
|
||||
argText !== 'any' &&
|
||||
argText !== 'unknown')
|
||||
) {
|
||||
payloadType = argText;
|
||||
}
|
||||
// If we found a concrete type (not any/unknown/any[]), we can stop searching.
|
||||
if (
|
||||
payloadType !== 'any' &&
|
||||
payloadType !== 'unknown' &&
|
||||
payloadType !== 'any[]'
|
||||
) {
|
||||
break; // Found a good type from typesToInspect loop
|
||||
}
|
||||
}
|
||||
// If typeArguments is empty here, it implies Observable (or Subject, etc.) without a generic.
|
||||
// The later fallback `if (payloadType === 'any[]') payloadType = 'any'` will handle this.
|
||||
}
|
||||
}
|
||||
// If, after checking all typesToInspect, payloadType is still 'any[]' or a non-specific type,
|
||||
// and we found a specific type from the primary propertyType earlier (even if it was 'any'),
|
||||
// we might need to ensure the most specific one found is kept.
|
||||
// However, the current logic of only updating if more specific should handle this.
|
||||
}
|
||||
|
||||
// Attempt 3: Look at initializer expression (e.g., new Subject<void>()) if still default
|
||||
if (payloadType === 'any[]') {
|
||||
const initializer = propertyDeclaration.getInitializer();
|
||||
if (initializer && Node.isNewExpression(initializer)) {
|
||||
const typeArgs = initializer.getType().getTypeArguments();
|
||||
if (typeArgs.length > 0) {
|
||||
payloadType = typeArgs[0].getText(sourceFile).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If still default any[] but we have detected Subject without generic, treat as any not array
|
||||
if (payloadType === 'any[]') {
|
||||
payloadType = 'any';
|
||||
}
|
||||
|
||||
// Final cleanup for void
|
||||
if (payloadType.toLowerCase() === 'void') {
|
||||
payloadType = ''; // Represent void as empty params for callback
|
||||
}
|
||||
|
||||
return {
|
||||
scope: scopeValue,
|
||||
eventName,
|
||||
ipcChannel,
|
||||
payloadType,
|
||||
originalPropertyName: propertyNameInCode,
|
||||
entry: determineEntry(sourceFile.getFilePath()),
|
||||
filePath: sourceFile.getFilePath(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses all IPC events in the project and collects their information
|
||||
*/
|
||||
export function parseIpcEvents(project: Project): {
|
||||
events: CollectedEventsMap;
|
||||
} {
|
||||
const collectedEvents: CollectedEventsMap = new Map();
|
||||
|
||||
project.getSourceFiles().forEach(sourceFile => {
|
||||
sourceFile.getClasses().forEach(classDeclaration => {
|
||||
classDeclaration.getProperties().forEach(propertyDeclaration => {
|
||||
const decorator = propertyDeclaration
|
||||
.getDecorators()
|
||||
.find(d => d.getName() === IpcEventDecoratorName);
|
||||
if (!decorator) return;
|
||||
|
||||
const parsedEventInfo = parseIpcEventDecorator(propertyDeclaration);
|
||||
if ('error' in parsedEventInfo) {
|
||||
if (parsedEventInfo.error !== 'Decorator not found')
|
||||
console.error(`[Event ERR] ${parsedEventInfo.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
scope,
|
||||
eventName,
|
||||
payloadType,
|
||||
entry,
|
||||
filePath,
|
||||
originalPropertyName,
|
||||
} = parsedEventInfo;
|
||||
|
||||
// derive modulePath and className here
|
||||
const absPath = filePath as string;
|
||||
const idx = absPath.lastIndexOf('/src/');
|
||||
let modulePath = absPath;
|
||||
if (idx !== -1) {
|
||||
modulePath =
|
||||
'@affine/electron/' + absPath.substring(idx + '/src/'.length);
|
||||
}
|
||||
modulePath = modulePath.replace(/\.[tj]sx?$/, '');
|
||||
|
||||
const clsName =
|
||||
propertyDeclaration.getParent()?.getName?.() || 'UnknownClass';
|
||||
const propName = originalPropertyName;
|
||||
const description = propertyDeclaration
|
||||
.getJsDocs()
|
||||
.map(doc => doc.getDescription().trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
if (!collectedEvents.has(scope)) collectedEvents.set(scope, []);
|
||||
const eventScopeMethods = collectedEvents.get(scope);
|
||||
if (eventScopeMethods) {
|
||||
const existingEvent = eventScopeMethods.find(
|
||||
event => event.eventName === eventName
|
||||
);
|
||||
if (existingEvent) {
|
||||
throw new Error(
|
||||
`[Event ERR] Duplicate event found for scope '${scope}' and eventName '${eventName}'.\n` +
|
||||
` Original: ${existingEvent.filePath} (${existingEvent.className}.${existingEvent.propertyName})\n` +
|
||||
` Duplicate: ${filePath as string} (${clsName}.${propName})`
|
||||
);
|
||||
}
|
||||
eventScopeMethods.push({
|
||||
eventName,
|
||||
payloadType,
|
||||
modulePath,
|
||||
className: clsName,
|
||||
propertyName: propName,
|
||||
description,
|
||||
entry,
|
||||
filePath: filePath as string,
|
||||
});
|
||||
} else {
|
||||
console.error(
|
||||
`[CRITICAL] Failed to retrieve event methods array for scope: ${scope}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
events: collectedEvents,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
MethodDeclaration,
|
||||
Node,
|
||||
Project,
|
||||
PropertyDeclaration,
|
||||
} from 'ts-morph';
|
||||
|
||||
import { type CollectedApisMap, type ParsedDecoratorInfo } from './types';
|
||||
import { determineEntry } from './utils';
|
||||
|
||||
export const IpcHandleDecoratorName = 'IpcHandle';
|
||||
|
||||
type IpcDecoratedMember = MethodDeclaration | PropertyDeclaration;
|
||||
|
||||
/**
|
||||
* Parses the @IpcHandle decorator and extracts relevant information
|
||||
*/
|
||||
function parseIpcHandleDecorator(
|
||||
memberDeclaration: IpcDecoratedMember
|
||||
): ParsedDecoratorInfo {
|
||||
const ipcHandleDecorator = memberDeclaration
|
||||
.getDecorators()
|
||||
.find(d => d.getName() === IpcHandleDecoratorName);
|
||||
|
||||
if (!ipcHandleDecorator) {
|
||||
return {}; // No decorator found
|
||||
}
|
||||
|
||||
const decoratorArgs = ipcHandleDecorator.getArguments();
|
||||
const sourceFile = memberDeclaration.getSourceFile();
|
||||
const methodNameInCode = memberDeclaration.getName(); // For error messages and fallback
|
||||
|
||||
if (decoratorArgs.length === 0) {
|
||||
return {
|
||||
error: `@${IpcHandleDecoratorName} on ${methodNameInCode} in ${sourceFile.getFilePath()} is missing arguments.`,
|
||||
};
|
||||
}
|
||||
|
||||
const optionsArg = decoratorArgs[0];
|
||||
|
||||
if (Node.isObjectLiteralExpression(optionsArg)) {
|
||||
const scopeProperty = optionsArg.getProperty('scope');
|
||||
let scopeValue: string | undefined;
|
||||
|
||||
if (scopeProperty && Node.isPropertyAssignment(scopeProperty)) {
|
||||
const scopeInitializer = scopeProperty.getInitializer();
|
||||
if (scopeInitializer) {
|
||||
if (Node.isStringLiteral(scopeInitializer)) {
|
||||
scopeValue = scopeInitializer.getLiteralValue();
|
||||
} else if (Node.isPropertyAccessExpression(scopeInitializer)) {
|
||||
const checker = scopeInitializer.getProject().getTypeChecker();
|
||||
const propertyAccessType = scopeInitializer.getType(); // Type of the e.g. IpcScope.MAIN expression
|
||||
|
||||
if (propertyAccessType.isStringLiteral()) {
|
||||
const literalValue = propertyAccessType.getLiteralValue();
|
||||
if (typeof literalValue === 'string') {
|
||||
scopeValue = literalValue;
|
||||
} else {
|
||||
// This case should be rare if isStringLiteral() is true
|
||||
return {
|
||||
error: `Scope for ${methodNameInCode} in ${sourceFile.getFilePath()} resolved to a string literal type, but its value is not a string: ${literalValue}. Please use a string enum or string literal for scope.`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// The type of the expression (e.g., IpcScope.MAIN) is not itself a string literal type.
|
||||
// This might happen if IpcScope is a numeric enum, or a more complex type.
|
||||
// Attempt to get the constant value of the expression.
|
||||
const constant = checker.compilerObject.getConstantValue(
|
||||
scopeInitializer.compilerNode
|
||||
);
|
||||
if (typeof constant === 'string') {
|
||||
scopeValue = constant;
|
||||
} else {
|
||||
let errorMessage = `Unable to resolve 'scope' for ${methodNameInCode} in ${sourceFile.getFilePath()} to a string constant. `;
|
||||
if (typeof constant === 'number') {
|
||||
errorMessage += `Resolved to a number (${constant}). Please use a string enum or string literal.`;
|
||||
} else if (constant === undefined) {
|
||||
errorMessage += `The expression does not resolve to a compile-time constant string. Ensure it's a direct string enum member (e.g., MyEnum.Value) or a string literal.`;
|
||||
} else {
|
||||
errorMessage += `Resolved to an unexpected type '${typeof constant}' with value '${constant}'. Please use a string enum or string literal.`;
|
||||
}
|
||||
return { error: errorMessage };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!scopeValue) {
|
||||
return {
|
||||
error: `@${IpcHandleDecoratorName} in ${methodNameInCode} in ${sourceFile.getFilePath()} is missing a valid 'scope'.`,
|
||||
};
|
||||
}
|
||||
|
||||
let nameValue: string | undefined;
|
||||
const nameProperty = optionsArg.getProperty('name');
|
||||
if (nameProperty && Node.isPropertyAssignment(nameProperty)) {
|
||||
const nameInitializer = nameProperty.getInitializer();
|
||||
if (nameInitializer && Node.isStringLiteral(nameInitializer)) {
|
||||
nameValue = nameInitializer.getLiteralValue();
|
||||
} else if (nameInitializer) {
|
||||
return {
|
||||
error: `@${IpcHandleDecoratorName} in ${methodNameInCode} in ${sourceFile.getFilePath()} has an invalid 'name' property. It must be a string literal.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
scope: scopeValue,
|
||||
apiMethodName: nameValue ?? methodNameInCode,
|
||||
entry: determineEntry(sourceFile.getFilePath()),
|
||||
filePath: sourceFile.getFilePath() as string,
|
||||
};
|
||||
} else if (Node.isStringLiteral(optionsArg)) {
|
||||
return {
|
||||
error: `@${IpcHandleDecoratorName} on ${methodNameInCode} in ${sourceFile.getFilePath()} uses legacy string literal. Please update to object format { scope: string, name?: string }.`,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: `@${IpcHandleDecoratorName} on ${methodNameInCode} in ${sourceFile.getFilePath()} has invalid arguments.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses all IPC handlers in the project and collects their information
|
||||
*/
|
||||
export function parseIpcHandlers(project: Project): {
|
||||
apis: CollectedApisMap;
|
||||
} {
|
||||
const collectedApiHandlers: CollectedApisMap = new Map();
|
||||
|
||||
project.getSourceFiles().forEach(sourceFile => {
|
||||
sourceFile.getClasses().forEach(classDeclaration => {
|
||||
// Iterate over both traditional methods and property declarations (which can
|
||||
// hold arrow-function handlers) so that handlers like
|
||||
// `handleWebContentsResize = () => {}` are also detected.
|
||||
const members: IpcDecoratedMember[] = [
|
||||
...classDeclaration.getMethods(),
|
||||
...classDeclaration.getProperties(),
|
||||
];
|
||||
|
||||
members.forEach(memberDeclaration => {
|
||||
const parsedHandlerInfo = parseIpcHandleDecorator(memberDeclaration);
|
||||
if (parsedHandlerInfo.error) {
|
||||
console.error(`[API Handler ERR] ${parsedHandlerInfo.error}`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!parsedHandlerInfo.scope ||
|
||||
!parsedHandlerInfo.apiMethodName ||
|
||||
!parsedHandlerInfo.entry ||
|
||||
!parsedHandlerInfo.filePath
|
||||
)
|
||||
return;
|
||||
|
||||
const { scope, apiMethodName, entry, filePath } = parsedHandlerInfo;
|
||||
const classDecl = memberDeclaration.getParent();
|
||||
const className = (classDecl as any)?.getName?.() || 'UnknownClass';
|
||||
|
||||
// Build module path for import specifier (strip up to /src/ and .ts extension)
|
||||
const absPath = sourceFile.getFilePath() as unknown as string;
|
||||
const srcIdx = absPath.lastIndexOf('/src/');
|
||||
let modulePath = absPath;
|
||||
if (srcIdx !== -1) {
|
||||
modulePath =
|
||||
'@affine/electron/' + absPath.substring(srcIdx + '/src/'.length);
|
||||
}
|
||||
modulePath = modulePath.replace(/\.[tj]sx?$/, '');
|
||||
|
||||
const description = memberDeclaration
|
||||
.getJsDocs()
|
||||
.map(doc => doc.getDescription().trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
if (!collectedApiHandlers.has(scope))
|
||||
collectedApiHandlers.set(scope, []);
|
||||
const handlerScopeMethods = collectedApiHandlers.get(scope);
|
||||
if (handlerScopeMethods) {
|
||||
handlerScopeMethods.push({
|
||||
apiMethodName,
|
||||
modulePath,
|
||||
className,
|
||||
methodName: memberDeclaration.getName(),
|
||||
description,
|
||||
entry,
|
||||
filePath: filePath as string,
|
||||
});
|
||||
} else {
|
||||
console.error(
|
||||
`[CRITICAL] Failed to retrieve handler methods array for scope: ${scope}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
apis: collectedApiHandlers,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Types for IPC generator
|
||||
*/
|
||||
|
||||
export interface ParsedDecoratorInfo {
|
||||
/** ipc scope */
|
||||
scope?: string;
|
||||
/** method name that will show up in the generated API */
|
||||
apiMethodName?: string;
|
||||
/** optional error message when decorator is invalid */
|
||||
error?: string;
|
||||
/** entry: main/helper */
|
||||
entry?: Entry;
|
||||
/** absolute path to the source file */
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export interface ParsedEventInfo {
|
||||
scope: string;
|
||||
/** event name without the scope prefix */
|
||||
eventName: string;
|
||||
/** full ipc channel (e.g. ui:maximized) */
|
||||
ipcChannel: string;
|
||||
/** payload type extracted from rxjs observable – kept for compatibility */
|
||||
payloadType: string;
|
||||
/** original property name in the class */
|
||||
originalPropertyName: string;
|
||||
/** entry of the source file */
|
||||
entry: Entry;
|
||||
description?: string;
|
||||
error?: string;
|
||||
/** absolute path to the source file */
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export type Entry = 'main' | 'helper';
|
||||
|
||||
export interface CollectedApiMethodInfo {
|
||||
apiMethodName: string;
|
||||
modulePath: string;
|
||||
className: string;
|
||||
methodName: string;
|
||||
description?: string;
|
||||
entry: Entry;
|
||||
filePath: string;
|
||||
}
|
||||
export type CollectedApisMap = Map<string, CollectedApiMethodInfo[]>;
|
||||
|
||||
export interface CollectedEventInfoForMeta {
|
||||
eventName: string;
|
||||
}
|
||||
export interface CollectedEventInfoForTypes extends CollectedEventInfoForMeta {
|
||||
payloadType: string;
|
||||
description?: string;
|
||||
entry: Entry;
|
||||
filePath: string;
|
||||
className: string; // Class containing the event property
|
||||
propertyName: string; // Original property name
|
||||
modulePath: string;
|
||||
}
|
||||
export type CollectedEventsMap = Map<string, CollectedEventInfoForTypes[]>; // Keyed by scope
|
||||
|
||||
export interface OutputPaths {
|
||||
apiTypes: string; // Type definitions for API handlers
|
||||
eventTypes: string; // Type definitions for events
|
||||
ipcMeta: string; // Combined metadata for both handlers and events
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
type CollectedApisMap,
|
||||
type CollectedEventsMap,
|
||||
type Entry,
|
||||
} from './types';
|
||||
|
||||
export const determineEntry = (path: string): Entry => {
|
||||
if (path.includes('src/entries/helper')) return 'helper';
|
||||
return 'main';
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the API type definitions file content
|
||||
*/
|
||||
export function generateApiTypesFile(collectedApis: CollectedApisMap): string {
|
||||
let content = `// AUTO-GENERATED FILE. DO NOT EDIT MANUALLY.\n// Generated by: packages/frontend/apps/electron/scripts/generate-types.ts\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n`;
|
||||
|
||||
content += `// Utility type: remove trailing IpcMainInvokeEvent param and ensure Promise return\n`;
|
||||
content += `type EnsurePromise<T> = T extends Promise<any> ? T : Promise<T>;\n`;
|
||||
content += `export type ApiMethod<T> = T extends (...args: infer P) => infer R ? (...args: P) => EnsurePromise<R> : never;\n\n`;
|
||||
|
||||
content += `export interface ElectronApis {\n`;
|
||||
|
||||
for (const [scope, methods] of collectedApis) {
|
||||
content += ` ${scope}: {\n`;
|
||||
for (const method of methods) {
|
||||
if (method.description && method.description.trim().length > 0) {
|
||||
const lines = method.description.split('\n').map(l => ` * ${l}`);
|
||||
content += ' /**\n';
|
||||
content += lines.join('\n') + '\n';
|
||||
content += ' */\n';
|
||||
}
|
||||
content += ` ${method.apiMethodName}: ApiMethod<import('${method.modulePath}').${method.className}['${method.methodName}']>;\n`;
|
||||
}
|
||||
content += ` };\n`;
|
||||
}
|
||||
|
||||
content += `}\n`;
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the event type definitions file content
|
||||
*/
|
||||
export function generateEventTypesFile(
|
||||
collectedEvents: CollectedEventsMap
|
||||
): string {
|
||||
let out = `// AUTO-GENERATED FILE. DO NOT EDIT MANUALLY.\n// Generated by: packages/frontend/apps/electron/scripts/generate-types.ts\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nimport type { Observable } from 'rxjs';\n\n`;
|
||||
|
||||
out += `type ToSubscribe<T extends Observable<unknown>> = T extends Observable<infer P> ? (callback: (payload: P) => void) => () => void : never;\n\n`;
|
||||
|
||||
out += `export interface ElectronEvents {\n`;
|
||||
for (const [scope, events] of collectedEvents) {
|
||||
out += ` ${scope}: {\n`;
|
||||
for (const evt of events) {
|
||||
const capName =
|
||||
evt.eventName.charAt(0).toUpperCase() + evt.eventName.slice(1);
|
||||
if (evt.description && evt.description.trim().length > 0) {
|
||||
const lines = evt.description.split('\n').map(l => ` * ${l}`);
|
||||
out += ' /**\n';
|
||||
out += lines.join('\n') + '\n';
|
||||
out += ' */\n';
|
||||
}
|
||||
out += ` on${capName}: ToSubscribe<import('${evt.modulePath}').${evt.className}['${evt.propertyName}']>;\n`;
|
||||
}
|
||||
out += ` };\n`;
|
||||
}
|
||||
out += `}\n`;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a combined metadata file for both IPC handlers and events
|
||||
*/
|
||||
export function generateCombinedMetaFile(
|
||||
apiHandlers: CollectedApisMap,
|
||||
events: CollectedEventsMap
|
||||
): string {
|
||||
let content = `// AUTO-GENERATED FILE. DO NOT EDIT MANUALLY.\n// Generated by: packages/frontend/apps/electron/scripts/generate-types.ts\n/* eslint-disable @typescript-eslint/no-explicit-any */\n`;
|
||||
|
||||
// Build nested structure: entry -> scope -> names[]
|
||||
const handlersMeta: Record<string, Record<string, string[]>> = {};
|
||||
apiHandlers.forEach((methods, scope) => {
|
||||
methods.forEach(m => {
|
||||
const ent = m.entry;
|
||||
if (!handlersMeta[ent]) handlersMeta[ent] = {};
|
||||
if (!handlersMeta[ent][scope]) handlersMeta[ent][scope] = [];
|
||||
handlersMeta[ent][scope].push(m.apiMethodName);
|
||||
});
|
||||
});
|
||||
|
||||
const eventsMeta: Record<string, Record<string, string[]>> = {};
|
||||
events.forEach((evtList, scope) => {
|
||||
evtList.forEach(e => {
|
||||
const ent = e.entry;
|
||||
if (!eventsMeta[ent]) eventsMeta[ent] = {};
|
||||
if (!eventsMeta[ent][scope]) eventsMeta[ent][scope] = [];
|
||||
eventsMeta[ent][scope].push(e.eventName);
|
||||
});
|
||||
});
|
||||
|
||||
// Serialize handlersMeta & eventsMeta
|
||||
content += `export const handlersMeta = ${JSON.stringify(
|
||||
handlersMeta,
|
||||
null,
|
||||
2
|
||||
)} as const;\n\n`;
|
||||
content += `export const eventsMeta = ${JSON.stringify(eventsMeta, null, 2)} as const;\n`;
|
||||
|
||||
return content;
|
||||
}
|
||||
Reference in New Issue
Block a user