Electron

AFFiNE Electron Module Architecture Overview

The AFFiNE Electron module is structured to provide a robust, maintainable, and type-safe environment for desktop application functionality.

It leverages NestJS for organizing backend logic in both the main and helper processes, a custom-built IPC system for communication, and a dedicated helper process for offloading intensive tasks.

1. NestJS for Code Organization

Both the Electron main process and the separate helper process are built as NestJS applications. This provides several benefits:

  • Modularity: Code is organized into modules (e.g., AppModule, IpcModule), services, and controllers (though traditional controllers aren't typical in Electron main process logic, the concept of service-oriented architecture is key).

    • Most side effects should be placed within onModuleInit lifecycle hook, except the ones that are restrictly to be called before app ready.

    • Services will contain most of the business logic and bindings to Electron related APIs.

    • Methods and events in servies also can be decorated with IPCHandle and IPCEvent to expose native handlers & events for the renderer processes

  • Dependency Injection: NestJS's DI system simplifies managing dependencies between different parts of the application, making services and configurations easy to inject and use.

  • Scalability and Maintainability: The structured nature of NestJS makes the codebase easier to understand, scale, and maintain, especially as the application grows in complexity.

  • Standard Entry Points: Each process (main, helper) has a bootstrap.ts file to initialize the NestJS application and an app.module.ts as the root module. Since we do not need the network layer of NestJS, we will bootstrap the processes with NestFactory.createApplicationContext(AppModule).

2. Custom IPC System (Handlers, Events, Type Safety)

AFFiNE employs a custom IPC (Inter-Process Communication) system to facilitate communication between the main process, renderer process (UI), and the helper process. There are four parties in the IPC: main, helper, renderer(s), shared-worker.

  1. IPC Handlers (Request-Response):

Main/Helper process services can expose methods to the renderer process (browser windows or tabs) using the @IpcHandle({ scope: IPCScope.xxx, name?: 'methodName' }) decorator to decorate instance methods of any @Injectable.

  • These decorated methods become asynchronous APIs that the renderer can invoke.

  • An IpcScanner service discovers these handlers at startup.

  • The IpcMainInitializerService then uses ipcMain.handle to register these methods, making them available for invocation from the preload script.

  • Helper process uses a different approach to exchange data with renderers via MessagePorts

  • decorated IPC handlers has access to IpcMainInvokeEvent via AsyncLocalStorage by calling getIpcEvent.

  • IPC Events (broadcast from Main to Renderers):

    • Services in the main process can define event sources (typically RxJS Subjects or Observables) and decorate them with @IpcEvent({ scope: IPCScope.xxx, name?: 'eventName' }).

    • Renderer process can access the events by using callbacks like __events.scope.onXXXChange

    • The IpcMainInitializerService subscribes to these sources and broadcasts any emitted events to all renderer windows.

  • Type Checking and Generation:

    • A crucial feature is its build-time type generation for IPC calls, ensuring end-to-end type safety.

  • Scripts analyze @IpcHandle and @IpcEvent decorators (including method signatures, parameter types, and return types).

    • This process generates several TypeScript files by running yarn af electron generate-types:

      • ipc-api-types.gen.ts: Contains an ElectronApis interface, strongly typing all invokable methods.

      • ipc-event-types.gen.ts: Contains an ElectronEvents interface, strongly typing event subscription and callback payloads.

      • ipc-meta.gen.ts: Provides metadata about all registered handlers and events, used by the preload script.

  • Preload Script Bridge: The preload script utilizes this generated metadata and types to create a type-safe bridge using contextBridge.exposeInMainWorld('__apis', apiHandlers).

  • Renderer Process Usage: The renderer process can then access these APIs and events via __apis.scope.method() and __events.scope.onEventName(...) with full TypeScript autocompletion and type checking.

3. Multi tab system based onWebContentsView

In AFFiNE, each tab of the app lives in its own WebContentsView thus the web content contexts are isolated. A shell web contents will be rendered as well to reduce tab flickering when opening a new tab.

The TabViewsManager service manages the application's tab metadata (e.g., tab names, urls, active state, etc) and synced with the Workbench service in the renderer.

Each tab view will have their own initialization lifecycle, like loading preload script, listening to events from the main process. When calling native APIs, the handlers in the main process can get access to the caller's web contents via getIpcEvent function call.

4. Helper Process for Task Offloading

To prevent blocking the main process and keep the UI responsive, a dedicated helper process is used for computationally intensive tasks, .e.g., writing to native sqlite db (the nbstore modules).

  • Separate Node.js Context: The helper process is spawned using Electron's utilityProcess API, running in an independent Node.js environment.

  • NestJS Application: It's also a lightweight NestJS application, configured using ElectronIpcModule.forHelper(). This provides the IpcScanner for its own internal handlers but omits main-process-specific services like IpcMainInitializerService.

  • RPC-based Communication (Main to/from Helper):

    Since the helper process does not have direct access to Electron APIs (like dialog, shell), and the main process might need to invoke helper-specific functions, a RPC channel is established using the AsyncCall-RPC library.

    • Main to Helper: The main process can call functions exposed by the helper.

    • Helper to Main: The helper process can call Electron APIs that are explicitly exposed to it by the main process via RPC. For instance, the MainRpcService in the helper process sets up an RPC server listening on process.parentPort, allowing it to receive calls from the main process. Conversely, the HelperProcessService in the main process manages the helper's lifecycle and facilitates RPC calls to it.

    This bidirectional RPC ensures that tasks are delegated appropriately and that the helper can still interact with necessary Electron functionalities through a controlled interface.