diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index b4017a7f9..20ba91632 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -1,6 +1,7 @@ const { chmodSync, existsSync, readdirSync } = require('node:fs') const { execFileSync } = require('node:child_process') const { join, resolve } = require('node:path') +const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' const featureWallResources = { @@ -230,11 +231,13 @@ module.exports = { artifactName: 'orca-ide-${version}.${arch}.${ext}', depends: ['python3', 'python3-gobject', 'at-spi2-core', 'xdotool', 'xclip'] }, + beforeBuild: electronBuilderNativeRebuild, // Why: must be true so that electron-builder rebuilds native modules // (node-pty) for each target architecture when producing dual-arch macOS // builds (x64 + arm64). With npmRebuild disabled, CI on an arm64 runner // packages arm64 binaries into the x64 DMG, causing "posix_spawnp failed" - // on Intel Macs. + // on Intel Macs. The beforeBuild hook performs Orca's targeted rebuild and + // returns false so electron-builder does not rebuild optional cpu-features. npmRebuild: true, publish: { provider: 'github', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 4e4938a35..d4d36cfc7 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) const electronBuilderConfig = require('../electron-builder.config.cjs') +const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs') describe('electron-builder config', () => { it('uses the multi-size icon source for Linux packages', () => { @@ -18,4 +19,9 @@ describe('electron-builder config', () => { artifactName: 'orca-ide-${version}.${arch}.${ext}' }) }) + + it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => { + expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild) + expect(electronBuilderConfig.npmRebuild).toBe(true) + }) }) diff --git a/config/scripts/electron-builder-native-rebuild.cjs b/config/scripts/electron-builder-native-rebuild.cjs new file mode 100644 index 000000000..2aa847db2 --- /dev/null +++ b/config/scripts/electron-builder-native-rebuild.cjs @@ -0,0 +1,52 @@ +const { execFileSync } = require('node:child_process') +const { resolve } = require('node:path') + +const projectDir = resolve(__dirname, '../..') + +function electronBuilderNativeRebuild(context) { + return runElectronBuilderNativeRebuild(context) +} + +function runElectronBuilderNativeRebuild(context, runner = execFileSync) { + const args = buildNativeRebuildArgs(context) + runner(process.execPath, args, { + cwd: projectDir, + stdio: 'inherit' + }) + + // Why: returning false tells electron-builder that native deps were handled + // externally, avoiding its all-module rebuild of optional cpu-features. + return false +} + +function buildNativeRebuildArgs(context) { + const platform = readPlatformName(context?.platform) + const arch = readArchName(context?.arch) + + return [ + 'config/scripts/rebuild-native-deps.mjs', + `--platform=${platform}`, + `--arch=${arch}`, + '--force' + ] +} + +function readPlatformName(platform) { + const name = typeof platform === 'string' ? platform : platform?.nodeName + if (!name) { + throw new Error('electron-builder native rebuild context is missing platform.nodeName') + } + return name +} + +function readArchName(arch) { + if (!arch || typeof arch !== 'string') { + throw new Error('electron-builder native rebuild context is missing arch') + } + return arch +} + +module.exports = electronBuilderNativeRebuild +module.exports.default = electronBuilderNativeRebuild +module.exports.buildNativeRebuildArgs = buildNativeRebuildArgs +module.exports.runElectronBuilderNativeRebuild = runElectronBuilderNativeRebuild diff --git a/config/scripts/electron-builder-native-rebuild.test.mjs b/config/scripts/electron-builder-native-rebuild.test.mjs new file mode 100644 index 000000000..e92c62b7a --- /dev/null +++ b/config/scripts/electron-builder-native-rebuild.test.mjs @@ -0,0 +1,49 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + buildNativeRebuildArgs, + runElectronBuilderNativeRebuild +} = require('./electron-builder-native-rebuild.cjs') + +describe('electron-builder native rebuild hook', () => { + it('passes the target platform and arch to Orca native rebuild script', () => { + expect( + buildNativeRebuildArgs({ + platform: { nodeName: 'darwin' }, + arch: 'x64' + }) + ).toEqual([ + 'config/scripts/rebuild-native-deps.mjs', + '--platform=darwin', + '--arch=x64', + '--force' + ]) + }) + + it('returns false so electron-builder skips its optional module rebuild pass', () => { + const calls = [] + const result = runElectronBuilderNativeRebuild( + { + platform: { nodeName: 'linux' }, + arch: 'arm64' + }, + (...args) => calls.push(args) + ) + + expect(result).toBe(false) + expect(calls).toEqual([ + [ + process.execPath, + ['config/scripts/rebuild-native-deps.mjs', '--platform=linux', '--arch=arm64', '--force'], + expect.objectContaining({ stdio: 'inherit' }) + ] + ]) + }) + + it('rejects incomplete electron-builder contexts', () => { + expect(() => buildNativeRebuildArgs({ arch: 'x64' })).toThrow(/platform/) + expect(() => buildNativeRebuildArgs({ platform: { nodeName: 'linux' } })).toThrow(/arch/) + }) +}) diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 7ba62dd8e..715d65f75 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -11,8 +11,9 @@ * `pnpm install` from completing. * * This script replaces `electron-builder install-app-deps` in the postinstall - * lifecycle. It calls @electron/rebuild's JS API directly so that we can skip - * `cpu-features` when rebuilding modules against Electron. Skipping + * lifecycle and the electron-builder beforeBuild hook. It calls + * @electron/rebuild's JS API directly so that we can skip `cpu-features` when + * rebuilding modules against Electron. Skipping * cpu-features is safe: ssh2 detects the missing native module and falls back * to pure-JS CPU feature detection automatically. */ @@ -24,6 +25,15 @@ import { platform as osPlatform } from 'node:os' import { resolve } from 'node:path' const projectDir = process.cwd() +let cliOptions +try { + cliOptions = readCliOptions(process.argv.slice(2)) +} catch (error) { + console.error(`[rebuild] ${formatError(error)}`) + process.exit(2) +} +const rebuildPlatform = cliOptions.platform ?? osPlatform() +const rebuildArch = cliOptions.arch ?? process.arch const electronPackageDir = resolve(projectDir, 'node_modules/electron') const electronVersion = JSON.parse( readFileSync(resolve(electronPackageDir, 'package.json'), 'utf8') @@ -41,7 +51,11 @@ if (ignoreModules.length > 0) { // ABI regardless of the package manager's store layout. const NATIVE_MODULES = ['node-pty', 'cpu-features'] const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m)) -const forceRebuild = process.env.ORCA_FORCE_NATIVE_REBUILD === '1' +const forceRebuild = + process.env.ORCA_FORCE_NATIVE_REBUILD === '1' || + cliOptions.force || + rebuildPlatform !== osPlatform() || + rebuildArch !== process.arch ensureElectronPackageInstalled() @@ -58,7 +72,7 @@ if (!forceRebuild) { console.log(probe.stderr.trim()) } } else { - console.log('[rebuild] ORCA_FORCE_NATIVE_REBUILD=1 set; forcing native rebuild.') + console.log(`[rebuild] Forcing native rebuild for ${rebuildPlatform}-${rebuildArch}.`) } // Why: cpu-features ships without `buildcheck.gypi`; its own `install` script @@ -95,6 +109,8 @@ try { await rebuild({ buildPath: projectDir, electronVersion, + platform: rebuildPlatform, + arch: rebuildArch, ignoreModules, onlyModules, // Why: without force, @electron/rebuild skips modules it considers @@ -253,7 +269,7 @@ function safeReaddir(targetPath) { function getElectronPlatformPath() { const targetPlatform = - process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || osPlatform() + process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || rebuildPlatform switch (targetPlatform) { case 'mas': case 'darwin': @@ -269,6 +285,51 @@ function getElectronPlatformPath() { } } +function readCliOptions(args) { + const options = { force: false } + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--force') { + options.force = true + continue + } + if (arg === '--platform') { + options.platform = readRequiredArgValue(args, (index += 1), '--platform') + continue + } + if (arg.startsWith('--platform=')) { + options.platform = readInlineArgValue(arg, '--platform') + continue + } + if (arg === '--arch') { + options.arch = readRequiredArgValue(args, (index += 1), '--arch') + continue + } + if (arg.startsWith('--arch=')) { + options.arch = readInlineArgValue(arg, '--arch') + continue + } + throw new Error(`Unknown argument: ${arg}`) + } + return options +} + +function readRequiredArgValue(args, index, flag) { + const value = args[index] + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${flag}`) + } + return value +} + +function readInlineArgValue(arg, flag) { + const value = arg.slice(`${flag}=`.length) + if (!value) { + throw new Error(`Missing value for ${flag}`) + } + return value +} + function getElectronExecutablePath() { const platformPath = getElectronPlatformPath() return process.env.ELECTRON_OVERRIDE_DIST_PATH