Electron Complete Guide | Build Desktop Apps with JavaScript
이 글의 핵심
Electron lets you build macOS, Windows, and Linux desktop apps using web technologies. This guide covers the main/renderer process model, secure IPC, native OS integration, and distribution — with a React + Vite setup.
What This Guide Covers
Electron wraps your web app in a desktop shell with access to the filesystem, system tray, notifications, and more. This guide covers the architecture, secure IPC, native APIs, and packaging for distribution.
Real-world insight: VS Code, Slack, Discord, Figma, and 1Password are all built with Electron — it’s proven for production desktop apps used by millions.
Setup (Vite + React + Electron)
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm install -D electron electron-builder @electron-toolkit/preload
1. Process Architecture
Electron has two process types:
Main Process (Node.js)
├── Creates browser windows
├── Manages app lifecycle
├── Accesses Node.js / native APIs
└── Communicates with renderer via IPC
Renderer Process (Chromium)
├── Renders your HTML/CSS/JS (React/Vue/etc.)
├── Cannot directly access Node.js APIs
└── Communicates with main via IPC (through preload)
Preload Script
├── Runs in renderer context with Node.js access
└── Bridge between main and renderer via contextBridge
2. Main Process
// src/main/index.ts
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'path'
let mainWindow: BrowserWindow | null = null
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true, // required for security
nodeIntegration: false, // required for security
},
})
// Load Vite dev server in development, built files in production
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173')
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
// Open external links in default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
}
app.whenReady().then(createWindow)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
3. Preload Script (Secure Bridge)
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron'
// Expose safe APIs to renderer
contextBridge.exposeInMainWorld('api', {
// File operations
readFile: (path: string) => ipcRenderer.invoke('fs:readFile', path),
writeFile: (path: string, content: string) =>
ipcRenderer.invoke('fs:writeFile', path, content),
// Dialog
openFile: () => ipcRenderer.invoke('dialog:openFile'),
saveFile: (content: string) => ipcRenderer.invoke('dialog:saveFile', content),
// App info
getVersion: () => ipcRenderer.invoke('app:getVersion'),
// Events from main to renderer
onUpdate: (callback: (version: string) => void) => {
ipcRenderer.on('update-available', (_, version) => callback(version))
return () => ipcRenderer.removeAllListeners('update-available')
},
})
TypeScript types for renderer:
// src/renderer/types/electron.d.ts
interface Window {
api: {
readFile: (path: string) => Promise<string>
writeFile: (path: string, content: string) => Promise<void>
openFile: () => Promise<string | null>
saveFile: (content: string) => Promise<string | null>
getVersion: () => Promise<string>
onUpdate: (callback: (version: string) => void) => () => void
}
}
4. IPC Handlers (Main Process)
// src/main/ipc.ts
import { ipcMain, dialog, app } from 'electron'
import { readFile, writeFile } from 'fs/promises'
export function registerIpcHandlers() {
// File read
ipcMain.handle('fs:readFile', async (_, path: string) => {
return readFile(path, 'utf-8')
})
// File write
ipcMain.handle('fs:writeFile', async (_, path: string, content: string) => {
await writeFile(path, content, 'utf-8')
})
// Open file dialog
ipcMain.handle('dialog:openFile', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Text Files', extensions: ['txt', 'md', 'json'] }],
})
if (result.canceled) return null
return readFile(result.filePaths[0], 'utf-8')
})
// Save file dialog
ipcMain.handle('dialog:saveFile', async (_, content: string) => {
const result = await dialog.showSaveDialog({
filters: [{ name: 'Text Files', extensions: ['txt'] }],
})
if (result.canceled || !result.filePath) return null
await writeFile(result.filePath, content)
return result.filePath
})
// App version
ipcMain.handle('app:getVersion', () => app.getVersion())
}
5. Using the API in React
// src/renderer/App.tsx
import { useState, useEffect } from 'react'
export function Editor() {
const [content, setContent] = useState('')
const [filePath, setFilePath] = useState<string | null>(null)
const [version, setVersion] = useState('')
useEffect(() => {
window.api.getVersion().then(setVersion)
}, [])
async function openFile() {
const text = await window.api.openFile()
if (text !== null) setContent(text)
}
async function saveFile() {
const path = await window.api.saveFile(content)
if (path) setFilePath(path)
}
return (
<div>
<header>
<button onClick={openFile}>Open</button>
<button onClick={saveFile}>Save</button>
<span>v{version}</span>
</header>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
style={{ width: '100%', height: '80vh' }}
/>
</div>
)
}
6. System Tray
// src/main/tray.ts
import { Tray, Menu, nativeImage, app } from 'electron'
import { join } from 'path'
export function createTray(mainWindow: BrowserWindow) {
const icon = nativeImage.createFromPath(join(__dirname, 'icon.png'))
const tray = new Tray(icon.resize({ width: 16, height: 16 }))
const menu = Menu.buildFromTemplate([
{ label: 'Show', click: () => mainWindow.show() },
{ label: 'Hide', click: () => mainWindow.hide() },
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() },
])
tray.setToolTip('My App')
tray.setContextMenu(menu)
tray.on('click', () => {
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show()
})
}
7. Auto-Update
npm install electron-updater
// src/main/updater.ts
import { autoUpdater } from 'electron-updater'
export function setupAutoUpdater(mainWindow: BrowserWindow) {
autoUpdater.checkForUpdatesAndNotify()
autoUpdater.on('update-available', (info) => {
mainWindow.webContents.send('update-available', info.version)
})
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall()
})
}
Requires hosting releases on GitHub Releases, S3, or your own server.
8. App Packaging (electron-builder)
// package.json
{
"build": {
"appId": "com.yourcompany.myapp",
"productName": "My App",
"directories": { "output": "dist-electron" },
"mac": {
"category": "public.app-category.productivity",
"target": ["dmg", "zip"]
},
"win": {
"target": ["nsis"]
},
"linux": {
"target": ["AppImage", "deb"]
},
"publish": {
"provider": "github",
"owner": "your-username",
"repo": "my-app"
}
}
}
# Build for current platform
npm run build
electron-builder
# Build for all platforms (requires macOS for signing)
electron-builder --mac --win --linux
Security Checklist
new BrowserWindow({
webPreferences: {
contextIsolation: true, // ✅ must be true
nodeIntegration: false, // ✅ must be false
sandbox: true, // ✅ enable sandbox
webSecurity: true, // ✅ don't disable this
}
})
// ✅ Always validate IPC inputs
ipcMain.handle('fs:readFile', async (_, path: string) => {
// Validate path is within allowed directory
if (!path.startsWith(allowedDir)) throw new Error('Access denied')
return readFile(path, 'utf-8')
})
// ✅ Don't use shell.openExternal with user-provided input without validation
Key Takeaways
| Concept | Key point |
|---|---|
| Main process | Node.js — creates windows, manages lifecycle |
| Renderer process | Chromium — your React/Vue app |
| Preload | Bridge via contextBridge — the only safe IPC path |
| IPC | ipcRenderer.invoke → ipcMain.handle for request/reply |
| Security | contextIsolation: true, nodeIntegration: false — always |
| Packaging | electron-builder for cross-platform distribution |
| Updates | electron-updater + GitHub Releases |
Electron’s main learning curve is the two-process model and secure IPC. Once that clicks — preload exposes a safe API, renderer calls it, main handles it — everything else follows the patterns you already know from web development.