Electron com Vue: preload, IPC e a ponte estreita entre a tela e o sistema
Monte um app Electron com Vue em que a interface só pede ações nomeadas por uma ponte de preload, sem acesso ao Node, e funcione igual no dev e no pacote.
O app de notas da equipe funcionava. Até alguém colar no editor um trecho de HTML copiado de um site, com um script dentro, e o componente que renderizava a nota ter acesso a require('fs'). Um texto colado podia apagar arquivos da máquina. A resposta curta: no Electron, o Vue roda como uma página web comum, sem Node, e conversa com o sistema operacional só por funções nomeadas expostas num script de preload, que o processo principal valida.
Esse desenho é o padrão atual do Electron: isolamento de contexto ligado, integração com Node desligada e sandbox no renderizador. Se o tutorial que você seguiu manda ligar nodeIntegration para "facilitar", ele está te dando o problema da história acima.
Os dois processos e por que eles não se misturam
Um app Electron tem um processo principal, que roda Node, cria janelas, abre diálogos e lê disco. E tem um ou mais processos de renderização, um por janela, que carregam HTML, CSS e JavaScript como um navegador. O Vue mora no segundo.
Entre os dois existe o script de preload. Ele roda antes da página, com acesso a uma parte limitada da API do Electron, e usa contextBridge.exposeInMainWorld para colocar no window da página apenas o que você escolheu. A página chama window.notas.salvar(texto); o preload repassa por IPC; o processo principal decide se aceita.
A regra que segura tudo: o renderizador pede uma ação, o principal escolhe como executar. O componente pede "salvar esta nota". Ele não escolhe o caminho no disco se essa escolha é regra do produto, nem recebe uma função genérica do tipo "executar comando".
Como montar o projeto passo a passo
- Crie o projeto Vue com Vite (
npm create vue@latest) e confirme quenpm run devsobe emhttp://localhost:5173. - Instale o Electron como dependência de desenvolvimento:
npm install --save-dev electron. - Crie a pasta
electron/commain.cjsepreload.cjs. A extensão.cjsimporta porque o projeto do Vite vem com"type": "module"nopackage.json, e esses arquivos usamrequire. - No
package.json, aponte"main": "electron/main.cjs". - No
vite.config.js, definabase: './'para que o build gere caminhos relativos, que funcionam quando a página é aberta como arquivo. - Se usar Vue Router, troque para
createWebHashHistory(). O histórico com caminhos "limpos" depende de um servidor, e no app empacotado não há servidor.
O processo principal
// electron/main.cjs
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('node:path');
const fs = require('node:fs/promises');
function criarJanela() {
const win = new BrowserWindow({
width: 1000,
height: 700,
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
});
if (app.isPackaged) {
win.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
} else {
win.loadURL('http://localhost:5173');
}
}
ipcMain.handle('nota:salvar', async (_event, texto) => {
if (typeof texto !== 'string' || texto.length > 1_000_000) {
throw new Error('Texto inválido');
}
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: 'nota.txt',
filters: [{ name: 'Texto', extensions: ['txt'] }]
});
if (canceled || !filePath) return { salvo: false };
await fs.writeFile(filePath, texto, 'utf8');
return { salvo: true, caminho: filePath };
});
app.whenReady().then(criarJanela);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
Repare que o caminho do arquivo é escolhido pelo diálogo nativo, no processo principal. A página manda só o texto. E o texto é validado: tipo e tamanho. Tudo que chega pelo IPC é entrada externa, como um corpo de requisição HTTP.
O preload: a ponte estreita
// electron/preload.cjs
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('notas', {
salvar: (texto) => ipcRenderer.invoke('nota:salvar', texto)
});
Não exponha o ipcRenderer inteiro, nem uma função invoke(canal, ...args) genérica. Isso devolve à página o poder de chamar qualquer canal, inclusive os que você criar no futuro sem pensar na página.
O componente Vue
<script setup>
import { ref } from 'vue';
const texto = ref('');
const status = ref('');
async function salvar() {
try {
const r = await window.notas.salvar(texto.value);
status.value = r.salvo ? 'Salvo em ' + r.caminho : 'Cancelado';
} catch (e) {
status.value = 'Não foi possível salvar: ' + e.message;
}
}
</script>
<template>
<textarea v-model="texto" rows="12"></textarea>
<button @click="salvar">Salvar</button>
<p>{{ status }}</p>
</template>
O componente não sabe que existe Node. Para testar no navegador, sem Electron, você pode criar um window.notas falso que só faz console.log. Isso é um bom sinal de que a separação está certa.
Desenvolvimento e build não são o mesmo ambiente
No desenvolvimento, rode npm run dev num terminal e npx electron . em outro. A janela carrega o servidor do Vite, com recarga automática da interface. No build, npm run build gera a pasta dist e a janela abre dist/index.html via loadFile.
É aqui que a maioria dos projetos quebra: tela branca no app empacotado. As causas de sempre são base absoluta no Vite (os assets apontam para /assets/..., que no protocolo de arquivo é a raiz do disco), roteador em modo history e caminho do index.html calculado a partir do diretório errado. Abra o DevTools da janela empacotada e olhe a aba de rede antes de mexer em qualquer outra coisa.
Ferramentas como Electron Forge e electron-vite automatizam esse arranjo e o empacotamento. Elas ajudam, mas a separação entre principal, preload e página continua sendo sua responsabilidade. A atualização do aplicativo instalado também é um fluxo à parte: não misture com a recarga automática da interface.
Erros comuns
- Ligar
nodeIntegrationpara usarfsno componente. Qualquer HTML ou script injetado na página ganha acesso ao disco da pessoa. - Expor o
ipcRendererinteiro no preload. A ponte deixa de ser estreita e qualquer canal vira acessível. - Aceitar caminho de arquivo vindo da página sem validar. A página passa
../../e escreve onde não devia. - Deixar
baseabsoluta no Vite. O app empacotado abre com tela branca porque os assets não são encontrados. - Carregar URL remota arbitrária na janela principal. Conteúdo de terceiros passa a ter acesso à mesma ponte que a sua interface.
- Testar só no modo de desenvolvimento. O bug de caminho relativo aparece só no instalador que o cliente baixou.
Checklist
contextIsolationesandboxligados,nodeIntegrationdesligado.- O preload expõe só funções nomeadas, uma por ação do produto.
- Cada
ipcMain.handlevalida tipo e tamanho dos argumentos. - Decisões de caminho de disco ficam no processo principal ou num diálogo nativo.
base: './'no Vite e roteador em modo hash.- O app empacotado foi aberto e testado, não só o
npm run dev. - O componente Vue roda no navegador com uma ponte falsa.
Abra o seu preload agora e conte as funções expostas: cada uma corresponde a uma ação que o produto precisa, ou alguma delas aceita um canal ou um comando qualquer vindo da página?
Referência: https://blog.logrocket.com/building-app-electron-vue/