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:

  • invoke creates a Promise-based request/response pattern expecting data return
  • send provides 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:

  1. Context Isolation: The renderer runs with contextIsolation: true, ensuring it cannot directly access Node.js APIs or the preload script's internal variables.

  2. Node Integration Disabled: With nodeIntegration: false, the renderer cannot require Node modules, preventing arbitrary code execution.

  3. Permission Validation: The main process validates arguments before performing operations, using helpers like normalizeVideoSourcePath and isTrustedProjectPath before touching the filesystem.

  4. Structured Error Handling: All IPC handlers catch exceptions and return { success: false, error: ... } objects rather than crashing the bridge, ensuring resilience across process boundaries.

  5. Session Permissions: session.defaultSession.setPermissionCheckHandler restricts 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:

  1. window.electronAPI.getSources invokes ipcRenderer.invoke('get-sources', opts) in the preload
  2. ipcMain.handle('get-sources', ...) executes in the main process, calling desktopCapturer.getSources
  3. 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.ts script uses contextBridge.exposeInMainWorld to create a limited window.electronAPI surface, preventing renderer access to arbitrary Node.js functionality.
  • Handler Registration: electron/ipc/handlers.ts registers all ipcMain.handle and ipcMain.on methods, centralizing privileged operations like desktopCapturer.getSources and file system access.
  • Async Patterns: The bridge supports both Promise-based invoke/handle patterns for data retrieval and send/on patterns for fire-and-forget commands.
  • Event Broadcasting: Main-to-renderer communication uses webContents.send paired with ipcRenderer.on listeners that expose cleanup functions for proper React lifecycle management.
  • Isolation Enforcement: contextIsolation: true and nodeIntegration: false ensure 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →