Pular para o conteúdo
Electron

Electron: transformando um site React em app desktop

Paloma Macetko
Ilustração de um unicórnio com uma varinha transformando uma janela de navegador em uma janela de aplicativo desktop, cercado por átomos do React

Olá meus Unicórnios! 🦄✨

Sabe quando você olha para um site React que já funciona lindamente no navegador e pensa "é só embrulhar isso num Electron, meia hora de trabalho"? 😅 Pois é. Eu pensei exatamente isso, e a meia hora virou umas boas semanas.

Não porque o Electron seja difícil — abrir uma janela e carregar uma página são literalmente sete linhas. O problema é que o navegador embutido do Electron não é o Chrome que você testou, e tudo o que a sua SPA assumia sobre "estar num site" deixa de valer: as rotas do react-router quebram, chamadas de API param de se comportar, e o app entra em modo offline estando online.

Neste artigo vou mostrar o caminho que deu certo, com o código real de um app de notas meu — um monorepo com frontend/ (React + Vite), backend/ (Express) e electron/. E vou contar principalmente onde eu me enganei, porque é isso que documentação nenhuma conta.

Se você prefere o código antes da explicação, montei um exemplo completo e funcional no GitHub — a mesma SPA rodando no navegador e como app desktop:

cmacetko/Exemplos_ElectronReactExemplo mínimo: React + Vite, backend Express e Electron com protocolo app://github.com

🎯 A decisão que define tudo: como o app carrega o frontend

Antes de escrever qualquer linha, você precisa responder uma pergunta. Todo o resto decorre dela.

Existem três jeitos de o Electron mostrar sua SPA, e eles não são equivalentes:

  1. loadURL("https://meusite.com") — o app vira um navegador dedicado. Simples, mas sem internet o usuário vê a tela de dinossauro. E você não ganha quase nada por ter empacotado.
  2. loadFile("dist/index.html") — carrega do disco. Funciona offline, mas a origem vira file://, e aí tudo quebra: sem fetch relativo, sem IndexedDB confiável, sem service worker, sem contexto seguro.
  3. Um protocolo customizado, tipo app://notas — você registra um esquema próprio e serve os arquivos do disco por ele. Funciona offline e tem uma origem estável, tratada como contexto seguro.

Escolhi a terceira. E se você tem uma SPA de verdade — com rotas e chamadas de API — é a única que sobrevive.

Repare no detalhe que me custou tempo: o esquema precisa ser marcado como privilegiado antes do app.whenReady(). Não é "boa prática" — é obrigatório. Se você chamar depois, o Chromium já decidiu que app:// é um esquema exótico e vai recusar fetch e streams.

// electron/src/appProtocol.ts
import { protocol } from "electron";
import { APP_SCHEME } from "./config.js";

/**
 * Marca o esquema app:// como privilegiado ANTES do app.ready: tratado como origem
 * segura (https-like), com suporte a fetch/streams. Deve ser chamado no topo do
 * módulo principal, antes de app.whenReady().
 */
export function registerAppScheme(): void {
  protocol.registerSchemesAsPrivileged([
    {
      scheme: APP_SCHEME,
      privileges: {
        standard: true,
        secure: true,
        supportFetchAPI: true,
        corsEnabled: true,
        stream: true,
      },
    },
  ]);
}

E no main.ts, a chamada fica solta no topo do arquivo — fora de qualquer whenReady:

// Esquema app:// precisa ser privilegiado ANTES do ready.
registerAppScheme();

Essas cinco flags de privileges parecem burocracia, mas cada uma resolve um sintoma específico: standard faz a URL ter host e path de verdade (sem ela, new URL() se perde), secure convence o Chromium de que é contexto seguro (senão adeus crypto.subtle e service worker), supportFetchAPI libera o fetch, corsEnabled evita bloqueio nas próprias requisições e stream permite responder com corpo em streaming.

🗺️ Servindo a SPA: três casos e um fallback

Registrado o esquema, falta o handler que responde às requisições. Ele resolve três coisas em ordem — e a terceira é o pulo do gato do react-router.

export function registerAppProtocol(): void {
  const root = frontendDir();

  protocol.handle(APP_SCHEME, async (request) => {
    const url = new URL(request.url);
    const pathname = decodeURIComponent(url.pathname);

    // 1) Chamadas de API → proxy para o backend.
    if (pathname === "/api" || pathname.startsWith("/api/")) {
      return proxyApiRequest(request, pathname);
    }

    // 2) Asset estático, se existir no disco (impede path traversal). O guard usa
    // `root + path.sep` para não casar diretórios-irmãos cujo nome começa com root
    // (ex.: ".../dist" vs ".../distEVIL").
    const candidate = path.normalize(path.join(root, pathname));
    const inRoot = candidate === root || candidate.startsWith(root + path.sep);
    if (inRoot && pathname !== "/" && fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
      return net.fetch(pathToFileURL(candidate).toString());
    }

    // 3) Fallback SPA → index.html (react-router resolve a rota no cliente).
    return net.fetch(pathToFileURL(path.join(root, "index.html")).toString());
  });
}

Três observações sobre esse trecho, porque cada uma vale um bug:

O fallback do passo 3 é o que faz o react-router funcionar. No servidor web você configura isso no nginx ou no .htaccess — "qualquer rota que não seja arquivo, devolva o index.html". No Electron, esse papel é seu. Sem ele, dar F5 em /notas/123 devolve 404 e a tela fica branca.

O guard do passo 2 usa root + path.sep, não só root. Parece paranoia, mas startsWith(root) puro deixaria passar um diretório irmão chamado distEVIL quando a raiz é dist. É barato blindar e caro descobrir depois.

A comparação pathname !== "/" não é decorativa. Sem ela, a raiz cairia no existsSync de um diretório, e o statSync().isFile() devolveria false — funcionaria por acidente. Deixar explícito é mais honesto que depender do acaso.

Falta ainda saber onde o frontend compilado mora, e a resposta muda entre desenvolvimento e app instalado:

/**
 * Resolve a pasta do frontend compilado.
 * - Empacotado: process.resourcesPath/frontend (electron-builder extraResources).
 * - Dev:        ../frontend/dist relativo a este arquivo (electron/dist/appProtocol.js).
 */
export function frontendDir(): string {
  if (app.isPackaged) {
    return path.join(process.resourcesPath, "frontend");
  }
  // electron/dist/appProtocol.js → sobe para electron/, depois ../frontend/dist
  return path.resolve(__dirname, "..", "..", "frontend", "dist");
}

🔀 O proxy: por que a API não pode ir direto

Se você só for ler um pedaço deste artigo, leia este. 🙏

No desenvolvimento, o Vite tem aquele server.proxy que manda /api/* para o backend. É o que faz a SPA e a API compartilharem a mesma origem — e é por isso que tudo funciona tão suavemente com fetch("/api/notas").

No app empacotado, esse proxy não existe mais. O Vite ficou no desenvolvimento; o que roda é um bundle estático. E aí seu fetch("/api/notas") vira app://notas/api/notas, que não é lugar nenhum.

A saída foi reconstruir o proxy do Vite dentro do processo principal — é o passo 1 do handler que você viu acima. Toda chamada /api/* é encaminhada ao backend real, e a resposta volta ao renderer:

// electron/src/proxy.ts
export async function proxyApiRequest(request: Request, pathname: string): Promise<Response> {
  // pathname começa com "/api". Monta a URL real no backend.
  const target = backendUrl() + pathname + new URL(request.url).search;

  // Clona headers, removendo os hop-by-hop / específicos de origem custom.
  const headers = new Headers(request.headers);
  headers.delete("host");
  headers.delete("origin");
  headers.delete("referer");

  const init: RequestInit = {
    method: request.method,
    headers,
    redirect: "follow",
    // GET/HEAD não têm corpo; demais métodos repassam o body bruto.
    body: request.method === "GET" || request.method === "HEAD"
      ? undefined
      : await request.arrayBuffer(),
  };

  // ... encaminha e devolve (ver adiante — este trecho nunca pode lançar)
}

Os três headers.delete() não são frescura. O Host apontaria para o esquema customizado e confundiria o servidor; o Origin e o Referer chegariam como app://notas, e qualquer backend com checagem de origem responderia 403. Removê-los faz a requisição parecer o que ela é: uma chamada de servidor para servidor.

E repare no ternário do body: passar body num GET faz o fetch lançar TypeError. Como o proxy é genérico e recebe todos os métodos, esse undefined explícito é obrigatório.

O detalhe que eu mais gostei dessa arquitetura: o código React não mudou nada. Ele continua chamando fetch("/api/notas") exatamente como no navegador. Quem se ajeitou foi a camada de baixo — e é lá que a bagunça deve morar.

💥 O bug que me fez perder um dia: offline estando online

Este merece seção própria porque o sintoma não tinha nada a ver com a causa. 🤯

O app tem modo offline. De vez em quando, sem padrão claro, ele entrava em modo offline com a internet perfeitamente funcionando. O console do renderer mostrava um TypeError do fetch, e a rede mostrava net::ERR_UNEXPECTED.

A causa: quando o handler de protocol.handle rejeita — qualquer promise rejeitada, por qualquer motivo — o Chromium devolve net::ERR_UNEXPECTED ao renderer. E o fetch do frontend interpreta isso como falha de rede, que é o gatilho do modo offline.

Ou seja: uma exceção no meu proxy virava "você está sem internet" na cara do usuário. A regra que passei a seguir é dura e simples — o handler nunca lança:

/**
 * SEMPRE resolve com uma Response — nunca lança. Um handler de protocol.handle que
 * rejeita faz o Chromium devolver net::ERR_UNEXPECTED ao renderer; o fetch do frontend
 * então rejeita com TypeError e o app entra em modo offline MESMO ONLINE. Por isso a
 * falha de rede vira um 503 (o frontend o trata como servidor indisponível → offline,
 * pelo mesmo caminho de antes, mas sem o ERR_UNEXPECTED espúrio).
 */
let upstream: Response;
try {
  upstream = await fetch(target, init);
} catch (err) {
  return new Response(JSON.stringify({ error: "backend unreachable" }), {
    status: 503,
    headers: { "content-type": "application/json", "x-proxy-error": "network" },
  });
}

Repare que ele não devolve sucesso fingido: devolve 503, que o frontend já sabia tratar como "servidor indisponível". O modo offline ainda ativa quando deve — só não ativa mais por engano.

E tinha um caso ainda mais sutil que eu levei a sério só depois de ver o stack trace: o próprio new Response() pode lançar.

// Defesa: status fora de 200–599 (ex.: 0 de um opaqueredirect) faz `new Response` lançar
// RangeError → ERR_UNEXPECTED. Normaliza para 502. 204/304 nunca têm body.
const status = upstream.status >= 200 && upstream.status <= 599 ? upstream.status : 502;
const noBody = status === 204 || status === 304;
try {
  return new Response(noBody ? null : upstream.body, {
    status,
    statusText: upstream.statusText,
    headers: resHeaders,
  });
} catch {
  return new Response(JSON.stringify({ error: "bad upstream response" }), {
    status: 502,
    headers: { "content-type": "application/json", "x-proxy-error": "upstream" },
  });
}

De onde vem um status 0? Do redirect. Eu tinha configurado redirect: "manual" achando que era mais "transparente" — repassar o 3xx e deixar o renderer decidir. Só que um redirect manual produz uma resposta opaqueredirect: status 0, body null. E new Response(null, { status: 0 }) lança RangeError. Que vira ERR_UNEXPECTED. Que vira modo offline. 🙃

O conserto foi uma palavra:

// SEGUE redirects no main process. Com "manual", um 3xx do backend (HTTP→HTTPS,
// trailing-slash, Cloudflare) virava uma resposta "opaqueredirect" com status 0 e
// body null; `new Response(body, { status: 0 })` lança RangeError → ERR_UNEXPECTED.
redirect: "follow",

Um HTTP → HTTPS do servidor, ou um trailing slash, ou o Cloudflare no meio — qualquer um desses derrubava o app. E o sintoma que o usuário via era "sem internet".

🌉 A ponte segura: preload com contextIsolation

Com a SPA carregando e a API funcionando, falta dar ao React acesso às coisas que só o desktop tem: gravar arquivo em disco, consultar a base local, saber que está rodando no Electron.

A tentação é ligar nodeIntegration: true e sair usando require("fs") dentro do React. Não faça isso. Sua SPA renderiza Markdown, carrega imagens, talvez tenha um editor — qualquer XSS que passe vira acesso total ao sistema de arquivos do usuário.

A configuração correta é a chata:

mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    title: `Notas v${app.getVersion()}`,
    icon: windowIconPath(),
    webPreferences: {
        preload: path.join(__dirname, "preload.js"),
        contextIsolation: true,
        nodeIntegration: false,
    },
});

E o preload expõe uma superfície estreita e nomeada, via contextBridge. Nada de expor ipcRenderer inteiro — isso seria o mesmo buraco com um passo a mais:

import { contextBridge, ipcRenderer } from "electron";

/**
 * Bridge exposta ao renderer. Mantém contextIsolation ligado (sem nodeIntegration
 * no renderer): o React só enxerga estas funções, nunca o Node.
 */
contextBridge.exposeInMainWorld("appDesktop", {
  isElectron: true,
  /** Consulta notas locais (SQLite) — devolve valor, por isso invoke. */
  notesQuery: (opts: { limit: number; offset: number; q?: string }) =>
    ipcRenderer.invoke("desktop-notes-query", opts),
  /** Insere ou atualiza uma nota local. */
  notesUpsert: (note: unknown) => ipcRenderer.invoke("desktop-notes-upsert", note),
  /** Assina um pedido vindo do menu nativo. Retorna uma função de unsubscribe. */
  onForceSync: (cb: () => void) => {
    const listener = () => cb();
    ipcRenderer.on("desktop-force-sync", listener);
    return () => ipcRenderer.removeListener("desktop-force-sync", listener);
  },
});

Duas coisas que aprendi escrevendo esse preload:

Todo on() devolve um unsubscribe. Sem isso, um useEffect do React que remonta vai acumulando listeners e um dia o callback dispara cinco vezes. Devolver a função de limpeza faz o useEffect ficar natural: return window.appDesktop.onForceSync(cb).

send é fire-and-forget; invoke devolve valor. Parece óbvio escrito assim, mas eu misturei os dois no começo e fiquei tentando entender por que o await nunca resolvia. 😳

Do lado do React, a detecção do ambiente vira uma linha só, e todo o resto do código continua igual ao da web:

// frontend/src/lib/api.ts
return typeof window !== "undefined" && !!window.appDesktop?.isElectron;

🚪 Uma janela só, por favor

Um detalhe pequeno que eu só entendi depois de corromper o cache local: duas janelas do mesmo app brigam pelo IndexedDB, porque compartilham a mesma origem app://notas. Abrir o app duas vezes bagunçava a base.

O conserto é uma chamada:

// Single-instance: evita duas janelas brigando pelo IndexedDB da mesma origem.
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
  app.quit();
} else {
  app.on("second-instance", () => {
    // Tentou abrir de novo → foca a janela que já existe.
    if (mainWindow) {
      if (mainWindow.isMinimized()) mainWindow.restore();
      mainWindow.focus();
    }
  });

  void app.whenReady().then(() => {
    registerAppProtocol();
    createWindow();
  });
}

Repare que o second-instance não é só um detalhe técnico — é comportamento esperado de app desktop. Clicar no ícone com o app já aberto deve trazer a janela para a frente, não abrir outra. É de graça e o usuário sente.

📦 Empacotando: onde o frontend some do pacote

Chegamos no electron-builder. A configuração que importa está em três chaves:

{
  "build": {
    "appId": "com.exemplo.notas",
    "productName": "Notas",
    "directories": { "output": "out" },
    "files": [
      "dist/**/*",
      "package.json"
    ],
    "extraResources": [
      {
        "from": "../frontend/dist",
        "to": "frontend"
      }
    ],
    "win": {
      "target": "nsis",
      "artifactName": "Notas-Setup-${version}.${ext}"
    },
    "nsis": {
      "oneClick": false,
      "allowToChangeInstallationDirectory": true
    }
  }
}

O extraResources é o item que eu esqueci na primeira tentativa. Repare que files só inclui dist/**/* — o build do Electron. O build do React vive em outro workspace, fora dessa pasta, e o electron-builder não vai adivinhar que você quer levá-lo junto. O app instalava lindamente e abria uma tela branca. 😖

É exatamente por isso que existe aquele frontendDir() com o if (app.isPackaged): extraResources joga a pasta em process.resourcesPath/frontend, que não tem nenhuma relação com o caminho de desenvolvimento.

E, já que dá para prever esse erro, o app avisa em vez de mostrar tela branca:

if (!frontendBuilt()) {
    // Build do frontend ausente → tela explicativa em vez de tela branca.
    void mainWindow.loadURL(
        "data:text/html;charset=utf-8," +
        encodeURIComponent(
            `<html lang="pt-BR"><body style="font-family:sans-serif;padding:2rem">
             <h1>Build do frontend não encontrado</h1>
             <p>Rode <code>npm run build:frontend</code> e abra novamente.</p>
             </body></html>`,
        ),
    );
    return;
}

Custa cinco minutos e economiza meia hora de "por que está branco?" — inclusive para você mesmo, três meses depois.

Uma dica de ergonomia que vale ouro no monorepo: amarre o build do frontend ao prestart do Electron, para nunca rodar o app com um bundle velho:

"scripts": {
  "build": "tsc -p tsconfig.json",
  "build:frontend": "npm run build --workspace @app/frontend",
  "prestart": "npm run build:frontend && npm run build",
  "start": "electron ."
}

A URL do backend precisa estar dentro do binário

Última pegadinha do empacotamento, e ela é sorrateira porque funciona na sua máquina. Meu config.ts lia process.env.APP_BACKEND_URL. Em desenvolvimento, perfeito. No app instalado na máquina do usuário: essa variável não existe.

A solução foi um arquivo que o CI reescreve no momento do build, gravando a URL dentro do código:

/**
 * Config injetada no momento do BUILD (CI). Estes valores são sobrescritos pelo
 * workflow de release a partir dos secrets do repositório.
 *
 * Em dev (build local sem CI) ficam vazios; config.ts então cai no fallback
 * localhost. NÃO leia process.env aqui: env vars do CI não existem em runtime na
 * máquina do usuário — por isso precisam ser fixadas neste arquivo no build.
 */
export const BUILD_BACKEND_URL = "";

E a resolução vira uma cascata de três níveis — env em dev, valor "assado" no build, e localhost como último recurso:

/** Primeiro valor não-vazio entre env, build-config e o fallback localhost. Sem barra final. */
function resolveUrl(envValue: string | undefined, bakedValue: string): string {
  const raw = (envValue || bakedValue || "http://localhost:4000").trim();
  return raw.replace(/\/+$/, "");
}

No CI, o passo que faz o "bake" é um node -e que troca a string do arquivo antes de compilar. E aquele replace(/\/+$/, "") parece trivial, mas evita um bug bobo e frequente: a barra final. Um secret com https://api.exemplo.com/ produziria https://api.exemplo.com//api/notas, e alguns servidores respondem 301 nisso — o que, lembra?, era justamente o caminho para o opaqueredirect e o modo offline fantasma. Os bugs conversam entre si. 😅

🩹 O botão "Estou com Problema"

Termino com o item mais humilde do app, e o que mais me poupou suporte.

Um app desktop offline-first acumula estado em vários lugares diferentes: base SQLite local, arquivos em disco, IndexedDB e cache do Chromium. Quando um deles desincroniza, o usuário vê um sintoma esquisito e você não tem console para depurar remotamente.

Então o menu Ajuda tem um item que limpa tudo o que é local — e o que é local:

// Reset "Estou com Problema": limpa o estado local e os caches do navegador embutido.
// O renderer já limpou o IndexedDB do app antes de invocar; aqui apagamos as bases
// SQLite locais, os arquivos em disco e o storage do Chromium (cache, service workers,
// IndexedDB residual). Os dados do servidor NÃO são tocados (só o local).
ipcMain.handle("desktop-problem-reset", async () => {
    clearAllNotesDbs();
    clearAllFiles();
    try {
        await session.defaultSession.clearStorageData({
            storages: ["indexdb", "localstorage", "serviceworkers", "cachestorage"],
        });
        await session.defaultSession.clearCache();
    } catch (err) {
        console.warn("[problem-reset] falha ao limpar storage do Chromium:", err);
    }
});

Note a divisão de trabalho: o renderer limpa o IndexedDB dele antes de invocar, porque o processo principal não tem acesso direto ao banco da aplicação. E o clearStorageData ainda passa por cima de qualquer resíduo. Cinto e suspensório.

Junto com isso vem um menu de Debug — arquivo de log, pasta de instalação, pasta de dados — e um "Sobre" com as versões, com botão de copiar:

function aboutText(): string {
    const v = process.versions;
    return [
        `Notas ${app.getVersion()}`,
        "",
        "[Runtime]",
        `  Electron: ${v.electron}`,
        `  Chromium: ${v.chrome}`,
        `  Node:     ${v.node}`,
        "",
        "[Sistema]",
        `  Plataforma: ${process.platform} (${process.arch})`,
        `  Backend:    ${backendUrl()}`,
        "",
        "[Instalação]",
        `  Exe:  ${app.getPath("exe")}`,
        `  Dados (userData): ${app.getPath("userData")}`,
    ].join("\n");
}

Quando um usuário reporta um problema, esse texto colado no chat resolve metade das perguntas antes de eu precisar fazê-las. 🙌 E o userData ali é especialmente útil: é onde mora todo o estado local, e ninguém decora esse caminho de cabeça em três sistemas operacionais diferentes.

📂 O código completo

Todo o exemplo está no GitHub, pronto para clonar e rodar: a mesma SPA React abrindo no navegador e como app desktop, com o backend Express junto. É o mínimo que funciona — sem autenticação, sem banco, sem mágica.

cmacetko/Exemplos_ElectronReactClone, rode npm install e npm run start:desktop — o app abre servindo a SPA por app://github.com

Tem uma aba Diagnóstico lá dentro que vale a visita: ela mostra ao vivo o proxy limpando os headers, o redirect sendo seguido e o erro tratado quando o backend cai. Dá para trocar redirect: "follow" por "manual" e ver o bug do opaqueredirect acontecer em trinta segundos. 🔬

O que mais me marcou nessa jornada foi perceber que quase nenhum dos problemas era do Electron. Eram premissas da web que eu carreguei sem perceber: "o proxy existe", "redirect é transparente", "a env var está lá", "só existe uma janela". O Electron só foi o lugar onde essas premissas apareceram nuas. 😳

E o melhor: o código React não mudou quase nada. Uma função isElectron() de uma linha, alguns caminhos alternativos para o armazenamento local, e pronto. Toda a bagunça ficou na camada de baixo, exatamente onde ela deveria estar — que é o sinal de que o desenho está certo. ✨

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também