Programação Por Equipe DevBras

Capa do artigo: Electron com Vue: preload, IPC e a ponte estreita entre a tela e o sistema

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

  1. Crie o projeto Vue com Vite (npm create vue@latest) e confirme que npm run dev sobe em http://localhost:5173.
  2. Instale o Electron como dependência de desenvolvimento: npm install --save-dev electron.
  3. Crie a pasta electron/ com main.cjs e preload.cjs. A extensão .cjs importa porque o projeto do Vite vem com "type": "module" no package.json, e esses arquivos usam require.
  4. No package.json, aponte "main": "electron/main.cjs".
  5. No vite.config.js, defina base: './' para que o build gere caminhos relativos, que funcionam quando a página é aberta como arquivo.
  6. 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 nodeIntegration para usar fs no componente. Qualquer HTML ou script injetado na página ganha acesso ao disco da pessoa.
  • Expor o ipcRenderer inteiro 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 base absoluta 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

  • contextIsolation e sandbox ligados, nodeIntegration desligado.
  • O preload expõe só funções nomeadas, uma por ação do produto.
  • Cada ipcMain.handle valida 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?

Avalie este artigo

Referência: https://blog.logrocket.com/building-app-electron-vue/