How OpenScreen's Electron IPC Bridge Enables Secure Main-Renderer Communication
OpenScreen implements Electron's secure contextBridge pattern to expose a curated window.electronAPI interface, allowing the React renderer to safely invoke Node.js functionality in the main process through structured ipcRenderer.invoke and ipcMain.handle channels.
OpenScreen is an open-source screen recording application that leverages Electron's architecture to isolate privileged system operations from the user interface. The Electron IPC bridge serves as the critical communication layer between the Node.js-powered main process and the Chromium-based renderer, ensuring that sensitive APIs like file system access and screen capture remain secure while remaining accessible to React components.
The Preload Script: Building the Secure API Surface
The IPC bridge originates in electron/preload.ts, where the preload script uses contextBridge.exposeInMainWorld to create a controlled entry point for the renderer.
// electron/preload.ts
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("electronAPI", {
// fire-and-forget
hudOverlayHide: () => ipcRenderer.send("hud-overlay-hide"),
// request-response (async)
getSources: async (opts: Electron.SourcesOptions) =>
await ipcRenderer.invoke("get-sources", opts),
// listeners with cleanup
onStopRecordingFromTray: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("stop-recording-from-tray", listener);
return () => ipcRenderer.removeListener("stop-recording-from-tray", listener);
},
storeRecordedVideo: (buffer: ArrayBuffer, fileName: string) =>
ipcRenderer.invoke("store-recorded-video", buffer, fileName),
});
The preload script runs in an isolated context with full Node.js access, while the renderer operates with nodeIntegration: false. By explicitly exposing only specific methods via contextBridge, the Electron IPC bridge prevents the renderer from accessing arbitrary OS APIs, maintaining strict security boundaries.
Key distinction between methods:
invokecreates a Promise-based request/response pattern expecting data returnsendprovides fire-and-forget messaging for one-way commands
Main Process Handlers: Implementing Privileged Operations
The main process registers all IPC handlers in electron/ipc/handlers.ts, centralizing heavy-weight logic including file I/O, OS dialogs, and screen capture via desktopCapturer.
// electron/ipc/handlers.ts
export function registerIpcHandlers(
createEditorWindow,
createSourceSelectorWindow,
getMainWindow,
getSourceSelectorWindow,
onRecordingStateChange,
) {
// Request/response pattern
ipcMain.handle("get-sources", async (_, opts) => {
const sources = await desktopCapturer.getSources(opts);
return sources.map(s => ({
id: s.id,
name: s.name,
display_id: s.display_id,
thumbnail: s.thumbnail?.toDataURL() ?? null,
appIcon: s.appIcon?.toDataURL() ?? null,
}));
});
ipcMain.handle("select-source", (_, source) => {
selectedSource = source;
const win = getSourceSelectorWindow();
if (win) win.close();
return selectedSource;
});
// Fire-and-forget pattern
ipcMain.on("hud-overlay-hide", () => {
const win = getMainWindow();
if (win) win.hide();
});
}
These handlers are registered once during application startup in electron/main.ts:
// electron/main.ts
app.whenReady().then(async () => {
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
(recording, sourceName) => {
selectedSourceName = sourceName;
updateTrayMenu(recording);
if (!recording) showMainWindow();
},
);
});
The main handlers maintain module-level state (such as selectedSource and currentProjectPath) that persists across renderer reloads while remaining completely inaccessible to the renderer process directly.
Renderer Consumption: React Components Using the Bridge
Renderer components access the Electron IPC bridge through the typed global window.electronAPI object, without importing any Electron modules directly. The src/components/launch/SourceSelector.tsx component demonstrates the typical usage pattern:
// src/components/launch/SourceSelector.tsx
const fetchSources = async () => {
const rawSources = await window.electronAPI.getSources({
types: ["screen", "window"],
thumbnailSize: { width: 320, height: 180 },
});
setSources(rawSources);
};
const pickSource = async (source) => {
await window.electronAPI.selectSource(source);
// Main process closes the source-selector window
};
For events originating from the main process—such as tray menu interactions—components subscribe using the cleanup functions returned by the preload:
// Listening for main-process events
useEffect(() => {
const unsubscribe = window.electronAPI.onStopRecordingFromTray(() => {
stopRecording();
});
return unsubscribe; // Cleanup on unmount
}, []);
Security Architecture and Context Isolation
OpenScreen's Electron IPC bridge implements defense-in-depth through multiple security mechanisms:
-
Context Isolation: The renderer runs with
contextIsolation: true, ensuring it cannot directly access Node.js APIs or the preload script's internal variables. -
Node Integration Disabled: With
nodeIntegration: false, the renderer cannot require Node modules, preventing arbitrary code execution. -
Permission Validation: The main process validates arguments before performing operations, using helpers like
normalizeVideoSourcePathandisTrustedProjectPathbefore touching the filesystem. -
Structured Error Handling: All IPC handlers catch exceptions and return
{ success: false, error: ... }objects rather than crashing the bridge, ensuring resilience across process boundaries. -
Session Permissions:
session.defaultSession.setPermissionCheckHandlerrestricts media-related permissions, preventing privilege escalation from the renderer.
Practical Code Examples
Requesting Screen Sources (Renderer to Main)
This pattern demonstrates the async request/response flow for retrieving available screen capture sources:
// In a React component
async function loadScreenSources() {
const sources = await window.electronAPI.getSources({
types: ["screen"],
thumbnailSize: { width: 200, height: 150 },
});
console.log("Available screens:", sources);
}
Bridge flow:
window.electronAPI.getSourcesinvokesipcRenderer.invoke('get-sources', opts)in the preloadipcMain.handle('get-sources', ...)executes in the main process, callingdesktopCapturer.getSources- Mapped data returns through the Promise chain to the React component
Saving Recorded Video with Binary Data
function saveVideo(blob: Blob, fileName: string) {
blob.arrayBuffer().then(buf => {
window.electronAPI.storeRecordedVideo(buf, fileName).then(result => {
if (result.success) {
console.log("Saved to:", result.path);
}
});
});
}
The main handler receives the ArrayBuffer, writes it to RECORDINGS_DIR via Node.js fs APIs, and returns a status object containing the final path.
Tray-Initiated Commands (Main to Renderer)
When users interact with the system tray, the main process emits events to the renderer:
// Main process (electron/main.ts)
mainWindow.webContents.send('stop-recording-from-tray');
// Renderer receives via preload bridge
window.electronAPI.onStopRecordingFromTray(callback);
Summary
- Secure Exposure: The
electron/preload.tsscript usescontextBridge.exposeInMainWorldto create a limitedwindow.electronAPIsurface, preventing renderer access to arbitrary Node.js functionality. - Handler Registration:
electron/ipc/handlers.tsregisters allipcMain.handleandipcMain.onmethods, centralizing privileged operations likedesktopCapturer.getSourcesand file system access. - Async Patterns: The bridge supports both Promise-based
invoke/handlepatterns for data retrieval andsend/onpatterns for fire-and-forget commands. - Event Broadcasting: Main-to-renderer communication uses
webContents.sendpaired withipcRenderer.onlisteners that expose cleanup functions for proper React lifecycle management. - Isolation Enforcement:
contextIsolation: trueandnodeIntegration: falseensure that only explicitly exposed APIs cross the process boundary, with validation occurring in the main process before any system operations execute.
Frequently Asked Questions
How does OpenScreen prevent the renderer from accessing dangerous Node.js APIs?
OpenScreen disables nodeIntegration and enables contextIsolation in its BrowserWindow configuration. The Electron IPC bridge only exposes specific functions through contextBridge.exposeInMainWorld in electron/preload.ts, creating a whitelist of safe operations. The renderer can only call window.electronAPI methods and cannot require Node modules or access the file system directly.
What is the difference between ipcRenderer.invoke and ipcRenderer.send in OpenScreen?
ipcRenderer.invoke creates a Promise-based request/response pattern used for operations that return data, such as getSources which retrieves screen capture sources. ipcRenderer.send provides fire-and-forget messaging for one-way commands like hudOverlayHide where no response is expected. The main process handles invocations with ipcMain.handle and one-way messages with ipcMain.on.
How does OpenScreen handle cleanup of IPC event listeners?
The preload script in electron/preload.ts returns cleanup functions when registering listeners. For example, onStopRecordingFromTray returns a function that calls ipcRenderer.removeListener. React components in the renderer use these cleanup functions in useEffect return hooks to prevent memory leaks when components unmount.
Where are the IPC handlers registered in the OpenScreen codebase?
All IPC handlers are defined in electron/ipc/handlers.ts and registered through the registerIpcHandlers function. This function is called once during application initialization in electron/main.ts within the app.whenReady() promise, ensuring handlers are available before any renderer windows load.
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 →