How the AFFiNE Electron Desktop Application Is Built and Integrated with Core Modules
The AFFiNE desktop application is an Electron wrapper that packages the same Vite-built React frontend used in browsers, using electron-forge for packaging and a typed RPC preload bridge to expose native OS capabilities while importing business logic directly from the monorepo's @affine/core modules.
AFFiNE’s desktop client is built on Electron to deliver native capabilities—such as auto-updates, protocol handling, and file-system access—while maintaining a single codebase with the web version. This article examines the build pipeline, the separation between main and renderer processes, and how core modules are integrated without duplication.
Architecture Overview
The Electron application follows a three-layer architecture that isolates privileged Node.js APIs from the renderer while allowing the React frontend to import core business logic directly.
Main Process – The Node.js runtime in src/main/index.ts creates the BrowserWindow, registers custom affine:// protocol handlers, manages the auto-updater, and spawns the preload script. It runs with full OS access but never executes UI code.
Preload Script – The isolated context in src/preload/electron-api.ts uses async-call-rpc to create a typed RPC bridge. It exposes a desktopAPI object on the global window that forwards calls such as clipboard.readText() or updater.check() to the main process, without granting the renderer direct Node.js access.
Renderer Process – The React application bootstrapped in src/preload/bootstrap.ts imports modules from @affine/core (e.g., WorkspaceEngine, SyncService) exactly as the web version does. It accesses native features only through the typed window.electron.desktopAPI RPC layer.
Build Pipeline and Configuration
AFFiNE uses electron-forge to package the desktop application, orchestrated through a monorepo workspace located at packages/frontend/apps/electron.
Package Definition – The package.json in this workspace declares the Electron entry point (src/main/index.ts), build scripts, and dependencies including electron-forge, electron-updater, and async-call-rpc.
Forge Configuration – The forge.config.mjs file configures makers for macOS (DMG), Windows (Squirrel/NSIS), and Linux (AppImage). It also references custom build steps such as make-squirrel.ts for Windows-specific packaging logic.
Layer Building – The scripts/build-layers.ts script bundles the Vite React frontend for production, outputting to ./dist/renderer, and copies these assets into the Electron resources folder. This ensures the main process can load the UI via loadFile in production.
Development Workflow – The scripts/dev.ts script launches the Vite development server and starts Electron with DEV_SERVER_URL=http://localhost:8080, allowing hot-reload of the renderer while maintaining the preload and main process context.
Main Process and Native Capabilities
The main process in src/main/index.ts initializes the Electron runtime and exposes native OS features to the renderer through IPC.
Window Management – The src/main/windows-manager/ directory contains logic for creating the primary BrowserWindow, handling multi-window scenarios, authentication pop-ups, and onboarding stages. It configures window options such as webPreferences with contextIsolation: true and preload pointing to the compiled preload script.
Protocol Handling – The main process registers the affine:// custom protocol (defined in package.json under build.protocols). When a user clicks an external AFFiNE link, the OS launches the desktop app, and the main process routes the URL to the appropriate workspace using the same core router logic as the web client.
Auto-Updater – Located in src/main/updater/, the updater uses electron-updater with a custom affine-update-provider.ts to fetch release metadata. The main process listens for update events and exposes methods such as checkForUpdates() and downloadAndInstall() to the renderer via the RPC bridge.
Preload Script and RPC Bridge
The preload script acts as a security boundary, exposing only whitelisted native APIs to the React frontend.
Typed RPC Setup – In src/preload/electron-api.ts, the script uses async-call-rpc to create a type-safe RPC client. It defines the DesktopAPI interface, which includes methods for clipboard access, shell operations, updater commands, and native theme queries.
API Exposure – The preload attaches the RPC client to window.electron.desktopAPI, allowing the renderer to call native methods without direct Node.js access. For example:
// Renderer code
const text = await window.electron.desktopAPI.clipboard.readText();
await window.electron.desktopAPI.shell.openExternal('https://affine.pro');
Bootstrap – The src/preload/bootstrap.ts file initializes the React application after confirming the desktop API is available. It imports the root component from @affine/core and mounts it to the DOM, effectively starting the same application code that runs in the browser.
Integrating Core Modules in the Renderer
The renderer process imports business logic directly from the monorepo's core packages, ensuring the desktop and web clients share identical functionality.
Direct Module Imports – The React frontend imports services from @affine/core using standard ES module syntax. For example, src/preload/bootstrap.ts and other renderer files import:
import { WorkspaceEngine } from '@affine/core/modules/workspace';
import { SyncService } from '@affine/core/modules/sync';
import { AuthService } from '@affine/core/modules/auth';
These imports resolve via the monorepo's TypeScript path mapping, ensuring the desktop uses the same source code as the web deployment.
Storage Abstraction – While the web version uses IndexedDB, the desktop app requires file-system persistence. The main process implements a JSON-file storage backend in src/main/shared-storage/json-file.ts that conforms to the same storage interface used by @affine/core. This allows the core storage module to operate on desktop without modification, simply by injecting the file-system adapter instead of the IndexedDB adapter.
Protocol Routing – When the desktop app handles an affine:// link, the main process extracts the workspace ID and page ID, then passes them to the renderer. The renderer uses the standard @affine/core router to navigate to the correct location, maintaining parity with how the web client handles URL routing.
Development and Production Workflows
Running the Desktop App in Development
To start the Electron app with hot-reload capabilities, use the development script located in the workspace:
# Install monorepo dependencies
yarn install
# Launch Vite dev server and Electron concurrently
yarn workspace @affine/electron dev
This executes scripts/dev.ts, which sets DEV_SERVER_URL=http://localhost:8080 and launches Electron. The renderer loads from the Vite server, enabling instant UI updates, while the main and preload processes run in their Node.js contexts.
Building a Production Release
To package the application for distribution:
# Build web assets and bundle into Electron resources
yarn workspace @affine/electron build
# Create platform-specific package (unpacked)
yarn workspace @affine/electron package
# Generate installers (DMG, NSIS, AppImage, etc.)
yarn workspace @affine/electron make
The build command runs scripts/build-layers.ts, which invokes Vite to compile the React frontend into dist/renderer and copies these static assets into the Electron bundle. The make command reads forge.config.mjs to determine which installer makers to run for the current platform.
Accessing Desktop-Only APIs from React
The renderer accesses native functionality through the typed RPC bridge:
import { useEffect } from 'react';
export function SystemIntegrationDemo() {
useEffect(() => {
async function checkClipboard() {
// Access clipboard through main process RPC
const text = await window.electron.desktopAPI.clipboard.readText();
console.log('System clipboard:', text);
}
checkClipboard();
}, []);
return <div>Desktop integration active</div>;
}
The desktopAPI object is injected by src/preload/electron-api.ts and provides methods for clipboard, shell operations, auto-updater, and native theme detection.
Summary
- Electron-forge orchestrates the build pipeline in
packages/frontend/apps/electron, usingforge.config.mjsto generate cross-platform installers. - Main process code in
src/main/index.tscreates the browser window, registers theaffine://protocol, and manages auto-updates viaelectron-updater. - Preload script at
src/preload/electron-api.tsimplements a type-safe RPC layer usingasync-call-rpc, exposingwindow.electron.desktopAPIto the renderer without compromising security. - Core module integration occurs through direct ES module imports from
@affine/corein the renderer bundle, with desktop-specific storage adapters (likesrc/main/shared-storage/json-file.ts) providing file-system persistence that conforms to the same interfaces used by the web client's IndexedDB implementation. - Development workflow uses
scripts/dev.tsto run a Vite dev server alongside Electron, while production builds usescripts/build-layers.tsto bundle the React app into the Electron resources folder.
Frequently Asked Questions
How does AFFiNE share code between its web and desktop applications?
AFFiNE uses a monorepo structure where business logic resides in packages/frontend/core. Both the web and Electron renderer import these modules directly using standard ES module syntax (e.g., import { WorkspaceEngine } from '@affine/core/modules/workspace'). The Electron app only adds a thin native layer via the preload script, ensuring the desktop and web clients run identical core logic while accessing different storage backends (file system vs. IndexedDB).
What build tool does AFFiNE use to package the Electron application?
The project uses electron-forge configured in packages/frontend/apps/electron/forge.config.mjs. This tool handles the entire packaging pipeline, including compiling the Vite-built React frontend, bundling native dependencies, and generating platform-specific installers such as DMG for macOS, NSIS for Windows, and AppImage for Linux. The make command reads this configuration to determine which makers to execute for the current platform.
How does the preload script secure native API access in AFFiNE's Electron app?
The preload script at src/preload/electron-api.ts runs in an isolated context with contextIsolation: true, creating a type-safe RPC bridge using async-call-rpc. Instead of exposing raw Node.js modules, it defines a DesktopAPI interface that whitelists specific methods like clipboard.readText() or updater.check(). These methods are attached to window.electron.desktopAPI, allowing the React renderer to invoke native functionality through inter-process communication without direct access to system resources, mitigating security risks associated with nodeIntegration.
Where is the storage implementation different between the desktop and web versions?
While the web client uses the browser's IndexedDB for persistence, the Electron desktop app implements a file-system backend in src/main/shared-storage/json-file.ts. This module conforms to the same storage interface used by @affine/core, allowing the core modules to remain platform-agnostic. When running on desktop, the application injects the JSON-file adapter instead of the IndexedDB adapter, enabling workspace data to persist on the local filesystem while using identical business logic for data management and synchronization.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →