This page is available in English only.

Obfuscate an Electron app

@afterpack/electron wires main, preload and renderer with one seed in a single call, and refuses electron-vite's bytecodePlugin and unguarded backups before they can ship your source.

@afterpack/electron obfuscates all three programs one Electron app ships (main, preload, renderer) from a single call, sharing one build seed across them. Wired by hand, each leg is its own bundler build and draws its own random seed, so the parts of one application come out structurally unrelated to each other.

electron-builder is not a bundler. It packages an already-built app into an installer and never touches your JavaScript, so it needs a bundler above it regardless of AfterPack.

Install

$ npm install -D @afterpack/electron
// electron.vite.config.ts
import { withAfterpack } from "@afterpack/electron";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";

export default withAfterpack(
  defineConfig({
    main: { plugins: [externalizeDepsPlugin()] },
    preload: { plugins: [externalizeDepsPlugin()] },
    renderer: {},
  }),
);

withAfterpack wraps an entire electron-vite config (the object, function or promise form defineConfig returns) and wires @afterpack/vite into every leg the config declares, never one it doesn't. Peer dependency: vite ^5 \| ^6 \| ^7 \| ^8.

Build

How you wire the pass depends on whether your toolchain configures all three legs in one call, or one config per leg.

One leg at a time

Toolchains that configure each leg separately (Electron Forge with @electron-forge/plugin-vite) call afterpackElectron once per config instead:

// vite.main.config.ts   (and vite.preload.config.ts / vite.renderer.config.ts)
import { afterpackElectron } from "@afterpack/electron";

export default { plugins: [afterpackElectron({ leg: "main" })] };

leg is required.

One seed, three ways

  • electron-vite (withAfterpack) and Forge + plugin-vite: all three configs build in one Node process, so the legs share a seed automatically.
  • vite-plugin-electron: also one process. Place afterpackElectron({ leg: "renderer" }) last in plugins, after vite-plugin-electron itself, so AfterPack's generateBundle seals the finished bundle.
  • Hand-rolled scripts that run each leg as a separate command: there is no shared process, so pin the seed across them with an environment variable:
// package.json
"build": "AFTERPACK_seed=git vite build -c vite.main.ts && vite build -c vite.renderer.ts"

AFTERPACK_seed takes the same values as seed: an integer, "git" (derived from HEAD), or any other string, hashed by the engine. An explicit seed option still wins over it. Seed strategy covers when to pin one and when not to.

Verify

The Protection Map is written per leg, into .afterpack/<leg>/protectionMap.html, when a source map was discoverable and the build isn't running under NODE_ENV=production / CI=true. Inspect main, preload and renderer separately. Each report contains your full source. Never commit it or serve it.

Never ship source maps inside the app

A .map in app.asar reaches your original source. Keep source-map emission off (the default in production) for anything you package.

Options

Every configuration key works here too, applied to every leg through withAfterpack(config, options) or forwarded per leg through afterpackElectron(options). These options exist only on this package:

OptionTypeDefault
leg"main" | "preload" | "renderer"required on afterpackElectron
projectRootstringprocess.cwd(). electron-vite roots the renderer build at src/renderer/. Override it if your build runs from somewhere else

seed defaults to one fresh value shared by every leg in a build session. protectionMap.enabled follows the usual source-map rule, and each leg writes its own report to .afterpack/<leg>/protectionMap.html.

// Heavier protection, reproducible per commit, applied to every leg:
withAfterpack(config, { preset: "hard", seed: "git" });

Directives

Each leg wires @afterpack/vite underneath, so directive capture works the same way there.

Edge cases

Support matrix

ToolchainWiringOne seed automatically?
electron-vitewithAfterpack(defineConfig({ … })), one callYes
Forge + plugin-viteafterpackElectron({ leg }) in each of the three configsYes
Forge + plugin-webpack@afterpack/webpack in each configYes
vite-plugin-electronafterpackElectron({ leg: "renderer" }), placed lastYes, once placed after that plugin
electron-builderit packages, it doesn't bundle; wire the bundler above itn/a
Hand-rolled scripts (separate processes)a plugin per leg, or npx afterpack@latest <dir>No; pin AFTERPACK_seed

What it refuses

Two fail-closed checks apply on any leg wired through this package.

bytecodePlugin. electron-vite's own bytecodePlugin replaces every emitted chunk with a 3-line loader stub and ships the real application as V8 bytecode (.jsc). The two cannot compose, because bytecode replaces the chunk Rollup generated. The build fails on the leg where bytecodePlugin is enabled, or wherever a .jsc/.cjsc file turns up in the output afterward. Drop one or the other.

build.backup: true. Backups are refused outright: the call throws instead of proceeding, because a .backup.<hash>.js is your complete original source, written next to the output inside the tree your packager copies into app.asar. Every user who installs the app would receive it.

The same risk extends to any artifact your packager doesn't know to exclude. electron-builder's default files glob matches with dot: true and does not exclude .afterpack/ by default. Exclude these from what you package:

// electron-builder
"files": ["**/*", "!.afterpack/**", "!**/*.backup.*", "!**/*.map"]
// electron-forge
packagerConfig: { ignore: [/^\/\.afterpack/, /\.backup\./, /\.map$/] }

Source maps get a warning rather than a refusal: a .map with sourcesContent inside app.asar is full deobfuscation. Keep sourceMap: { enabled: true, emitUrl: true } to local debug builds and exclude *.map from what you package.

IPC channels

Channel names must match byte-for-byte across the two bundles. ipcMain.on("save-file", …) and ipcRenderer.send("save-file", …) are the same string on both sides. They're ordinary string literals in ordinary emitted JavaScript, so keeping both legs on the same seed and the same posture (preset / complexity) is what keeps the two halves consistent.

If a channel name is assembled at runtime rather than written as a literal, keep that construction in a module you exclude with paths.exclude, or lower the posture for that region through a bundler-plugin regions entry (Pro).

Without the plugin: the CLI

Prefer not to add a dependency, or building a leg with something other than Vite? Run npx afterpack@latest once per output directory after the build instead:

npx afterpack@latest out/main --seed=git
npx afterpack@latest out/renderer --seed=git

The path argument is required; there's no auto-detection. Each run walks the directory, collects every .js/.mjs/.cjs, and obfuscates them in place; a single file works as the argument too. Pin the same --seed across every run: git, derived from the current commit, is the simplest way to guarantee it. A CLI run gets none of the refusals above; it obfuscates whatever JavaScript it finds. Keep the same --seed and --preset across runs.

Next