返回博客

Electron 打包后 runtime asset 消失:extraResources + process.resourcesPath 的正确姿势

用 Electron 做桌面 app,开发模式一切正常:托盘图标显示正常、配置文件读取正常。npm run dist 打出 dmg,安装启动,托盘变成空白,或者日志直接抛 ENOENT。翻了半天,文件明明在源码目录里——但打包后的 .app 里根本没有它。

这是 Electron 桌面开发里最经典的"dev/prod 路径撕裂"问题,踩一次之后很难忘。

现象

// dev 模式正常,packaged app 报 Empty Image 或 ENOENT
const iconPath = resolve(process.cwd(), '../assets/icon.png')
const tray = new Tray(iconPath)

或者:

const config = JSON.parse(
  fs.readFileSync(path.join(__dirname, '../config/defaults.json'), 'utf-8')
)
// packaged 后:Error: ENOENT: no such file or directory

根因

electron-builder 的 files 字段控制的是代码进 ASAR——它只打 JS bundle 和 package.json

files:
  - dist/src/**/*
  - package.json

assets/config/build/icon.png 这些 runtime asset 完全不在 packaged app 里

更深一层:打包后 process.cwd()__dirname 指向的路径彻底变了:

  • dev 模式:cwd = 项目根目录,__dirname = 源码目录,sibling 路径可以正常引用
  • packaged 模式(mac):cwd = <App>.app/Contents/MacOS/,源码 sibling 路径根本不存在
坑:process.env.NODE_ENV 不能判断是否已打包

很多人的第一反应是用 process.env.NODE_ENV !== 'production' 做分支。但 packaged app 里 NODE_ENV 有时仍然是 'development',判断会失效。

必须用 app.isPackaged,这是 Electron 提供的权威标志,打包后永远是 true

electron-builder 专门为此提供了 extraResources 字段——把任意文件作为 runtime resource 复制到 packaged app 的资源目录:

  • mac:<App>.app/Contents/Resources/<to>
  • win / linux:<InstallDir>/resources/<to>

对应的 runtime 路径通过 process.resourcesPath 拿到,跨平台一致。

正解

第一步:在 electron-builder.yml 声明 extraResources

extraResources:
  - from: build/icon.png        # 源路径相对于 electron-builder.yml 所在目录
    to: icon.png                # 目标路径相对于 Contents/Resources(mac)
  - from: config/defaults.json
    to: config/defaults.json
  - from: assets/
    to: assets/
    filter:
      - "**/*.ttf"              # 可按需过滤

第二步:runtime 用 app.isPackaged 做分支解析:

// asset-paths.ts
import { app } from 'electron'
import { join, resolve } from 'node:path'
 
export function runtimeAssetPath(relativeToResources: string): string {
  if (app.isPackaged) {
    return join(process.resourcesPath, relativeToResources)
  }
  // dev 模式回到源码目录
  return resolve(__dirname, '../../build', relativeToResources)
}
 
// 用法
const tray = new Tray(runtimeAssetPath('icon.png'))

第三步:加 try/catch 防止漏打 asset 时整 app 崩掉:

try {
  const tray = new Tray(runtimeAssetPath('icon.png'))
} catch (err) {
  console.error('tray icon load failed, running without tray', err)
}
正解:extraResources 声明 + app.isPackaged 分支 + process.resourcesPath

三件套缺一不可:

  1. electron-builder.ymlextraResources 把 asset 复制进去
  2. app.isPackaged 判断当前是 dev 还是 packaged(不要用 NODE_ENV)
  3. process.resourcesPath 拿 packaged 资源目录的绝对路径

验证方法

打包后直接 ls 看资源目录是否有文件:

# mac
ls "dist-package/mac/<AppName>.app/Contents/Resources/" | grep icon
# win:解压 installer 或安装后查 <InstallDir>/resources/

同时在主进程日志里打一下 process.resourcesPath 的实际值,mac 下应该是以 *.app/Contents/Resources 结尾的绝对路径。

延伸

  • extraResources 不参与 ASAR 压缩,文件原样保留,适合需要 fs.readFile 或外部进程直接访问的资源
  • 不要和 extraFiles 混淆:extraFiles 落在 app root 旁,不进 Resources/,路径不同
  • ASAR 内的文件读取用 app.getAppPath(),和 process.resourcesPath 是不同目录,别搞混
  • electron-forge 用户对应字段是 extraResource(注意没有 s

一句话外卖

electron-builderfiles 只打代码,runtime asset 必须走 extraResources + process.resourcesPath,用 app.isPackaged 而非 NODE_ENV 做 dev/packaged 分支判断。