diff --git a/apps/desktop/scripts/after-pack.mjs b/apps/desktop/scripts/after-pack.mjs index e173506978..d274c65635 100644 --- a/apps/desktop/scripts/after-pack.mjs +++ b/apps/desktop/scripts/after-pack.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs' import { 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' @@ -48,6 +49,14 @@ export async function restoreMacLocaleMarkers({ appOutDir, packager }) { 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') diff --git a/apps/desktop/scripts/after-pack.test.mjs b/apps/desktop/scripts/after-pack.test.mjs index b908856c11..8424c5d918 100644 --- a/apps/desktop/scripts/after-pack.test.mjs +++ b/apps/desktop/scripts/after-pack.test.mjs @@ -1,3 +1,8 @@ +import { + assertPackagedBackendReadyArtifact, + assertBackendReadyArtifactSourceAcceptsBothTokens, + resolvePackagedAsarPath +} from './backend-ready-artifact.mjs' import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import os from 'node:os' @@ -15,6 +20,20 @@ async function configuredHook(context) { await hook.default(context) } +// The afterPack readiness guard reads the packaged bundle's unpacked main; +// every fixture here packs a valid dual-token matcher so the tests keep +// exercising the locale/signing paths the hook also performs. +async function seedPackagedMain(context) { + const asarPath = resolvePackagedAsarPath(context) + await mkdir(path.dirname(asarPath), { recursive: true }) + await writeFile(asarPath, 'stub archive') + await mkdir(path.join(`${asarPath}.unpacked`, 'dist'), { recursive: true }) + await writeFile( + path.join(`${asarPath}.unpacked`, 'dist', 'electron-main.mjs'), + 'const re = /HERMES_(?:BACKEND|DASHBOARD)_READY[^\\n]*port=(\\d+)/m\n' + ) +} + function context(appOutDir, productFilename = 'Hermes Preview') { // Use electron-builder's real bundle path resolution, including branding. const packager = Object.assign(Object.create(PlatformPackager.prototype), { @@ -29,6 +48,7 @@ it('restores app localizations from the filtered framework without copying local const root = await mkdtemp(path.join(os.tmpdir(), 'hermes-locale-pack-')) try { const ctx = context(root) + await seedPackagedMain(ctx) const framework = ctx.packager.getMacOsElectronFrameworkResourcesDir(root) const resources = ctx.packager.getResourcesDir(root) await mkdir(resources, { recursive: true }) @@ -40,7 +60,8 @@ it('restores app localizations from the filtered framework without copying local await mkdir(path.join(framework, 'other'), { recursive: true }) await configuredHook(ctx) await configuredHook(ctx) - expect((await readdir(resources)).sort()).toEqual(['en_GB.lproj', 'nb.lproj']) + expect((await readdir(resources)).filter(name => name.endsWith('.lproj')).sort()) + .toEqual(['en_GB.lproj', 'nb.lproj']) expect(await readdir(path.join(resources, 'nb.lproj'))).toEqual([]) expect(await readFile(path.join(framework, 'nb.lproj', 'locale.pak'), 'utf8')).toBe('untouched locale data') } finally { @@ -53,12 +74,15 @@ it('leaves Linux alone and reports a missing framework without failing packaging const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { // win32 is not a no-op here: the same hook sanitizes and batch-signs the PE tree. - await configuredHook({ appOutDir: root, electronPlatformName: 'linux' }) - expect(await readdir(root)).toEqual([]) + const linuxCtx = { appOutDir: root, electronPlatformName: 'linux' } + await seedPackagedMain(linuxCtx) + await configuredHook(linuxCtx) expect(warn).not.toHaveBeenCalled() - await configuredHook(context(root)) + const ctx = context(root) + await seedPackagedMain(ctx) + await configuredHook(ctx) expect(warn).toHaveBeenCalledWith(expect.stringContaining('macOS locale markers were not restored')) - expect(await readdir(root)).toEqual([]) + expect((await readdir(root)).sort()).toEqual(['Hermes Preview.app', 'resources']) } finally { warn.mockRestore() await rm(root, { recursive: true, force: true }) diff --git a/apps/desktop/scripts/backend-ready-artifact.mjs b/apps/desktop/scripts/backend-ready-artifact.mjs new file mode 100644 index 0000000000..649cac1bf6 --- /dev/null +++ b/apps/desktop/scripts/backend-ready-artifact.mjs @@ -0,0 +1,116 @@ +/** + * Guard the packaged backend-readiness parser against artifact skew (#60772). + * + * The packaged app's `dist/electron-main.mjs` bundle must accept BOTH ready + * tokens (`HERMES_BACKEND_READY` from a current backend, and the legacy + * `HERMES_DASHBOARD_READY` from older ones). A stale artifact that matches + * only one token boots a perfectly healthy backend and then kills it after + * "Timed out waiting for Hermes backend port announcement" — invisible to + * every source-level test, because the packaged bundle is the only thing that + * ships. afterPack runs for every packed build, so this turns that class of + * skew into a build failure instead of a user-side boot loop. + */ + +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) + +const PACKAGED_MAIN_MODULE = 'dist/electron-main.mjs' +const READY_TOKENS = ['HERMES_BACKEND_READY', 'HERMES_DASHBOARD_READY'] +// The esbuild-bundled regex source for the dual-token readiness matcher. The +// bundle keeps the source literal verbatim, so the packaged text contains the +// (unexecuted) source form `HERMES_(?:BACKEND|DASHBOARD)_READY`; the parens +// and `?` are escaped here to match that substring as text. +const READY_MATCHER_SOURCE = /HERMES_\(\?:BACKEND\|DASHBOARD\)_READY/ + +function resolvePackagedAsarPath(context) { + const appOutDir = context?.appOutDir + if (!appOutDir || typeof appOutDir !== 'string') { + throw new Error('electron-builder afterPack context is missing appOutDir') + } + + if (context.electronPlatformName === 'darwin') { + if (appOutDir.endsWith('.app')) { + return path.join(appOutDir, 'Contents', 'Resources', 'app.asar') + } + const productName = context.packager?.appInfo?.productFilename || 'Hermes' + return path.join(appOutDir, `${productName}.app`, 'Contents', 'Resources', 'app.asar') + } + + return path.join(appOutDir, 'resources', 'app.asar') +} + +function loadAsarModule() { + try { + return require('@electron/asar') + } catch (err) { + throw new Error( + `Cannot inspect packaged app.asar because @electron/asar is unavailable: ${err.message}` + ) + } +} + +function unpackedPathForAsar(asarPath) { + return path.join(`${asarPath}.unpacked`, PACKAGED_MAIN_MODULE) +} + +function extractPackagedMainSource(asarPath, options = {}) { + if (!fs.existsSync(asarPath)) { + throw new Error(`Missing packaged app.asar: ${asarPath}`) + } + + const unpackedPath = unpackedPathForAsar(asarPath) + if (fs.existsSync(unpackedPath)) { + return fs.readFileSync(unpackedPath, 'utf8') + } + + const asarModule = options.asarModule ?? loadAsarModule() + if (!asarModule || typeof asarModule.extractFile !== 'function') { + throw new Error('@electron/asar module does not expose extractFile') + } + + let source + try { + source = asarModule.extractFile(asarPath, PACKAGED_MAIN_MODULE) + } catch (err) { + throw new Error( + `Could not extract ${PACKAGED_MAIN_MODULE} from ${asarPath}: ${err.message}` + ) + } + + return Buffer.isBuffer(source) ? source.toString('utf8') : String(source) +} + +function assertBackendReadyArtifactSourceAcceptsBothTokens( + source, + label = PACKAGED_MAIN_MODULE +) { + if (!READY_MATCHER_SOURCE.test(source)) { + throw new Error( + `${label} does not contain a packaged readiness matcher accepting ` + + READY_TOKENS.join(' and ') + ) + } +} + +function assertPackagedBackendReadyArtifact(asarPath, options = {}) { + const source = extractPackagedMainSource(asarPath, options) + assertBackendReadyArtifactSourceAcceptsBothTokens(source, PACKAGED_MAIN_MODULE) + return { + asarPath, + module: PACKAGED_MAIN_MODULE, + tokens: READY_TOKENS.slice() + } +} + +export { + PACKAGED_MAIN_MODULE, + READY_TOKENS, + assertBackendReadyArtifactSourceAcceptsBothTokens, + assertPackagedBackendReadyArtifact, + extractPackagedMainSource, + resolvePackagedAsarPath, + unpackedPathForAsar +} diff --git a/apps/desktop/scripts/backend-ready-artifact.test.mjs b/apps/desktop/scripts/backend-ready-artifact.test.mjs new file mode 100644 index 0000000000..dd64e8c8b0 --- /dev/null +++ b/apps/desktop/scripts/backend-ready-artifact.test.mjs @@ -0,0 +1,110 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { expect, it, vi } from 'vitest' + +import { + PACKAGED_MAIN_MODULE, + READY_TOKENS, + assertBackendReadyArtifactSourceAcceptsBothTokens, + assertPackagedBackendReadyArtifact, + extractPackagedMainSource, + resolvePackagedAsarPath +} from './backend-ready-artifact.mjs' + +const CURRENT_SOURCE = 'const re = /HERMES_(?:BACKEND|DASHBOARD)_READY[^\\n]*port=(\\d+)/m\n' +// The pre-#55923 matcher: only the legacy token. A packaged bundle carrying +// this kills a current backend after the port-announcement timeout (#60772). +const STALE_SOURCE = 'const re = /HERMES_DASHBOARD_READY port=(\\d+)/m\n' + +async function packedAppRoot() { + const root = await mkdtemp(path.join(os.tmpdir(), 'hermes-ready-artifact-')) + const resources = path.join(root, 'resources') + await mkdir(resources, { recursive: true }) + return { root, resources } +} + +async function writeAsar(resources, source) { + // app.asar is an archive we cannot write by hand; the guard reads the + // .unpacked mirror first, and electron-builder always lays the bundled + // main out there when asarUnpack applies — and the test controls which + // path the guard takes, so exercising the unpacked mirror is the real + // flow for the asarUnpack-eligible bundle. + const unpacked = path.join(resources, 'app.asar.unpacked', 'dist') + await mkdir(unpacked, { recursive: true }) + await writeFile(path.join(unpacked, 'electron-main.mjs'), source) + await writeFile(path.join(resources, 'app.asar'), 'stub archive') + return path.join(resources, 'app.asar') +} + +it('resolves the asar path per platform, including the branded macOS bundle', () => { + expect(resolvePackagedAsarPath({ appOutDir: '/out/win', electronPlatformName: 'win32' })) + .toBe(path.join('/out/win', 'resources', 'app.asar')) + expect(resolvePackagedAsarPath({ + appOutDir: '/out/mac', + electronPlatformName: 'darwin', + packager: { appInfo: { productFilename: 'Hermes Preview' } } + })).toBe(path.join('/out/mac', 'Hermes Preview.app', 'Contents', 'Resources', 'app.asar')) + expect(() => resolvePackagedAsarPath({ electronPlatformName: 'linux' })) + .toThrow('missing appOutDir') +}) + +it('accepts a packaged bundle whose matcher handles both tokens', async () => { + const { resources } = await packedAppRoot() + try { + const asar = await writeAsar(resources, CURRENT_SOURCE) + const result = assertPackagedBackendReadyArtifact(asar) + expect(result.module).toBe(PACKAGED_MAIN_MODULE) + expect(result.tokens).toEqual(READY_TOKENS) + } finally { + await rm(resources, { recursive: true, force: true }) + } +}) + +it('rejects the stale dashboard-only matcher — the #60772 artifact skew', async () => { + const { resources } = await packedAppRoot() + try { + const asar = await writeAsar(resources, STALE_SOURCE) + expect(() => assertPackagedBackendReadyArtifact(asar)).toThrow( + 'does not contain a packaged readiness matcher accepting HERMES_BACKEND_READY and HERMES_DASHBOARD_READY' + ) + } finally { + await rm(resources, { recursive: true, force: true }) + } +}) + +it('fails the pack when the packaged app.asar is missing', async () => { + const { resources } = await packedAppRoot() + try { + expect(() => assertPackagedBackendReadyArtifact(path.join(resources, 'app.asar'))) + .toThrow('Missing packaged app.asar') + } finally { + await rm(resources, { recursive: true, force: true }) + } +}) + +it('extracts through @electron/asar when no unpacked mirror exists', async () => { + const { resources } = await packedAppRoot() + try { + const asar = path.join(resources, 'app.asar') + await writeFile(asar, 'stub archive') + const extracted = vi.fn(() => Buffer.from(CURRENT_SOURCE, 'utf8')) + const source = extractPackagedMainSource(asar, { asarModule: { extractFile: extracted } }) + expect(source).toBe(CURRENT_SOURCE) + expect(extracted).toHaveBeenCalledWith(asar, PACKAGED_MAIN_MODULE) + expect(() => assertPackagedBackendReadyArtifact(asar, { asarModule: { extractFile: () => Buffer.from(STALE_SOURCE) } })) + .toThrow('readiness matcher') + } finally { + await rm(resources, { recursive: true, force: true }) + } +}) + +it('the CURRENT source-tree parser itself passes the guard', async () => { + // The shipped parser (electron/backend-ready.ts) must satisfy the very + // property the packaged bundle is asserted to keep. + const { readFile } = await import('node:fs/promises') + const backendReady = await readFile( + path.resolve(import.meta.dirname, '..', 'electron', 'backend-ready.ts'), 'utf8') + expect(() => assertBackendReadyArtifactSourceAcceptsBothTokens(backendReady, 'backend-ready.ts')) + .not.toThrow() +})