本页目录

本地打包、制品校验与签名更新边界

区分开发运行、应用目录与分发格式,理解 Forge、ASAR、原生模块和更新安全。

L2 · 能交付约 16 分钟阅读含示例、练习与验收
本页内容

先区分三种“做出来了”#

开发运行表示 Electron 从你的项目目录加载源码或编译产物,依赖开发工具和当前机器环境。package 表示把应用代码、运行时和必要资源整理成可运行的应用目录。installer 或其他分发格式则在这个目录之外增加安装、卸载、快捷方式、平台元信息或归档能力。三者不是同一个按钮的不同叫法,前一步成功不能证明后一步在另一台机器上能运行。

本章适用于准备把应用本地交付给自己或受控测试者的阶段。当前教材只要求本地交付,不要求注册发布账号、购买证书或把文件上传公网。你需要学会判断包里应该有什么、运行时从哪里找资源、原生依赖为什么可能失效,以及未来签名和更新承担什么职责。不会实际执行打包、签名、公证、安装或发布。

前置是能运行 Electron 应用并理解安装目录与 userData 的区别。例子故意使用很小的静态窗口,不再造完整笔记产品。两个实验分别是完整 Forge 本地构建配置与实际可运行的 Node 制品清单校验,后者不能替代真实安装版验收。

Forge 与 Packager 处于哪一层#

Electron 本体提供桌面运行时,应用打包由工具链完成。Electron Packager 负责整理应用及目标 Electron 运行时;Forge 在其上编排开发、package、make 等阶段,并通过 maker 生成指定分发格式。publisher 才负责把产物发送到外部平台,本章不配置 publisher,也不执行 publish。Forge 构建生命周期

二〇二六年九月十一日核对的包版本是 Electron 44.3.0、@electron-forge/cli 7.11.2、@electron-forge/maker-zip 7.11.2,以及独立 @electron/packager 20.3.0。后者用于认识当前受维护的工具名称,不需要再额外安装进本章 Forge 项目。不要把网上旧教程里的非 scoped 包名和最新配置拼在一起,也不要推断 Forge 内部恰好使用你查到的独立 Packager 最新版本。

同一项目一般选一套编排工具作为入口。直接运行 Packager 与运行 Forge package,配置来源和钩子可能不同;两种命令混着使用时,容易出现某次包含了新 preload,另一次忘记复制。应保留明确 npm scripts 与锁文件,让“我执行哪个命令得到哪类产物”成为项目合同。

实验一:本地应用目录与 ZIP 分发包#

建立 local-package-lab,保存下面四个完整文件。首次 npm install 会下载依赖和 Electron 运行时;本文只提供步骤,没有替你下载。此例没有原生模块、图标、签名和自动更新,便于先观察文件布局与生命周期。

package.json
{
  "name": "local-package-lab",
  "productName": "Local Package Lab",
  "version": "1.0.0",
  "description": "Local packaging teaching fixture",
  "author": "Course Learner",
  "private": true,
  "main": "main.cjs",
  "scripts": {
    "start": "electron-forge start",
    "package": "electron-forge package",
    "make": "electron-forge make"
  },
  "config": { "forge": "./forge.config.cjs" },
  "devDependencies": {
    "electron": "44.3.0",
    "@electron-forge/cli": "7.11.2",
    "@electron-forge/maker-zip": "7.11.2"
  }
}
forge.config.cjs
module.exports = {
  packagerConfig: {
    asar: true,
    // 排除本例可能出现的环境文件与报告;不能代替打包前的内容审查。
    ignore: [/^\/\.env(?:\.|$)/, /^\/reports(?:\/|$)/]
  },
  rebuildConfig: {},
  makers: [{ name: "@electron-forge/maker-zip", config: {} }]
};
main.cjs
const { app, BrowserWindow, session } = require("electron");
const path = require("node:path");
app.whenReady().then(async () => {
  session.defaultSession.setPermissionRequestHandler((_wc, _permission, done) => done(false));
  session.defaultSession.setPermissionCheckHandler(() => false);
  const win = new BrowserWindow({
    width: 720, height: 420,
    title: "Package lab " + app.getVersion(),
    webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false }
  });
  win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
  win.webContents.on("will-navigate", event => event.preventDefault());
  await win.loadFile(path.join(__dirname, "index.html"));
  console.log({ packaged: app.isPackaged, version: app.getVersion(),
    platform: process.platform, arch: process.arch });
}).catch(error => { console.error(error); app.exit(1); });
app.on("window-all-closed", () => app.quit());
index.html
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="Content-Security-Policy"
    content="default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'">
  <title>本地交付实验</title>
</head>
<body>
  <h1>应用资源已加载</h1>
  <p>请从打包后的应用目录启动,并核对窗口与文件布局。</p>
</body>
</html>
bash
npm install
npm start
npm run package
npm run make

先关闭开发窗口,再逐条执行后续命令,观察终端是否真正以零退出。package 预期在 out 下生成当前平台与架构的应用目录;make 会执行所需的构建阶段,并在 out/make 下生成 ZIP。具体名称来自产品名、版本、平台和工具配置,应读取实际输出,不在脚本里猜一个固定文件名。ZIP 是包含应用的归档,不等于带安装流程的安装器。ZIP maker

name 是 npm 项目标识,productName 是面向用户的产品名称,version 参与应用版本与分发元信息。main 指向打包后也必须存在的入口。private:true 防止误向 npm 发布包,不会自动阻止其他发布工具;真正限制本例不发布的是没有 publisher 配置与不执行发布命令。不要把一个 npm 字段当成对所有外部动作的安全锁。

asar:true 将应用文件放进归档,减少松散文件并提供 Electron 可读取的应用资源结构。Electron 对许多文件读取提供 asar 支持,但归档内部不是普通可写目录,某些需要真实磁盘路径的 API 或原生文件有额外限制。应用配置仍应放在 userData,不因代码路径位于 app.asar 就尝试往其中写入。

ASAR 不是加密,也不是发布者身份#

asar 可以被读取和提取,不能用来保护硬编码 API key。压缩、混淆、归档和加密是不同机制;把源码放进归档最多改变组织形式,并不让秘密从客户端消失。所有交付给用户设备的长期共享密钥都需要重新考虑信任模型,不能指望用户“看不到源码”。ASAR 语义

完整性校验也不等于保密。哈希可以帮助发现文件与已知清单不同,但如果攻击者同时替换文件和清单,单独 SHA-256 不能证明发布者。签名把内容与可信身份或密钥建立关系,验证仍依赖信任根与分发流程。教材后面的清单实验只做字节一致性,不把它称作代码签名。

打包目录应只包含运行需要的内容。开发日志、临时数据库、测试密钥和真实用户样本可能因为“复制整个项目”被带进包。排除规则必须结合实际产物检查,尤其依赖的复制行为可能由插件和打包器共同决定。构建时先看资源清单,再在干净目录启动,比在源码目录里反复 npm start 更能暴露遗漏文件。

资源路径为什么在打包后改变#

开发时 __dirname 通常指向源码所在目录,打包后可能位于 asar 内部;process.resourcesPath 指向运行时资源目录;userData 用于用户可写数据;process.cwd() 则由启动方式决定。它们解决不同问题。静态页面随代码一起打包时可按模块位置找,外置模型或二进制资源应按打包配置使用明确资源位置,不能依赖当前终端目录恰好是项目根。

绝对开发路径最容易漏到成品中。例如在本机选择了一个图标文件,再把 C:\Users 下完整路径写进 main,本机可能运行正常,另一台机器必然找不到。资源应纳入受控构建输入,通过相对结构定位。需要用户选择的外部文件则保存业务引用与访问策略,不应把开发机路径当作安装包资源。

原生模块更复杂:JavaScript 包存在,不代表内部二进制适配 Electron。系统 Node 的 ABI、Electron 内置 Node 的 ABI、操作系统与 CPU 架构都可能不同。错误信息包含 NODE_MODULE_VERSION 或找不到 .node 文件时,先记录实际 Electron、process.versions.modules、平台与架构,再核对重建与解包策略,避免无目的地修改 bridge 安全选项。

Forge 可以在构建流程中处理原生依赖重建,但具体模块是否提供预构建产物、编译环境是否齐全、是否支持目标架构,仍需要验证。跨平台打包不是万能转换器,某些 maker、签名工具与平台原生组件必须在相应系统执行。即使 ZIP maker 本身可跨平台运行,也不能推出任意应用可以在任意机器上构建并验收所有平台。

实验二:用离线清单发现漏文件与字节变化#

在 inventory-lab 保存 inventory.cjs 与 inventory-check.cjs,执行 node inventory-check.cjs。它只读取你明确给定的目录,拒绝符号链接,生成相对路径、长度和哈希。实验使用临时 fixture,不访问实际打包产物;未来可以将 root 替换成自己控制的应用目录,再把清单保存在目录之外。

inventory.cjs
const fs = require("node:fs/promises");
const path = require("node:path");
const { createHash } = require("node:crypto");
async function inventory(root) {
  const files = [];
  async function walk(directory) {
    const entries = await fs.readdir(directory, { withFileTypes: true });
    for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, "en"))) {
      const full = path.join(directory, entry.name);
      if (entry.isSymbolicLink()) throw new Error("SYMLINK_NOT_SUPPORTED");
      if (entry.isDirectory()) await walk(full);
      else if (entry.isFile()) {
        const bytes = await fs.readFile(full);
        files.push({
          path: path.relative(root, full).split(path.sep).join("/"),
          bytes: bytes.length,
          sha256: createHash("sha256").update(bytes).digest("hex")
        });
      } else throw new Error("UNSUPPORTED_ENTRY");
    }
  }
  await walk(root);
  return { format: 1, files };
}
function compareInventory(expected, actual) {
  const before = new Map(expected.files.map(file => [file.path, file]));
  const after = new Map(actual.files.map(file => [file.path, file]));
  const missing = [], changed = [], extra = [];
  for (const [name, file] of before) {
    if (!after.has(name)) missing.push(name);
    else if (file.bytes !== after.get(name).bytes || file.sha256 !== after.get(name).sha256) {
      changed.push(name);
    }
  }
  for (const name of after.keys()) if (!before.has(name)) extra.push(name);
  return { missing, changed, extra, ok: !missing.length && !changed.length && !extra.length };
}
module.exports = { inventory, compareInventory };
inventory-check.cjs
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { inventory, compareInventory } = require("./inventory.cjs");
(async () => {
  const tempBase = await fs.realpath(os.tmpdir());
  const tempPrefix = "artifact-lab-";
  const root = await fs.mkdtemp(path.join(tempBase, tempPrefix));
  try {
    await fs.mkdir(path.join(root, "assets"));
    await fs.writeFile(path.join(root, "main.cjs"), "entry-v1");
    await fs.writeFile(path.join(root, "assets", "screen.html"), "page-v1");
    const baseline = await inventory(root);
    assert.equal(compareInventory(baseline, await inventory(root)).ok, true);
    await fs.writeFile(path.join(root, "main.cjs"), "entry-v2");
    await fs.rm(path.join(root, "assets", "screen.html"));
    await fs.writeFile(path.join(root, "unexpected.txt"), "extra");
    const report = compareInventory(baseline, await inventory(root));
    assert.deepEqual(report.changed, ["main.cjs"]);
    assert.deepEqual(report.missing, ["assets/screen.html"]);
    assert.deepEqual(report.extra, ["unexpected.txt"]);
    assert.equal(report.ok, false);
    console.log("inventory checks passed");
  } finally { const resolved = await fs.realpath(root);
    if (path.dirname(resolved) !== tempBase || !path.basename(resolved).startsWith(tempPrefix)) {
      throw new Error("UNSAFE_TEMP_CLEANUP");
    }
    await fs.rm(resolved, { recursive: true, force: true }); }
})().catch(error => { console.error(error); process.exitCode = 1; });

输出 inventory checks passed 表示三类差异被正确区分。哈希覆盖文件字节,所以相同文件名与大小也不能冒充相同内容。清单没有验证可执行文件签名、权限位、所有者、平台扩展属性或 asar 内部每个条目,也没有证明应用能够启动。真实制品中的符号链接,尤其某些平台应用结构,需要专门支持;本实验明确拒绝,避免遍历到目录外部。

这个实验还帮助理解可重复构建的边界。相同源码不一定产生逐字节相同产物,时间戳、工具版本、签名和平台元数据都可能参与输出。发现哈希差异后应解释来源,而不是直接断言构建被攻击。反过来,只比较 package.json 版本也远远不够,同版本文件仍可能被意外替换。

签名、公证与更新各自证明什么#

代码签名帮助操作系统与用户核对软件来源和内容完整性,具体流程因平台不同。macOS 分发通常还涉及 Apple 的公证服务及相关结果处理,Windows 使用相应的代码签名体系。签名成功不证明业务逻辑没有漏洞,也不保证所有安全提示永远消失;签名身份、证书有效性、时间戳与分发场景都需要正确管理。

私钥与签名凭据属于构建环境秘密,不能写进 renderer、源码仓库或公开日志。首次学习可以先做本地未签名构建,并如实说明平台可能限制运行;不要把让用户关闭系统保护作为正式分发方案。本轮教材没有申请证书、访问公证服务或修改系统安全设置,相关内容用于理解将来工作边界。代码签名

自动更新也不是“下载新 exe 后执行”。它涉及版本判断、平台与架构、可信元数据、完整性、签名、下载中断、安装时机和失败恢复。不同安装格式有不同更新机制,Electron 内置 autoUpdater 的支持范围与平台实现也需要单独核对。不要在 renderer 里接收任意 URL 后下载执行,更不要把模型生成的地址当作更新源。更新机制

应用代码回退与数据回退不能混为一谈。新版本已经升级数据库后,旧二进制不一定能读取;自动更新失败时重新打开旧版本,也可能触发未知 schema。数据迁移应有兼容策略与恢复点。用户正在编辑时强制退出安装新版本会造成损失,因此更新交互也属于产品任务,需要明确保存、取消和重启时机。

应用目录能启动之后,还要离开开发者环境#

开发者机器通常已经安装编译工具、系统库和各种运行时,这会掩盖分发问题。把应用目录放到另一个干净路径启动,可以先发现对项目根目录和绝对路径的依赖;在受控测试机器或独立系统环境运行,才能进一步发现缺少系统组件、权限和原生二进制的问题。复制一个 exe 而不带旁边资源,通常不是完整应用交付。

验收应记录入口文件而不是只记录“打开成功”。同一机器上可能同时有开发版、解压版和安装版,快捷方式指向哪一个决定实际运行内容。窗口标题中的版本只能帮助识别,最好再通过受控诊断读取 app.isPackaged 与运行路径。不要把仍由 npm start 启动的窗口误当作刚生成的安装版。

用户数据要在更新与卸载过程中保持明确策略。程序文件可以替换,用户内容是否保留由产品决定;卸载器默认行为与应用主动清理行为也可能不同。测试卸载不能在真实唯一数据上试验,应使用专用测试账户或数据目录。安装包功能越多,越需要区分程序生命周期和用户资料生命周期。

构建输入与输出应当可以追溯#

一份本地制品至少应关联源码修订、锁文件、Electron 版本、平台架构与构建命令。若同一个版本号可以对应多次不同构建,应另有构建标识或制品摘要,方便确认收到的是哪一份文件。版本号是面向产品的标识,哈希是字节身份,两者不能互相替代。

在同一次构建中混入运行时下载的未固定资源,会降低可追溯性。图标、模板和必要离线资源应有明确来源与版本;需要独立更新的大资源则应设计校验和兼容合同。不要在安装后第一次启动时静默下载任意脚本来补齐打包遗漏,那会把构建问题转成供应链与运行权限问题。

构建失败后的半成品不应混进下一次交付。清理范围应限定在工具管理的输出目录,保留用于诊断的必要日志,不要为了“干净”删除用户数据或重置整个工作区。成功标准是命令退出、产物清单与运行验证共同成立,而不是看到 out 目录里有一个旧文件就宣布生成成功。

原生模块排错应保存实际错误文本#

常见问题包括找不到二进制、ABI 不匹配、系统动态库缺失和架构不匹配,它们的修复路径不同。找不到文件先检查是否被打包排除或没有正确解包;ABI 不匹配检查 Electron 目标重建;系统库缺失检查平台依赖;架构错误核对 x64 与 arm64。不要仅凭“原生模块报错”就同时更换所有依赖。

重建成功也不等于模块运行正确。数据库驱动需要打开、查询、写入和关闭的最小检查;图像库需要处理代表性输入;调用系统能力的模块需要实际平台权限。检查应在目标 Electron 运行时执行,普通 node 脚本加载成功只能证明终端 Node 那一套环境。

本章没有原生模块,故不能通过这个小窗口宣称整条原生构建链已验证。选择这样的小实验,是为了先观察打包阶段和目录语义;将来引入 SQLite 时,再添加专门的目标运行时检查。教学上逐层增加变量,比一开始复制巨大配置更容易知道每个字段为何存在。

本地交付也可以有清楚的完成定义#

一个受控本地交付可以是已验证的应用目录加说明文件,也可以是 ZIP;是否需要安装器取决于快捷方式、更新和卸载需求。无需为了证明自己学会 Electron 就先接入公网更新平台。先确保用户能够识别版本、启动、完成关键任务、正常退出,并知道数据存在哪里,已经构成有价值的交付闭环。

如果以后需要正式分发,再增加签名、公证、安装与更新的对应步骤,并在真实目标平台逐项验证。它们不是可以在文档中打勾的抽象能力,而是依赖证书、操作系统和分发格式的具体流程。保持本地阶段与正式分发阶段的证据边界,能让你清楚看到下一步需要的环境与工作。

练习:把清单校验变成可重复执行的命令#

要求接受两个明确文件参数,读取基线与当前清单,差异时输出报告并以非零退出。不要自动寻找系统里的应用目录,也不要校验失败后删除产物。保存 verify-inventory.cjs,与 inventory.cjs 同目录。下面是完整实现;基线与当前清单由 inventory 函数生成并另存,文件内容不是外部签名格式。

完整参考实现
verify-inventory.cjs
const fs = require("node:fs/promises");
const { compareInventory } = require("./inventory.cjs");
function parseManifest(value) {
  if (value?.format !== 1 || !Array.isArray(value.files)) throw new Error("INVALID_MANIFEST");
  const seen = new Set();
  for (const file of value.files) {
    if (!file || typeof file.path !== "string" || !file.path ||
        file.path.includes("\\") || file.path.startsWith("/") ||
        file.path.split("/").some(part => !part || part === "." || part === "..") ||
        seen.has(file.path) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 ||
        !/^[0-9a-f]{64}$/.test(file.sha256)) throw new Error("INVALID_MANIFEST_ENTRY");
    seen.add(file.path);
  }
  return value;
}
(async () => {
  const [baseline, current, extra] = process.argv.slice(2);
  if (!baseline || !current || extra) throw new Error("用法:node verify-inventory.cjs baseline.json current.json");
  const before = parseManifest(JSON.parse(await fs.readFile(baseline, "utf8")));
  const after = parseManifest(JSON.parse(await fs.readFile(current, "utf8")));
  const report = compareInventory(before, after);
  console.log(JSON.stringify(report, null, 2));
  process.exitCode = report.ok ? 0 : 1;
})().catch(error => { console.error(error.message); process.exitCode = 2; });

退出零表示清单相同,退出一表示存在差异,退出二表示输入或运行错误。把“比较不同”和“根本没有完成比较”分开,CI 才能给出正确原因。不要因为两个错误输入都无法解析,就把它们视作相等;也不要忽略未知字段后让关键路径重复覆盖 Map 中之前的记录,所以参考实现先拒绝重复路径。

最终本地验收应从实际应用目录启动,关闭开发服务器,确认入口、静态资源、用户数据位置与退出行为;再在目标平台核对原生依赖和首次启动。打包命令成功只证明工具生成了产物,不能代替这些检查。本文实际验证的是 Node 清单程序和失败退出,Forge 构建、安装、签名、公证与更新均未执行。自测一:ZIP 就是安装器吗?不是。自测二:asar 能保存秘密吗?不能。自测三:源码模式启动成功能证明原生模块在安装版可用吗?不能。

本章验证记录#

Node 22.22.0 已运行 inventory-check.cjs;verify-inventory.cjs 对相同、变化和重复路径非法清单分别退出零、一、二,符合合同。Forge 配置与 main.cjs 仅语法检查,package.json 解析通过。未下载构建工具、安装 Electron、运行 package/make、验证安装器或执行签名、公证、发布。

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭