Skip to content

Instantly share code, notes, and snippets.

@evanwalsh
Created August 28, 2025 13:47
Show Gist options
  • Select an option

  • Save evanwalsh/570e99942880a2db98c7fe58d049c610 to your computer and use it in GitHub Desktop.

Select an option

Save evanwalsh/570e99942880a2db98c7fe58d049c610 to your computer and use it in GitHub Desktop.
Electron Forge v7.8.1 + Vite + native modules issue
{
uniqueToX64: [
'Contents/Resources/app/node_modules/@bugsnag/plugin-electron-app/bin/darwin-x64-136/plugin-electron-app.node',
'Contents/Resources/app/node_modules/@bugsnag/plugin-electron-client-state-persistence/bin/darwin-x64-136/plugin-electron-client-state-persistence.node'
],
uniqueToArm64: [
'Contents/Resources/app/node_modules/@bugsnag/plugin-electron-app/bin/darwin-arm64-136/plugin-electron-app.node',
'Contents/Resources/app/node_modules/@bugsnag/plugin-electron-client-state-persistence/bin/darwin-arm64-136/plugin-electron-client-state-persistence.node'
]
}
✖ Stitching darwin/x64 and darwin/arm64 into a darwin/universal package [FAILED: While trying to merge mach-o files across your apps we found a mismatch, the number of mach-o files is not the same between the arm64 and x64 builds]
import {execSync} from 'child_process'
import {join} from 'path'
import {FuseV1Options, FuseVersion} from '@electron/fuses'
import {MakerSquirrel} from '@electron-forge/maker-squirrel'
import {MakerZIP} from '@electron-forge/maker-zip'
import {AutoUnpackNativesPlugin} from '@electron-forge/plugin-auto-unpack-natives'
import {FusesPlugin} from '@electron-forge/plugin-fuses'
import {VitePlugin} from '@electron-forge/plugin-vite'
import type {ForgeConfig} from '@electron-forge/shared-types'
import {copy, remove, mkdirp} from 'fs-extra'
const ASAR = false
const config: ForgeConfig = {
packagerConfig: {
asar: ASAR,
osxSign: {},
osxUniversal: {
singleArchFiles: '**/node_modules/**/*.node',
mergeASARs: ASAR,
},
},
hooks: {
packageAfterCopy: async (
_config,
buildPath,
_electronVersion,
_platform,
_arch
) => {
await copyProductionNodeModules(buildPath)
},
},
makers: [
new MakerSquirrel(),
new MakerZIP(),
],
plugins: [
new VitePlugin({
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
// If you are familiar with Vite configuration, it will look really familiar.
build: [
{
// `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
entry: 'src/main.ts',
config: 'vite.main.config.ts',
target: 'main',
},
{
entry: 'src/preload.ts',
config: 'vite.preload.config.ts',
target: 'preload',
},
],
renderer: [
{
name: 'main_window',
config: 'vite.renderer.config.ts',
},
],
}),
// Fuses are used to enable/disable various Electron functionality
// at package time, before code signing the application
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: false,
[FuseV1Options.OnlyLoadAppFromAsar]: false,
}),
...(ASAR ? [new AutoUnpackNativesPlugin({})] : []),
],
}
async function copyProductionNodeModules(
appOutDir: string,
appSrcDir = process.cwd()
) {
const tempDir = join(appSrcDir, '.electron-forge-tmp')
await remove(tempDir)
await mkdirp(tempDir)
await copy(join(appSrcDir, 'package.json'), join(tempDir, 'package.json'))
await copy(
join(appSrcDir, 'package-lock.json'),
join(tempDir, 'package-lock.json')
)
execSync('npm install --omit=dev', {cwd: tempDir, stdio: 'inherit'})
const targetNodeModules = join(appOutDir, 'node_modules')
await remove(targetNodeModules)
await copy(join(tempDir, 'node_modules'), targetNodeModules)
await remove(tempDir)
}
export default config
{
"name": "test-desktop",
"productName": "Test",
"version": "1.0.0",
"private": true,
"main": ".vite/build/main.js",
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"package:debug": "DEBUG='electron-forge:*' electron-forge package",
"make": "electron-forge make",
"make:debug": "DEBUG='electron-forge:*' electron-forge make",
},
"devDependencies": {
"@electron-forge/cli": "^7.8.1",
"@electron-forge/maker-squirrel": "^7.8.1",
"@electron-forge/maker-zip": "^7.8.1",
"@electron-forge/plugin-auto-unpack-natives": "^7.8.1",
"@electron-forge/plugin-fuses": "^7.8.1",
"@electron-forge/plugin-vite": "^7.8.1",
"@electron-forge/publisher-gcs": "^7.8.1",
"@electron/fuses": "^1.8.0",
"electron": "37.2.4",
"fs-extra": "^11.3.0",
"vite": "^6.3.5"
},
"dependencies": {
"@bugsnag/electron": "^8.4.0",
"electron-squirrel-startup": "^1.0.1",
"keytar": "^7.9.0",
"ntp-time-sync": "^0.5.0"
},
"volta": {
"node": "22.18.0",
"npm": "11.5.2"
}
}
@evanwalsh
Copy link
Author

For posterity, here's how I fixed this:

  1. Used ASARs and set mergeASARs to true.
  2. Set singleArchFiles to 'node_modules/**/*'
  3. Added a packageAfterPrune hook that removed build artifacts from the Bugsnag directories that were problematic.
  4. Swore to never use a computer again

packageAfterPrune: async (
  _config,
  buildPath,
  _electronVersion,
  _platform
) => {
  const nodeModulesPath = join(buildPath, 'node_modules')
  await removeProblematicBuiltFiles(nodeModulesPath)
}
async function removeProblematicBuiltFiles(nodeModulesPath: string) {
  const archSpecificPatterns = [
    '**/build/Makefile',
    '**/build/Release/.deps/**',
    '**/build/Release/.forge-meta/**',
    '**/build/Release/obj.target/**',
    '**/build/*.target.mk',
    '**/build/config.gypi',
    // I'm leaving these here in case it's needed in the future
    // x64 ----------------------------
    // '**/bin/darwin-x64-*/**',
    // '**/prebuilds/darwin-x64/**',
    // '**/*-darwin-x64.node',
    // arm64 --------------------------
    // '**/bin/darwin-arm64-*/**',
    // '**/prebuilds/darwin-arm64/**',
    // '**/*-darwin-arm64.node',
  ]

  for (const pattern of archSpecificPatterns) {
    const files = globSync(join(nodeModulesPath, pattern))
    for (const file of files) {
      console.log(
        `Removing problematic file: ${file.replace(nodeModulesPath, '')}`
      )
      await remove(file)
    }
  }

  // I'm leaving this here in case it's needed in the future

  // const emptyDirs = globSync(
  //   join(nodeModulesPath, '**/bin/darwin-{x64,arm64}-*'),
  //   {onlyDirectories: true}
  // )
  // for (const dir of emptyDirs) {
  //   try {
  //     await remove(dir)
  //     console.log(
  //       `Removed empty directory: ${dir.replace(nodeModulesPath, '')}`
  //     )
  //   } catch (error) {
  //     console.error('Error removing prebuilt directory:', error)
  //     // Directory might not be empty or already removed
  //   }
  // }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment