# How IPC Communication Handles Requests Between Renderer and Main Process in Modly

> Learn how Modly's IPC communication manages renderer and main process requests using contextBridge, ipcRenderer, and ipcMain for seamless app interaction. Explore async RPC and one-way messaging.

- Repository: [lightningpixel/modly](https://github.com/lightningpixel/modly)
- Tags: internals
- Published: 2026-08-15

---

**Modly uses Electron's contextBridge and ipcRenderer to expose a type-safe `window.electron` API in the preload script, with main-process handlers registered via `ipcMain.handle` for async RPC and `ipcMain.on` for one-way messages.**

The **Inter-Process Communication (IPC)** architecture in Modly follows Electron's security best practices by isolating the renderer process from direct Node.js access. Instead, all cross-process requests flow through a tightly controlled preload layer that exposes only whitelisted functionality to the web-based UI.

## The Preload Script: Creating the Trusted API Surface

Modly's preload script at [`electron/preload/electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts) constructs a secure bridge between the Chromium-based renderer and the Node.js main process. This file uses Electron's `contextBridge` module to inject a global `window.electron` object that the renderer can safely access.

The preload API wraps two core Electron IPC methods:

- **`ipcRenderer.send(channel, ...args)`** — Fire-and-forget messages for actions that don't need a response
- **`ipcRenderer.invoke(channel, ...args)`** — Promise-based request/response RPC for operations that return data

This design prevents the renderer from directly importing `ipcRenderer` or any Node modules, eliminating a common attack surface in Electron applications.

## Main Process Handlers: `ipcMain.handle` vs `ipcMain.on`

Handler registration happens in [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts). Modly distinguishes between two communication patterns:

### One-Way Messages (`ipcMain.on`)

Used for actions where the renderer doesn't need confirmation or return values. Window controls are the canonical example:

```typescript
// electron/main/ipc-handlers.ts
ipcMain.on('window:minimize', () => getWindow()?.minimize());
ipcMain.on('window:maximize', () => {
  const win = getWindow();
  if (win) win.isMaximized() ? win.restore() : win.maximize();
});
ipcMain.on('window:close', () => getWindow()?.close());

```

The corresponding preload exposure uses `send()`:

```typescript
// electron/preload/electron-api.ts
window.electron = {
  window: {
    minimize: () => ipcRenderer.send('window:minimize'),
    maximize: () => ipcRenderer.send('window:maximize'),
    close: () => ipcRenderer.send('window:close'),
    // ...
  }
};

```

### Request-Response RPC (`ipcMain.handle`)

Used when the renderer needs data back from the main process. File dialogs, model queries, and system information all use this pattern:

```typescript
// electron/main/ipc-handlers.ts
ipcMain.handle('fs:selectImage', async () => {
  const win = getWindow();
  if (!win) return null;
  const result = await dialog.showOpenDialog(win, {
    title: 'Select an image',
    filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp'] }],
    properties: ['openFile'],
  });
  return result.canceled ? null : result.filePaths[0];
});

ipcMain.handle('window:isMaximized', () => {
  return getWindow()?.isMaximized() ?? false;
});

```

The preload wraps these with `invoke()`:

```typescript
// electron/preload/electron-api.ts
window.electron = {
  window: {
    isMaximized: () => ipcRenderer.invoke('window:isMaximized') as Promise<boolean>,
  },
  fs: {
    selectImage: () => ipcRenderer.invoke('fs:selectImage') as Promise<string | null>,
  }
};

```

## Complete Message Flow

Understanding how an IPC request travels through Modly's architecture:

```

Renderer (UI thread)
    │
    ▼
Calls window.electron.fs.selectImage()
    │
    ▼
Preload script (electron/preload/electron-api.ts)
    │
    ▼
ipcRenderer.invoke('fs:selectImage')
    │
    ▼
Electron internal bridge
    │
    ▼
Main process (electron/main/ipc-handlers.ts)
    │
    ▼
ipcMain.handle('fs:selectImage') handler executes
    │
    ▼
Native dialog.showOpenDialog() → filesystem access
    │
    ▼
Return value serializes across IPC boundary
    │
    ▼
Promise resolves in renderer with file path (or null)

```

This flow executes entirely asynchronously, preventing the renderer from blocking while the main process performs privileged operations like filesystem access or spawning Python processes.

## Renderer-Side Usage Patterns

The exposed `window.electron` API provides ergonomic methods that hide the underlying channel names:

```typescript
// Window state management
await window.electron.window.minimize();
const isMaxed: boolean = await window.electron.window.isMaximized();

// File operations with full dialog support
const imagePath: string | null = await window.electron.fs.selectImage();
const meshPath: string | null = await window.electron.fs.selectMeshFile();

// Python bridge initialization
const { success, port } = await window.electron.python.start();
if (success) {
  console.log(`Python server available on port ${port}`);
}

// Extension management
const extensions = await window.electron.extensions.list();
await window.electron.extensions.installFromGitHub('author/repo-name');

```

All methods return promises for async handlers and void for fire-and-forget operations, with TypeScript definitions ensuring compile-time safety.

## Security Architecture

Modly's IPC design implements defense in depth:

- **Channel whitelisting** — Only channels explicitly registered in [`ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/ipc-handlers.ts) can receive messages
- **Context isolation** — The preload runs in an isolated context with no access to renderer globals
- **No Node exposure** — `contextBridge` explicitly prevents leaking `require()` or Node APIs
- **Path validation** — Handlers like those for extensions use [`electron/main/extension-path-guard.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/extension-path-guard.ts) to prevent directory traversal

According to the Modly source code, the preload entry at [`electron/preload/index.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/index.ts) loads the API definition and immediately clears any accidental global exposures.

## Key Channel Categories

The IPC surface organizes functionality into semantic groups:

| Category | Example Channels | Handler Location |
|----------|---------------|------------------|
| Window controls | `window:minimize`, `window:maximize`, `window:close`, `window:isMaximized` | [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts) |
| File system | `fs:selectImage`, `fs:selectMeshFile`, `fs:saveModel`, `fs:listDir` | [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts) |
| Python bridge | `python:start`, `python:status` | [`electron/main/python-bridge.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/python-bridge.ts) |
| Model management | `model:download`, `model:delete`, `model:export` | [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts) |
| Extensions | `extensions:list`, `extensions:installFromGitHub`, `extensions:uninstall` | [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts) |
| System info | `app:info`, `system:memory` | [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts) |

## Summary

- **Preload script** ([`electron/preload/electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts)) creates a type-safe `window.electron` API using `contextBridge`
- **`ipcRenderer.invoke()`** handles request-response patterns; **`ipcRenderer.send()`** handles one-way messages
- **Main process handlers** register via `ipcMain.handle()` for async RPC and `ipcMain.on()` for fire-and-forget
- **Security** is enforced through channel whitelisting, context isolation, and path validation guards
- **All renderer access** to filesystem, dialogs, Python bridge, and window management flows through this controlled IPC layer

## Frequently Asked Questions

### What is the difference between `ipcRenderer.send` and `ipcRenderer.invoke` in Modly?

`ipcRenderer.send` is fire-and-forget: the renderer emits a message and doesn't wait for a response, used for window controls like minimize or maximize. `ipcRenderer.invoke` returns a Promise and enables request-response patterns, required when the renderer needs data back such as a selected file path or Python server port. Modly's preload API abstracts this distinction—methods like `window.electron.window.minimize()` use `send` internally while `window.electron.fs.selectImage()` uses `invoke`.

### Why does Modly use a preload script instead of enabling `nodeIntegration`?

The preload script at [`electron/preload/electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts) follows Electron's security recommendations by using `contextBridge` to expose only whitelisted functionality. Enabling `nodeIntegration` would give the renderer direct access to Node.js APIs and the filesystem, creating severe security risks if the renderer loads untrusted content. Modly's approach isolates privileged operations to the main process while keeping the UI layer sandboxed.

### How does Modly prevent unauthorized IPC channel access?

Only channels registered in [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts) can receive messages. The preload script does not expose `ipcRenderer` directly—instead, it wraps specific channel names in typed methods. Attempting to call `ipcRenderer.invoke('unknown:channel')` from the renderer would fail because the preload never exposes the raw `ipcRenderer` object, and the main process has no handler registered for that channel.

### Where are Python bridge IPC handlers implemented?

The `python:start` and `python:status` channels are handled in [`electron/main/python-bridge.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/python-bridge.ts), not the main [`ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/ipc-handlers.ts) file. This modular approach keeps the Python-specific spawn logic and port management separate from generic window and filesystem handlers, while still registering through the standard `ipcMain.handle` pattern.