# 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](#security-model) - [The Three-Step Bridge Pattern](#the-three-step-bridge-pattern) - [Existing IPC Channels](#existing-ipc-channels) - [Adding a New Feature: Step-by-Step Example](#adding-a-new-feature-step-by-step-example) - [Anti-Patterns to Avoid](#anti-patterns-to-avoid) --- ## 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` ```typescript // 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` ```typescript const electronAPI = { // For request/response channels featureAction: (arg1: string, arg2: number): Promise => { 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('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` ```typescript interface ElectronAPI { // ... existing methods ... // New feature featureAction: (arg1: string, arg2: number) => Promise; 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**: ```typescript 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()`: ```typescript ipcMain.handle('app:get-version', () => { return app.getVersion(); }); ``` ### 2. Preload Script In `src/preload/index.ts`, add to the `electronAPI` object: ```typescript const electronAPI = { // ... existing methods ... getAppVersion: (): Promise => { return ipcRenderer.invoke('app:get-version'); }, }; ``` ### 3. TypeScript Declarations In `apps/web/src/types/electron.d.ts`, add to the `ElectronAPI` interface: ```typescript interface ElectronAPI { // ... existing methods ... getAppVersion: () => Promise; } ``` ### 4. React Usage ```tsx function VersionDisplay() { const [version, setVersion] = useState(''); useEffect(() => { if (window.electronAPI) { window.electronAPI.getAppVersion().then(setVersion); } }, []); if (!version) return null; return v{version}; } ``` --- ## Anti-Patterns to Avoid ### ❌ Never expose `ipcRenderer` directly ```typescript // BAD — gives the renderer unrestricted IPC access contextBridge.exposeInMainWorld('ipc', ipcRenderer); ``` ### ❌ Never expose `require` or Node.js APIs ```typescript // BAD — allows arbitrary code execution from the renderer contextBridge.exposeInMainWorld('require', require); ``` ### ❌ Never use `nodeIntegration: true` ```typescript // BAD — completely disables the security boundary new BrowserWindow({ webPreferences: { nodeIntegration: true, contextIsolation: false } }); ``` ### ❌ Never pass unsanitized IPC data to shell commands ```typescript // 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 ```typescript // 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 ```typescript // GOOD — prevents memory leaks in React's useEffect onSomeEvent: createEventSubscription('channel:event') // In React: useEffect(() => { const unsub = window.electronAPI.onSomeEvent((data) => { /* ... */ }); return () => unsub(); // Cleanup on unmount }, []); ```