Files
hermes-agent/apps/desktop/scripts/after-pack.mjs
ethernet 26960b1afb build(desktop): compile the layered icon to Assets.car on macOS 26 hosts
electron-builder (app-builder-lib 27.0.0-alpha.6) compiles a `.icon` given as
`mac.icon` into Contents/Resources/Assets.car and sets CFBundleIconName, but
only with actool >= 26 (Xcode 26); on any other host it throws and the build
fails. scripts/mac-icon.cjs picks the Icon Composer package when that actool
is present and the legacy .icns otherwise, so a dev Mac on Xcode 16 keeps
building the app it always built.

The release runners keep Xcode 16.4 selected although 26.x is installed, so
the darwin job now selects the newest Xcode 26 via DEVELOPER_DIR and asserts
the actool version before packaging; a silent fallback cannot ship from CI.

electron-builder's `.icon` path also replaces the bundled icon.icns with
actool's 256px fallback. macOS <= 15 shows that file, so after-pack restores
our 16..1024 assets/icon.icns.
2026-10-02 10:33:43 -04:00

143 lines
7.1 KiB
JavaScript

/**
* after-pack.mjs — electron-builder afterPack hook.
*
* Per-platform post-pack work on the unpacked app: payload relocation, nested
* Chromium + wheel signing on macOS, PE signature sanitizing and batch signing
* on Windows. The exe identity stamp lives in after-extract.mjs (#105629).
*
* electron-builder passes a context with:
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
* - appOutDir: the unpacked app directory for this target
* - packager.appInfo.productFilename: the exe basename (e.g. 'Hermes')
*/
import path from 'node:path'
import fs from 'node:fs'
import { copyFile, mkdir, readdir } from 'node:fs/promises'
import { runPython } from '../../../scripts/build/python.mjs'
import { assertPackagedBackendReadyArtifact, resolvePackagedAsarPath } from './backend-ready-artifact.mjs'
import { batchSignAppTree } from './batch-sign-binaries.mjs'
import { rehashPayloadDigests } from './payload-digests.mjs'
import { resolveSigningIdentity, signNestedChromium } from './sign-nested-chromium.mjs'
import { signWheelZipMembers } from './sign-wheel-zips.mjs'
import { sanitizeTree } from './sanitize-pe-signatures.mjs'
/**
* Put our full-resolution `assets/icon.icns` back as the bundle's legacy icon.
* When `mac.icon` is the Icon Composer package, electron-builder replaces the
* bundled `icon.icns` with actool's 256px fallback; macOS <= 15 shows that
* file, so it must be the 16→1024 artwork the generator produced.
* @param {{ appOutDir: string, packager: { appInfo: { productFilename: string } } }} context
* @param {string} [appDir] the apps/desktop directory
*/
export async function restoreLegacyMacIcon({ appOutDir, packager }, appDir = path.resolve(import.meta.dirname, '..')) {
await copyFile(path.join(appDir, 'assets', 'icon.icns'), path.join(packager.getResourcesDir(appOutDir), 'icon.icns'))
}
/**
* Restore the empty app-level localizations dropped during Electron extraction.
* Runs after language filtering and before signing; the markers come from the
* packaged framework, not the host's Electron (which may be another version).
* Non-blocking: a failed restore leaves a usable package and says so.
*/
export async function restoreMacLocaleMarkers({ appOutDir, packager }) {
try {
const resources = packager.getResourcesDir(appOutDir)
const framework = packager.getMacOsElectronFrameworkResourcesDir(appOutDir)
const entries = await readdir(framework, { withFileTypes: true })
// Chromium also ships grammatical-gender packs; these are not macOS locales.
const locales = entries.filter(
entry =>
entry.isDirectory() && entry.name.endsWith('.lproj') && !/_(FEMININE|MASCULINE|NEUTER)\.lproj$/.test(entry.name)
)
await Promise.all(locales.map(entry => mkdir(path.join(resources, entry.name), { recursive: true })))
} catch (error) {
console.warn(
`[after-pack] macOS locale markers were not restored: ${error instanceof Error ? error.message : String(error)}`
)
}
}
export default async function afterPack(context) {
const platform = context.electronPlatformName
// Artifact-skew guard (#60772): before any platform work, prove the packed
// bundle's readiness parser still accepts both ready tokens. This runs for
// every packed build — first install, `hermes desktop`, the installer's
// --update rebuild — so a stale matcher fails the pack here instead of
// killing healthy backends on user machines.
const asarPath = resolvePackagedAsarPath(context)
assertPackagedBackendReadyArtifact(asarPath)
console.log(`[after-pack] verified backend readiness parser in ${asarPath}`)
const resources = platform === 'darwin'
? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources')
: path.join(context.appOutDir, 'resources')
const payload = path.join(resources, 'agent-payload')
if (platform !== 'win32' && fs.existsSync(path.join(payload, 'manifest.json'))) {
runPython([
path.resolve(import.meta.dirname, '../../../scripts/bundles/payload.py'), 'relocate', payload], { stdio: 'inherit' })
}
if (platform === 'darwin') {
await restoreLegacyMacIcon(context)
await restoreMacLocaleMarkers(context)
if (fs.existsSync(payload)) {
const entitlements = path.join(import.meta.dirname, '..', 'electron', 'entitlements.mac.inherit.plist')
const { identity, keychain } = await resolveSigningIdentity(context.packager)
const nested = signNestedChromium(payload, { entitlements, identity, keychain })
console.log(
`[after-pack] repaired ${nested.repaired} framework links; signed ${nested.signed} nested chromium targets` +
(identity ? ` as ${identity}` : ' (no Developer ID in the builder keychain)')
)
// uv-cache wheel zips carry Mach-O members the notary validates but
// electron-osx-sign cannot reach; sign them in place (see module doc).
const wheels = signWheelZipMembers(payload, { identity, keychain })
if (wheels.signed > 0) {
console.log(
`[after-pack] signed ${wheels.signed} Mach-O members across ${wheels.wheels} payload wheel zips` +
(identity ? ` as ${identity}` : ' (no Developer ID in the builder keychain)'))
}
// The macOS signer refreshes this again before sealing the outer app.
// Unsigned builds end here and still need final-byte facts.
rehashPayloadDigests(payload)
}
return
}
if (platform === 'linux') {
return
}
if (platform !== 'win32') {
return
}
const productName = context.packager?.appInfo?.productFilename || 'Hermes'
const exe = path.join(context.appOutDir, `${productName}.exe`)
// Repair dangling PE certificate tables BEFORE electron-builder signs the
// tree. A stripped-but-still-declared signature makes signtool reject the
// file with 0x800700C1, and AppxSIP inspects every PE inside the MSIX, so
// one bad payload DLL fails the whole package. Unlike the stamp below this
// is NOT best-effort: shipping past it means shipping an unsignable bundle.
// this is a hack until https://github.com/astral-sh/python-build-standalone/pull/1217 is merged.
const { scanned, repaired } = sanitizeTree(context.appOutDir)
console.log(`[after-pack] ${scanned} PEs scanned, ${repaired.length} dangling certificate tables cleared`)
for (const file of repaired) {
console.log(` ${file}`)
}
// The identity stamp already ran from afterExtract on the pristine exe
// (scripts/after-extract.mjs, #105629); rcedit cannot commit to the
// ASAR-integrity-rewritten PE we hold here.
// Batch-sign every payload binary AFTER sanitize (above) and the rcedit
// stamp: a dangling certificate table or a subsequent resource edit would
// invalidate the signature. The product exe is excluded here and signed
// per-file by the customSign hook (scripts/batch-sign-binaries.mjs) after
// electron-builder's own rcedit + fuses pass. No-op with a loud warning when
// the AZURE_SIGN_* environment is absent (unsigned/fork/canary lanes).
await batchSignAppTree(context.appOutDir, exe, {
config: context.packager.config,
resourcesDir: context.packager.buildResourcesDir,
})
rehashPayloadDigests(payload)
}