Files
trackgo-fe/apps/desktop/docs/IPC_ARCHITECTURE.md
T

10 KiB

IPC Architecture & Security Model

This document explains the security model of the Electron desktop wrapper, documents the existing IPC channels, and provides a step-by-step guide for extending the app with new native features.


Table of Contents


Security Model

The Electron desktop wrapper enforces a strict security boundary between the main process (Node.js) and the renderer process (web app). This is critical because the renderer runs untrusted web content that could be compromised by XSS, malicious dependencies, or supply chain attacks.

Core Principles

Setting Value Why
contextIsolation true The preload script runs in an isolated JavaScript context. The renderer cannot access Node.js APIs, require(), or the preload's scope.
nodeIntegration false Node.js APIs (fs, child_process, os, etc.) are completely unavailable in the renderer.
sandbox true The renderer process runs in a Chromium sandbox with restricted OS-level access.
webSecurity true Same-origin policy is enforced. Cross-origin requests follow standard browser rules.

What This Means in Practice

┌──────────────────────────────────────────────────────────────────┐
│                        Main Process                              │
│  Full Node.js access: filesystem, printers, native APIs,         │
│  auto-updater, child processes, network (unrestricted)           │
│                                                                  │
│  ipcMain.handle('channel', handler)                              │
├──────────────────────────────────────────────────────────────────┤
│                        Preload Script                            │
│  Isolated context. Can use ipcRenderer (send/invoke only).       │
│  Exposes a MINIMAL API surface via contextBridge.                │
│                                                                  │
│  contextBridge.exposeInMainWorld('electronAPI', { ... })          │
├──────────────────────────────────────────────────────────────────┤
│                     Renderer (React App)                         │
│  Standard browser environment. NO Node.js access.               │
│  Can ONLY call methods on window.electronAPI.                    │
│  Cannot access ipcRenderer, require, fs, etc.                   │
│                                                                  │
│  window.electronAPI.someMethod()                                 │
└──────────────────────────────────────────────────────────────────┘

The renderer communicates with the main process only through the API surface defined in the preload script. This API surface is deliberately narrow — each exposed method does exactly one thing.


The Three-Step Bridge Pattern

Every native feature follows the same three-step pattern:

Step 1: Register the Handler in the Main Process

File: apps/desktop/src/main/index.ts

// Use ipcMain.handle for request/response (returns a value)
ipcMain.handle('feature:action', async (_event, arg1, arg2) => {
  // Perform the native operation
  const result = await someNativeAPI(arg1, arg2);
  return result;
});

// Use ipcMain.on for fire-and-forget (no return value)
ipcMain.on('feature:fire', (_event, data) => {
  doSomething(data);
});

Naming convention: Use namespace:action format. Examples: printer:get-list, updater:check, fs:read-file.

Step 2: Expose via contextBridge in the Preload Script

File: apps/desktop/src/preload/index.ts

const electronAPI = {
  // For request/response channels
  featureAction: (arg1: string, arg2: number): Promise<ResultType> => {
    return ipcRenderer.invoke('feature:action', arg1, arg2);
  },

  // For fire-and-forget channels
  featureFire: (data: SomeType): void => {
    ipcRenderer.send('feature:fire', data);
  },

  // For main→renderer events (push notifications)
  onFeatureEvent: createEventSubscription<EventDataType>('feature:event'),
};

contextBridge.exposeInMainWorld('electronAPI', electronAPI);

Rules:

  • Never expose ipcRenderer directly.
  • Never expose ipcRenderer.on — use the createEventSubscription() helper that returns an unsubscribe function.
  • Always specify TypeScript types for function signatures.

Step 3: Update TypeScript Declarations in the React App

File: apps/web/src/types/electron.d.ts

interface ElectronAPI {
  // ... existing methods ...

  // New feature
  featureAction: (arg1: string, arg2: number) => Promise<ResultType>;
  featureFire: (data: SomeType) => void;
  onFeatureEvent: (callback: (data: EventDataType) => void) => () => void;
}

All three files must stay in sync. If you add a channel to the main process, you must expose it in the preload and declare it in the type file.


Existing IPC Channels

Printer Channels

Channel Direction Type Description
printer:get-list Renderer → Main → Renderer invoke / handle Returns ElectronPrinterInfo[] — list of all connected printers.
printer:print Renderer → Main → Renderer invoke / handle Triggers a print job with given options. Returns { success, failureReason? }.

Main process implementation: setupPrinterIPC() in src/main/index.ts

Preload exposure:

getPrinters: () => ipcRenderer.invoke('printer:get-list')
print: (options?) => ipcRenderer.invoke('printer:print', options)

React hook: useElectronPrinter() in apps/web/src/hooks/use-electron-printer.ts


Auto-Updater Channels

Channel Direction Type Description
updater:check Renderer → Main invoke / handle Triggers a manual update check. Returns the check result.
updater:install Renderer → Main send / on Quits the app and installs the downloaded update.
updater:checking Main → Renderer send Emitted when the updater starts checking.
updater:available Main → Renderer send Emitted when an update is found. Payload: UpdateInfo.
updater:not-available Main → Renderer send Emitted when the app is up to date. Payload: UpdateInfo.
updater:progress Main → Renderer send Emitted during download. Payload: ProgressInfo.
updater:downloaded Main → Renderer send Emitted when download completes. Payload: UpdateInfo.
updater:error Main → Renderer send Emitted on error. Payload: error message string.

Main process implementation: setupAutoUpdaterIPC() and setupAutoUpdaterEvents() in src/main/index.ts

Preload exposure: checkForUpdates(), installUpdate(), onUpdateAvailable(), onDownloadProgress(), onUpdateDownloaded(), onUpdateError(), onUpdateChecking(), onUpdateNotAvailable()

React hook: useElectronUpdater() in apps/web/src/hooks/use-electron-updater.ts


Adding a New Feature: Step-by-Step Example

Scenario: Add a method to read the app's version from the main process.

1. Main Process

In src/main/index.ts, add inside app.whenReady():

ipcMain.handle('app:get-version', () => {
  return app.getVersion();
});

2. Preload Script

In src/preload/index.ts, add to the electronAPI object:

const electronAPI = {
  // ... existing methods ...

  getAppVersion: (): Promise<string> => {
    return ipcRenderer.invoke('app:get-version');
  },
};

3. TypeScript Declarations

In apps/web/src/types/electron.d.ts, add to the ElectronAPI interface:

interface ElectronAPI {
  // ... existing methods ...

  getAppVersion: () => Promise<string>;
}

4. React Usage

function VersionDisplay() {
  const [version, setVersion] = useState('');

  useEffect(() => {
    if (window.electronAPI) {
      window.electronAPI.getAppVersion().then(setVersion);
    }
  }, []);

  if (!version) return null;
  return <span>v{version}</span>;
}

Anti-Patterns to Avoid

Never expose ipcRenderer directly

// BAD — gives the renderer unrestricted IPC access
contextBridge.exposeInMainWorld('ipc', ipcRenderer);

Never expose require or Node.js APIs

// BAD — allows arbitrary code execution from the renderer
contextBridge.exposeInMainWorld('require', require);

Never use nodeIntegration: true

// BAD — completely disables the security boundary
new BrowserWindow({
  webPreferences: { nodeIntegration: true, contextIsolation: false }
});

Never pass unsanitized IPC data to shell commands

// BAD — command injection vulnerability
ipcMain.handle('run-cmd', (_event, cmd: string) => {
  exec(cmd); // Attacker can run ANY command
});

Always validate IPC arguments in the main process

// GOOD — validate and constrain inputs
ipcMain.handle('file:read', async (_event, filename: string) => {
  // Validate: only allow specific filenames, no path separators
  if (filename.includes('/') || filename.includes('\\')) {
    throw new Error('Invalid filename');
  }
  const safePath = join(app.getPath('userData'), 'data', filename);
  return readFileSync(safePath, 'utf-8');
});

Always return unsubscribe functions for event listeners

// GOOD — prevents memory leaks in React's useEffect
onSomeEvent: createEventSubscription<DataType>('channel:event')

// In React:
useEffect(() => {
  const unsub = window.electronAPI.onSomeEvent((data) => { /* ... */ });
  return () => unsub(); // Cleanup on unmount
}, []);