The Bitwarden Desktop app is built on Electron, which uses a multi-process architecture. Understanding this architecture is critical for developing the desktop app correctly.
Electron Multi-Process Architecture
Electron applications run in two distinct process types:
Main Process
Location: /apps/desktop/src/main/
Entry Point: /apps/desktop/src/main.ts
The main process:
- Runs Node.js with full system access
- Has access to all Electron APIs
- Manages application lifecycle
- Creates and controls browser windows
- Handles native integrations (file system, OS APIs, etc.)
- Cannot import Angular or browser-only code
Key responsibilities:
export class Main {
windowMain: WindowMain; // Window management
messagingMain: MessagingMain; // IPC messaging
updaterMain: UpdaterMain; // Auto-updates
menuMain: MenuMain; // Application menu
trayMain: TrayMain; // System tray
powerMonitorMain: PowerMonitorMain; // Power events
nativeMessagingMain: NativeMessagingMain; // Browser extension communication
clipboardMain: ClipboardMain; // Clipboard operations
biometricsService: DesktopBiometricsService; // Biometric auth
sshAgentService: MainSshAgentService; // SSH agent
// ...
}
Renderer Process
Location: /apps/desktop/src/app/
Entry Point: /apps/desktop/src/app/main.ts (Angular bootstrap)
The renderer process:
- Runs Chromium (browser environment)
- Hosts the Angular application
- Has limited system access (sandboxed)
- Cannot directly import Node.js modules
- Cannot directly use Electron main process APIs
Environment: Standard web application with Angular, running in a Chromium-based browser window.
CRITICAL RULE: Never import Node.js modules directly in the renderer process!This will cause runtime errors because Node.js APIs are not available in the browser environment. Use preload scripts or IPC instead.
Preload Scripts
Location: /apps/desktop/src/preload.ts and feature-specific preload files
Preload scripts are the bridge between main and renderer processes. They:
- Run before the renderer process loads
- Have access to both Node.js and browser APIs
- Expose safe APIs to the renderer via
contextBridge
- Are the only way to safely expose Node.js functionality to the renderer
Main Preload Structure
// apps/desktop/src/preload.ts
import { contextBridge } from "electron";
import tools from "./app/tools/preload";
import auth from "./auth/preload";
import autofill from "./autofill/preload";
import keyManagement from "./key-management/preload";
import platform from "./platform/preload";
// Each team owns a subspace of the `ipc` global variable
export const ipc = {
auth,
autofill,
platform,
keyManagement,
tools,
};
// Expose to renderer as window.ipc
contextBridge.exposeInMainWorld("ipc", ipc);
Feature-Specific Preload Example
// apps/desktop/src/platform/preload.ts
import { ipcRenderer } from "electron";
const storage = {
get: <T>(key: string): Promise<T> =>
ipcRenderer.invoke("storageService", { action: "get", key }),
save: (key: string, obj: any): Promise<void> =>
ipcRenderer.invoke("storageService", { action: "save", key, obj }),
remove: (key: string): Promise<void> =>
ipcRenderer.invoke("storageService", { action: "remove", key }),
};
const passwords = {
get: (key: string, keySuffix: string): Promise<string> =>
ipcRenderer.invoke("keytar", { action: "getPassword", key, keySuffix }),
set: (key: string, keySuffix: string, value: string): Promise<void> =>
ipcRenderer.invoke("keytar", { action: "setPassword", key, keySuffix, value }),
// ...
};
export default {
storage,
passwords,
clipboard,
sshAgent,
powermonitor,
// ...
};
Inter-Process Communication (IPC)
Electron provides IPC mechanisms for communication between processes:
IPC Invoke (Request-Response)
Best for: Operations that return a value
Renderer → Main:
// Renderer (via preload)
const version = await ipcRenderer.invoke("appVersion");
// Main process handler
ipcMain.handle("appVersion", async () => {
return app.getVersion();
});
IPC Send (One-Way)
Best for: Fire-and-forget notifications
Renderer → Main:
// Renderer (via preload)
ipcRenderer.send("window-focus");
// Main process handler
ipcMain.on("window-focus", () => {
mainWindow.focus();
});
IPC Send from Main to Renderer
Main → Renderer:
// Main process
window.webContents.send("systemThemeUpdated", theme);
// Renderer (via preload listener)
ipcRenderer.on("systemThemeUpdated", (_event, theme: ThemeType) => {
callback(theme);
});
Context Isolation
Electron uses context isolation for security:
- Renderer process code runs in an isolated context
- Direct access to Electron/Node.js APIs is blocked
- Only APIs explicitly exposed via
contextBridge are available
// ❌ WRONG - Direct access blocked
import * as fs from "fs";
fs.readFileSync("/path/to/file");
// ✅ CORRECT - Use IPC through preload
const data = await window.ipc.platform.storage.get("key");
Never disable context isolation! This is a critical security feature. Always use contextBridge to expose APIs.
Service Architecture
Main Process Services
Services in the main process handle system-level operations:
// Window management
class WindowMain {
async init() {
this.createWindow();
this.setupWindowHandlers();
}
}
// Native messaging for browser extensions
class NativeMessagingMain {
listen() {
// Handle messages from browser extensions
}
}
// Biometric authentication
class MainBiometricsService {
async authenticateWithBiometric() {
// Call native Rust module
return await nativeModule.biometrics.prompt();
}
}
Renderer Process Services
Angular services in the renderer process:
@Injectable()
export class DesktopPlatformService {
// Use IPC to communicate with main process
async getVersion(): Promise<string> {
return window.ipc.platform.versions.app();
}
}
State Management
State is managed differently in each process:
Main Process State
export class Main {
storageService: ElectronStorageService;
memoryStorageService: MemoryStorageService;
environmentService: DefaultEnvironmentService;
// State providers for main process
}
Renderer Process State
- Angular services and dependency injection
- RxJS for reactive state
- Shared state providers from
@bitwarden/state-internal
Cross-Process State Synchronization
Use IPC to synchronize state:
// Renderer notifies main of state change
window.ipc.platform.sendMessage({ command: "logout" });
// Main process broadcasts to all renderer windows
BrowserWindow.getAllWindows().forEach(win => {
win.webContents.send("messagingService", { command: "logout" });
});
Native Module Integration
Rust native modules are loaded in the main process only:
// Main process - Direct import of N-API module
import { biometrics, passwords } from "@bitwarden/desktop-napi";
class MainBiometricsService {
async prompt(message: string): Promise<boolean> {
return await biometrics.prompt(Buffer.from([]), message);
}
}
// IPC handler exposes functionality to renderer
ipcMain.handle("biometric.prompt", async (event, message) => {
return mainBiometricsService.prompt(message);
});
See Native Modules for detailed information.
Security Considerations
Principle of Least Privilege
- Renderer process is sandboxed and has minimal privileges
- Main process has full system access
- Only expose necessary APIs through preload scripts
// Always validate IPC inputs in main process
ipcMain.handle("storage.get", async (event, key) => {
// Validate key before accessing storage
if (typeof key !== "string" || key.length === 0) {
throw new Error("Invalid key");
}
return storageService.get(key);
});
Secure IPC Channels
// Use namespaced channel names
const AUTOTYPE_IPC_CHANNELS = {
RUN_COMMAND: "autofill.runCommand",
LISTENER_READY: "autofill.listenerReady",
PASSKEY_REGISTRATION: "autofill.passkeyRegistration",
};
Process Lifecycle
Application Startup
- Main process starts (
main.ts)
- Services are initialized (storage, crypto, etc.)
- Window is created (
WindowMain)
- Preload script runs (before renderer)
- Renderer process starts (Angular app)
- Angular application bootstraps
- IPC communication established
Application Shutdown
- User triggers quit
- Main process emits
before-quit event
- Cleanup handlers run
- Windows are closed
- Main process exits
Process Communication Patterns
Pattern 1: Simple Request-Response
// Renderer
const version = await window.ipc.platform.versions.app();
// Preload
versions: {
app: (): Promise<string> => ipcRenderer.invoke("appVersion")
}
// Main
ipcMain.handle("appVersion", () => app.getVersion());
Pattern 2: Event Broadcasting
// Main broadcasts event
window.webContents.send("systemThemeUpdated", theme);
// Preload exposes listener
onSystemThemeUpdated: (callback) => {
ipcRenderer.on("systemThemeUpdated", (_event, theme) => callback(theme));
}
// Renderer subscribes
window.ipc.platform.onSystemThemeUpdated((theme) => {
this.updateTheme(theme);
});
Pattern 3: Bidirectional Communication
// Renderer sends command
window.ipc.platform.sendMessage({ command: "logout" });
// Main handles and broadcasts
ipcMain.on("messagingService", (event, message) => {
// Broadcast to all windows
BrowserWindow.getAllWindows().forEach(win => {
win.webContents.send("messagingService", message);
});
});
// All renderers receive via listener
window.ipc.platform.onMessage.addListener((message) => {
if (message.command === "logout") {
// Handle logout
}
});
Critical Development Rules
Main Process Context:
- ✅ Can import Node.js modules
- ✅ Can import Electron main process APIs
- ✅ Can import Rust N-API modules
- ❌ Cannot import Angular modules
- ❌ Cannot import browser-only code
Renderer Process Context:
- ✅ Can import Angular modules
- ✅ Can import browser APIs
- ✅ Can use
window.ipc exposed by preload
- ❌ Cannot import Node.js modules
- ❌ Cannot import Electron APIs directly
- ❌ Cannot import Rust N-API modules directly
Preload Script Context:
- ✅ Can import Electron’s
ipcRenderer and contextBridge
- ✅ Can import Node.js modules
- ✅ Bridge between main and renderer
- ❌ Should not import large libraries
- ❌ Cannot import Angular
Debugging
Main Process
# Launch with Chrome DevTools for main process
electron --inspect=5858 ./build
# Or use VSCode debugger with launch.json
Renderer Process
Use Chrome DevTools (automatically available in development):
// Open DevTools from main process
window.webContents.openDevTools();
IPC Messages
Log IPC messages for debugging:
ipcMain.on("*", (event, channel, ...args) => {
console.log("IPC:", channel, args);
});