Electron Complete Guide | Build Desktop Apps with JavaScript

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

ConceptKey point
Main processNode.js — creates windows, manages lifecycle
Renderer processChromium — your React/Vue app
PreloadBridge via contextBridge — the only safe IPC path
IPCipcRenderer.invokeipcMain.handle for request/reply
SecuritycontextIsolation: true, nodeIntegration: false — always
Packagingelectron-builder for cross-platform distribution
Updateselectron-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.