Guia completo de desenvolvimento de aplicativos desktop com Electron: Da configuração à produção
Por que Electron
O desenvolvimento desktop multiplataforma sempre foi uma dor de cabeça. Escreva aplicativos Windows em C# e WPF, aplicativos macOS em Swift e AppKit, aplicativos Linux em GTK ou Qt — três bases de código, triplo de manutenção. Electron mudou o jogo: uma base de código HTML/CSS/JavaScript, empacotada para Windows, macOS e Linux simultaneamente.
Se você usa VS Code, Figma, Slack ou Discord, já está executando aplicativos Electron todos os dias. A velocidade de inicialização do VS Code e o desempenho de renderização do Figma provam que Electron pode igualar a experiência nativa com a arquitetura certa.
Ajudei a migrar uma ferramenta interna de C# WinForms para Electron. A velocidade de entrega de funcionalidades triplicou nos dois trimestres seguintes. A razão é simples: contratar desenvolvedores frontend é muito mais fácil do que contratar desenvolvedores desktop em C#, e o ecossistema de componentes é vastamente mais rico.
Este artigo é meu resumo completo pós-migração. Não vou recitar a documentação da API — você pode consultar isso — vou focar no que realmente dá errado em produção e como consertar.
Inicialização do projeto: Não copie o Quick-Start oficial
O electron-quick-start oficial usa JavaScript puro, sem ferramentas de build, sem módulos, sem hot reload. Começar um projeto real dessa forma garante código espaguete em dois meses.
Use electron-vite ou configure manualmente Vite + Electron:
npm create @quick-start/electron@latest my-app -- --template vue-ts
Ou configuração manual:
mkdir my-electron-app && cd my-electron-app
npm init -y
npm i -D electron electron-builder vite @vitejs/plugin-vue typescript
npm i vue
Estrutura de diretórios recomendada:
my-electron-app/
├── electron/ # Processo principal
│ ├── main.ts # Ponto de entrada, criação de janelas
│ ├── preload.ts # Ponte de segurança
│ └── ipc/ # Gerenciamento de canais IPC
│ ├── file.ts # Operações de arquivo
│ └── system.ts # Informações do sistema
├── src/ # Renderizador (frontend)
│ ├── App.vue
│ ├── main.ts
│ └── components/
├── resources/ # Ícones do app, recursos estáticos
├── electron-builder.yml
├── vite.config.ts
└── package.json
A decisão arquitetônica crítica: separação completa dos processos principal e renderizador. O processo principal (electron/) gerencia apenas operações de nível de sistema. O renderizador (src/) é um projeto frontend padrão. Isso permite que especialistas de cada lado trabalhem independentemente sem atrito.
Modelo de processos: Saiba quem faz o quê
Electron tem dois processos principais. Se você não interiorizar esse conceito, problemas de segurança e desempenho vão forçar reescritas repetidas.
| Processo | Runtime | Capacidades | Limitações |
|---|---|---|---|
| Principal | Node.js | Criar janelas, ler/escrever arquivos, chamar APIs do sistema, gerenciar ciclo de vida | Não pode manipular o DOM diretamente |
| Renderizador | Chromium | Renderizar HTML/CSS, responder à entrada do usuário, manipular DOM | Não pode acessar APIs do Node.js por padrão |
A comunicação acontece através de IPC (Inter-Process Communication). O processo principal expõe uma superfície de API limitada aos renderizadores via contextBridge.
Um fluxo de comunicação típico:
Usuário clica em "Salvar Arquivo"
→ Renderizador: ipcRenderer.invoke('save-file', fileData)
→ Principal: ipcMain.handle('save-file', async (event, data) => {
dialog.showSaveDialog() → fs.writeFileSync()
})
→ Retorna resultado ao renderizador
Entrada do processo principal
// electron/main.ts
import { app, BrowserWindow } from 'electron';
import { registerIpcHandlers } from './ipc';
let mainWindow: BrowserWindow | null = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // Deve estar habilitado
nodeIntegration: false, // Deve estar desabilitado
sandbox: true, // Modo sandbox
},
});
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
}
app.whenReady().then(() => {
registerIpcHandlers();
createWindow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
Script de preload — O portão de segurança do seu app
// electron/preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
saveFile: (content: string, defaultName?: string) =>
ipcRenderer.invoke('save-file', content, defaultName),
openFile: () =>
ipcRenderer.invoke('open-file'),
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getPlatform: () => process.platform,
minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'),
});
Chamada a partir do renderizador:
// src/components/SaveButton.vue
const handleSave = async () => {
const result = await window.electronAPI.saveFile(
editorContent.value,
'untitled.txt'
);
if (result.success) {
showToast(`Salvo em ${result.filePath}`);
}
};
contextBridge é crucial — ele restringe o que o renderizador pode chamar. Nunca exponha o ipcRenderer bruto ao renderizador; isso daria ao conteúdo web acesso completo ao Node.js.
Segurança: Sete regras inegociáveis
O modelo de segurança do Electron evoluiu através de múltiplas iterações. Tutoriais antigos frequentemente mostram padrões que agora são inseguros. A partir de 2026, estes são os requisitos rigorosos:
| # | Regra | Config | Por quê |
|---|---|---|---|
| 1 | contextIsolation: true |
webPreferences | Isola o contexto JS do renderizador do preload |
| 2 | nodeIntegration: false |
webPreferences | Bloqueia require do Node.js direto no renderizador |
| 3 | sandbox: true |
webPreferences | Sandbox em nível de SO, restringe permissões do processo |
| 4 | Usar contextBridge |
preload.ts | Exposição de API baseada em lista de permissão |
| 5 | Validar origem da mensagem IPC | ipcMain.handle | Verificar URL de event.senderFrame |
| 6 | Sem conteúdo remoto sem CSP | webPreferences | Política CSP obrigatória para URLs remotas |
| 7 | Assinar e notarizar | electron-builder | macOS requer notarização, Windows recomenda assinatura |
Validação de origem IPC
// electron/ipc/file.ts
ipcMain.handle('save-file', async (event, content: string) => {
if (!event.senderFrame?.url.startsWith('file://')) {
throw new Error('Chamador não autorizado');
}
if (typeof content !== 'string' || content.length > 10 * 1024 * 1024) {
throw new Error('Conteúdo inválido');
}
const { filePath } = await dialog.showSaveDialog({
defaultPath: 'untitled.txt',
filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
});
if (!filePath) return { success: false, canceled: true };
await fs.promises.writeFile(filePath, content, 'utf-8');
return { success: true, filePath };
});
Gerenciamento de janelas: Além do new BrowserWindow
Janelas sem moldura e barras de título personalizadas
A maioria dos aplicativos desktop modernos abandona as barras de título nativas — VS Code, Figma, Notion todos fazem isso. Implementação:
const mainWindow = new BrowserWindow({
frame: false,
titleBarStyle: 'hidden',
...(process.platform === 'darwin'
? { titleBarStyle: 'hiddenInset' }
: { frame: false }),
});
Barra de título personalizada no renderizador:
<!-- src/components/TitleBar.vue -->
<template>
<div class="title-bar" :class="{ 'is-mac': isMac }">
<div class="title-bar-drag" @dblclick="toggleMaximize">
<span class="app-title">{{ title }}</span>
</div>
<div class="window-controls" v-if="!isMac">
<button @click="minimize" class="control-btn minimize">─</button>
<button @click="toggleMaximize" class="control-btn maximize">□</button>
<button @click="close" class="control-btn close">✕</button>
</div>
</div>
</template>
Note sobre -webkit-app-region: drag — esta propriedade CSS habilita arrastar para mover na área da barra de título. Áreas de botões devem sobrescrever com no-drag, caso contrário clicar em um botão aciona o arraste da janela.
Comunicação entre múltiplas janelas
Aplicativos complexos frequentemente precisam de múltiplas janelas (configurações, pré-visualização, diálogos). Rotear comunicação entre janelas através do processo principal:
ipcMain.on('broadcast-to-windows', (event, channel: string, data: unknown) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win.webContents.id !== event.sender.id) {
win.webContents.send(channel, data);
}
});
});
Integração de módulos nativos: Quando Node.js não é suficiente
Algumas tarefas são muito lentas em JavaScript puro — processamento em lote de imagens, codificação de vídeo. É aí que entram os módulos nativos C++.
Opção 1: Addon nativo do Node.js (node-addon-api)
// native/thumbnail.cc
#include <napi.h>
Napi::Value GenerateThumbnail(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string inputPath = info[0].As<Napi::String>();
std::string outputPath = info[1].As<Napi::String>();
// Usar FFmpeg/libvips para gerar miniatura
return Napi::String::New(env, outputPath);
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("generateThumbnail",
Napi::Function::New(env, GenerateThumbnail));
return exports;
}
NODE_API_MODULE(thumbnail, Init)
Opção 2: Processo filho chamando ferramentas externas
Prefira esta opção — desacoplada, falhas não afetam o processo principal:
// electron/ipc/image.ts
import { spawn } from 'child_process';
ipcMain.handle('compress-image', async (event, inputPath: string) => {
return new Promise((resolve, reject) => {
const child = spawn('oxipng', ['--opt', 'max', '--strip', 'safe', inputPath]);
let stderr = '';
child.stderr.on('data', (data) => { stderr += data; });
child.on('close', (code) => {
if (code === 0) resolve({ success: true });
else reject(new Error(`oxipng saiu com ${code}: ${stderr}`));
});
});
});
Auto-atualização: Atualizações sem interrupções
A auto-atualização é padrão para aplicativos desktop. electron-updater (baseado no electron-builder) é a opção mais madura:
// electron/updater.ts
import { autoUpdater } from 'electron-updater';
import { BrowserWindow, dialog } from 'electron';
export function setupAutoUpdater(mainWindow: BrowserWindow) {
autoUpdater.setFeedURL({
provider: 'generic',
url: 'https://releases.your-app.com/updates/',
});
setInterval(() => {
autoUpdater.checkForUpdates();
}, 4 * 60 * 60 * 1000);
autoUpdater.on('update-available', () => {
dialog.showMessageBox(mainWindow, {
type: 'info',
title: 'Atualização disponível',
message: 'Baixando atualização em segundo plano...',
});
});
autoUpdater.on('update-downloaded', () => {
dialog.showMessageBox(mainWindow, {
type: 'info',
title: 'Atualização pronta',
message: 'Reinicie agora para instalar a versão mais recente.',
}).then(() => {
autoUpdater.quitAndInstall();
});
});
autoUpdater.on('error', (err) => {
console.error('Erro de atualização:', err.message);
});
setTimeout(() => autoUpdater.checkForUpdates(), 5000);
}
Empacotamento e distribuição
Exemplo de electron-builder.yml:
appId: com.yourcompany.yourapp
productName: YourApp
directories:
output: release
files:
- dist/**/*
- electron/**/*.js
- package.json
win:
target:
- target: nsis
arch: [x64, arm64]
icon: resources/icon.ico
mac:
target:
- target: dmg
arch: [x64, arm64]
icon: resources/icon.icns
category: public.app-category.developer-tools
hardenedRuntime: true
notarize:
teamId: YOUR_TEAM_ID
linux:
target:
- target: AppImage
arch: [x64]
- target: deb
arch: [x64]
category: Development
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
Comandos de build:
npm run dev # desenvolvimento local
npm run build # plataforma atual
npm run build:win # Windows
npm run build:mac # macOS
npm run build:linux # Linux
Otimização de desempenho: Não deixe seu app virar Chrome
A maior crítica ao Electron é o uso de memória. Um app Electron básico usa 50-80MB, e com conteúdo real, facilmente ultrapassa 200MB+. Aqui estão estratégias validadas em produção:
1. Carregamento preguiçoso de janelas não críticas
let settingsWindow: BrowserWindow | null = null;
ipcMain.handle('open-settings', async () => {
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus();
return;
}
settingsWindow = new BrowserWindow({ /* ... */ });
settingsWindow.loadURL('...');
settingsWindow.on('closed', () => { settingsWindow = null; });
});
2. Limitação em segundo plano
const win = new BrowserWindow({
webPreferences: {
backgroundThrottling: true,
offscreen: false,
},
});
win.on('blur', () => {
win.webContents.setBackgroundThrottling(true);
});
3. Usar BrowserView para conteúdo embarcado
const view = new BrowserView({
webPreferences: { sandbox: true, contextIsolation: true },
});
mainWindow.setBrowserView(view);
view.setBounds({ x: 0, y: 0, width: 800, height: 600 });
view.webContents.loadURL('https://preview.your-app.com/doc/123');
4. Checklist de vazamento de memória
| Verificação | Ferramenta | Métrica |
|---|---|---|
| Processos zumbi após fechar janela | Gerenciador de Tarefas / Activity Monitor | Processos electron persistentes |
| webContents não destruídos | webContents.getAllWebContents() |
Contagem crescente |
| Listeners IPC não limpos | ipcMain.eventNames() |
Falta removeHandler |
| Referências a objetos grandes | Painel Memory do Chrome DevTools | Comparação de snapshots do heap |
Dicas de depuração
Depuração do processo principal
# Iniciar com depurador anexado
electron --inspect=5858 .
# Abrir chrome://inspect no Chrome para conectar
VSCode launch.json:
{
"type": "node",
"request": "launch",
"name": "Debug Main Process",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"runtimeArgs": ["--inspect=5858", "."],
"port": 5858
}
Depuração do processo renderizador
DevTools são abertos automaticamente em modo de desenvolvimento. Em produção, force com uma flag:
if (process.argv.includes('--devtools')) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
Armadilhas comuns
Armadilha 1: Notarização do macOS falha
"notarization failed: The binary is not signed"
Correção: Garanta hardenedRuntime: true, arquivo de entitlements correto e um certificado Apple Developer ID.
Armadilha 2: Caminho do Windows muito longo
O Windows tem um limite de 260 caracteres para caminhos. node_modules profundamente aninhados facilmente o excedem. Habilite suporte a caminhos longos no Windows 10 1607+ via Política de Grupo, ou use electron-builder install-app-deps.
Armadilha 3: Módulos nativos falham após empacotamento asar
electron-builder empacota o código em app.asar por padrão, mas módulos nativos .node não podem ser carregados de dentro do asar:
# electron-builder.yml
asarUnpack:
- "node_modules/sharp/**"
- "node_modules/better-sqlite3/**"
- "native/**/*.node"
Armadilha 4: Tela branca longa na inicialização
- Divida o código de saída do Vite, carregue código mínimo para a primeira pintura
- Use uma tela de splash para cobrir o período em branco
- Mostre a janela apenas no evento
ready-to-show:
mainWindow.once('ready-to-show', () => { mainWindow.show(); });
Ferramentas relacionadas
- Codificar/Decodificar Base64 — Manipular recursos inline e transferências de arquivos
- Formatador JSON — Depurar mensagens IPC e arquivos de configuração
- Compressor de imagens — Otimizar o tamanho de recursos de imagem no aplicativo
Experimente estas ferramentas executadas localmente no navegador — nenhum cadastro necessário →