混淆 Electron 应用

@afterpack/electron 用一次调用、一个种子接入 main、preload 和 renderer,并在 electron-vite 的 bytecodePlugin 和缺乏防护的备份把你的源代码发布出去之前拒绝它们。

@afterpack/electron 用一次调用混淆一个 Electron 应用所发布的全部三个程序(main、preload、renderer),并让它们共享同一个构建种子。如果手动接入,每一部分都是各自独立的打包工具构建,各自取一个随机种子,于是同一个应用的各个部分产出的结构彼此毫无关联。

electron-builder 不是打包工具。它把已经构建好的应用打包成安装程序,从不触碰你的 JavaScript,因此无论是否使用 AfterPack,它上游都需要一个打包工具。

安装

$ 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 包裹整个 electron-vite 配置(即 defineConfig 返回的对象、函数或 Promise 形式),并把 @afterpack/vite 接入该配置声明的每一部分,未声明的部分则不会接入。对等依赖:vite ^5 \| ^6 \| ^7 \| ^8。

构建

如何接入这一遍处理,取决于你的工具链是用一次调用配置全部三部分,还是每部分一个配置。

一次一个部分

分别配置每一部分的工具链(使用 @electron-forge/plugin-vite 的 Electron Forge)改为在每个配置中各调用一次 afterpackElectron:

// 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 是必填项。

一个种子,三种方式

  • **electron-vite(withAfterpack)**和 Forge + plugin-vite:三个配置都在同一个 Node 进程中构建,因此各部分会自动共享一个种子。
  • vite-plugin-electron:同样是一个进程。把 afterpackElectron({ leg: "renderer" }) 放在 plugins 的最后,位于 vite-plugin-electron 本身之后,这样 AfterPack 的 generateBundle 会对最终完成的 bundle 收尾。
  • 把每一部分作为单独命令运行的手写脚本:没有共享的进程,因此用环境变量在它们之间固定种子:
// package.json
"build": "AFTERPACK_seed=git vite build -c vite.main.ts && vite build -c vite.renderer.ts"

AFTERPACK_seed 接受的取值与 seed 相同:一个整数、"git"(由 HEAD 派生),或者任何其他字符串(由引擎进行哈希)。显式的 seed 选项仍然优先于它。何时该固定种子、何时不该,参见种子策略。

验证

Protection Map 会在能找到 source map、且构建没有在 CI 中(CI=true 或 CI=1)运行时,按部分写入 .afterpack/<leg>/protectionMap.html。生产模式不会关闭它,但除非你要求,electron-vite 不会输出 source map,因此 Protection Map 默认关闭。请分别检查 main、preload 和 renderer。每份报告都包含你的完整源码。绝不要提交或对外提供它。

绝不要在应用内发布 source map

app.asar 中的一个 .map 就能直达你的原始源码。对于任何要打包的内容,都要保持 source map 输出关闭(生产环境的默认值)。

选项

每个配置键在这里同样可用,可以通过 withAfterpack(config, options) 应用到每一部分,或者通过 afterpackElectron(options) 按部分传入。以下选项只存在于这个包中:

选项类型默认值
leg"main" | "preload" | "renderer"在 afterpackElectron 上必填
projectRootstringprocess.cwd()。electron-vite 把 renderer 构建的根目录设在 src/renderer/。如果你的构建从其他位置运行,请覆盖它

seed 默认为一个新值,由同一构建会话中的每一部分共享。protectionMap.enabled 遵循通常的 source map 规则,每一部分都会把自己的报告写入 .afterpack/<leg>/protectionMap.html。

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

指令

每一部分底层接入的都是 @afterpack/vite,因此指令捕获在这里的工作方式完全相同。

边界情况

支持矩阵

工具链接入方式自动共享一个种子?
electron-vitewithAfterpack(defineConfig({ … })),一次调用是
Forge + plugin-vite在三个配置中各使用 afterpackElectron({ leg })是
Forge + plugin-webpack在每个配置中使用 @afterpack/webpack是
vite-plugin-electronafterpackElectron({ leg: "renderer" }),放在最后是,只要放在该插件之后
electron-builder它负责打包安装程序,不负责打包代码;请接入它上游的打包工具不适用
手写脚本(独立进程)每部分一个插件,或 npx afterpack@latest <dir>否;请固定 AFTERPACK_seed

它会拒绝什么

通过这个包接入的任何部分都会应用两项失败即中止的检查。

**bytecodePlugin。**electron-vite 自带的 bytecodePlugin 会把每个输出的 chunk 替换成一个 3 行的加载器桩代码,而把真正的应用以 V8 字节码(.jsc)形式发布。两者无法组合使用,因为字节码替换的正是 Rollup 生成的 chunk。在启用了 bytecodePlugin 的那一部分上,或者之后只要输出中出现 .jsc/.cjsc 文件,构建就会失败。二者只能择其一。

build.backup: true。备份会被直接拒绝:调用会抛出异常而不是继续执行,因为 .backup.<hash>.js 就是你完整的原始源代码,它被写在输出旁边,而这个目录树正是打包工具复制进 app.asar 的内容。每个安装该应用的用户都会拿到它。

同样的风险也适用于打包工具不知道要排除的任何产物。electron-builder 默认的 files glob 以 dot: true 匹配,且默认不排除 .afterpack/。请把以下内容从打包范围中排除:

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

source map 只会收到警告而不会被拒绝:app.asar 中一个带有 sourcesContent 的 .map 就等于完全去混淆。把 sourceMap: { enabled: true, emitUrl: true } 限定在本地调试构建中,并把 *.map 从打包范围中排除。

IPC 通道

通道名在两个 bundle 之间必须逐字节一致。ipcMain.on("save-file", …) 和 ipcRenderer.send("save-file", …) 两端是同一个字符串。它们是普通输出 JavaScript 中的普通字符串字面量,因此让两部分使用相同的种子和相同的保护强度(preset / complexity),就能让两半保持一致。

如果某个通道名是在运行时拼出来的,而不是写成字面量,就把这段构造代码放进一个用 paths.exclude 排除的模块中,或者通过打包工具插件的 regions 条目降低该区域的保护强度(Pro)。

不使用插件:CLI

不想增加依赖,或者某一部分不是用 Vite 构建的?那就在构建之后,对每个输出目录各运行一次 npx afterpack@latest:

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

不带路径参数时,CLI 会检测 out/,即 electron-vite 写入的目录;如果你的目录不同,请指定它。每次运行都会遍历目录,收集每一个 .js/.mjs/.cjs,并就地混淆它们;也可以把单个文件作为参数。在每次运行中固定同一个 --seed:由当前提交派生的 git 是最简单的保证方式。CLI 运行不会执行上述任何拒绝检查;它会混淆找到的所有 JavaScript。请在各次运行中保持相同的 --seed 和 --preset。

下一步