diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fe91d2991c09..582918452a5b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.ts electron/hardening.test.ts electron/backend-env.test.ts electron/backend-probes.test.ts electron/backend-ready.test.ts electron/bootstrap-runner.test.ts electron/connection-config.test.ts electron/dashboard-token.test.ts electron/gateway-ws-probe.test.ts electron/oauth-net-request.test.ts electron/desktop-uninstall.test.ts electron/session-windows.test.ts electron/link-title-window.test.ts electron/workspace-cwd.test.ts electron/fs-read-dir.test.ts electron/git-root.test.ts electron/git-worktree-ops.test.ts electron/windows-child-process.test.ts electron/update-remote.test.ts electron/update-count.test.ts electron/update-rebuild.test.ts electron/update-marker.test.ts electron/update-relaunch.test.ts electron/windows-user-env.test.ts electron/wsl-clipboard-image.test.ts electron/titlebar-overlay-width.test.ts electron/window-state.test.ts electron/zoom.test.ts electron/windows-hermes-resolution.test.ts electron/oauth-session-request.test.ts", + "test:desktop:platforms": "node --test scripts/backend-ready-artifact.test.mjs electron/bootstrap-platform.test.ts electron/hardening.test.ts electron/backend-env.test.ts electron/backend-probes.test.ts electron/backend-ready.test.ts electron/bootstrap-runner.test.ts electron/connection-config.test.ts electron/dashboard-token.test.ts electron/gateway-ws-probe.test.ts electron/oauth-net-request.test.ts electron/desktop-uninstall.test.ts electron/session-windows.test.ts electron/link-title-window.test.ts electron/workspace-cwd.test.ts electron/fs-read-dir.test.ts electron/git-root.test.ts electron/git-worktree-ops.test.ts electron/windows-child-process.test.ts electron/update-remote.test.ts electron/update-count.test.ts electron/update-rebuild.test.ts electron/update-marker.test.ts electron/update-relaunch.test.ts electron/windows-user-env.test.ts electron/wsl-clipboard-image.test.ts electron/titlebar-overlay-width.test.ts electron/window-state.test.ts electron/zoom.test.ts electron/windows-hermes-resolution.test.ts electron/oauth-session-request.test.ts", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", diff --git a/apps/desktop/scripts/after-pack.mjs b/apps/desktop/scripts/after-pack.mjs index 509cbb424814..0f18493afa14 100644 --- a/apps/desktop/scripts/after-pack.mjs +++ b/apps/desktop/scripts/after-pack.mjs @@ -1,12 +1,18 @@ /** * after-pack.mjs — electron-builder afterPack hook. * - * Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via - * rcedit (delegated to set-exe-identity.mjs). This runs for EVERY packed build - * — first install, `hermes desktop`, the installer's --update rebuild, and a - * dev's manual `npm run pack` — so the branded exe can never silently revert - * to the stock "Electron" icon/name (the bug when the stamp lived only in - * install.ps1, which the update path doesn't use). + * Guards the packaged Electron artifact before it can be released: + * + * - Verifies the packaged `dist/electron-main.mjs` bundle still accepts both + * HERMES_BACKEND_READY and HERMES_DASHBOARD_READY. This catches source / + * packaged-artifact skew that makes Desktop kill a healthy backend after + * "Timed out waiting for Hermes backend port announcement" (#60772). + * - Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via + * rcedit (delegated to set-exe-identity.mjs). This runs for EVERY packed + * build — first install, `hermes desktop`, the installer's --update rebuild, + * and a dev's manual `npm run pack` — so the branded exe can never silently + * revert to the stock "Electron" icon/name (the bug when the stamp lived + * only in install.ps1, which the update path doesn't use). * * Windows-only: rcedit edits PE resources, irrelevant on macOS/Linux where the * app identity comes from the bundle Info.plist / desktop entry. Best-effort: @@ -21,9 +27,17 @@ import path from 'node:path' +import { + assertPackagedBackendReadyArtifact, + resolvePackagedAsarPath +} from './backend-ready-artifact.mjs' import { stampExeIdentity } from './set-exe-identity.mjs' export default async function afterPack(context) { + const asarPath = resolvePackagedAsarPath(context) + assertPackagedBackendReadyArtifact(asarPath) + console.log(`[after-pack] verified backend readiness parser in ${asarPath}`) + if (context.electronPlatformName !== 'win32') { return } diff --git a/apps/desktop/scripts/backend-ready-artifact.mjs b/apps/desktop/scripts/backend-ready-artifact.mjs new file mode 100644 index 000000000000..65651aa1f426 --- /dev/null +++ b/apps/desktop/scripts/backend-ready-artifact.mjs @@ -0,0 +1,99 @@ +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'] +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 000000000000..2a609ce83511 --- /dev/null +++ b/apps/desktop/scripts/backend-ready-artifact.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { + PACKAGED_MAIN_MODULE, + READY_TOKENS, + assertBackendReadyArtifactSourceAcceptsBothTokens, + assertPackagedBackendReadyArtifact, + extractPackagedMainSource, + resolvePackagedAsarPath, + unpackedPathForAsar +} from './backend-ready-artifact.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const BACKEND_READY_SOURCE = path.resolve(here, '..', 'electron', 'backend-ready.ts') + +function readBackendReadySource() { + return fs.readFileSync(BACKEND_READY_SOURCE, 'utf8') +} + +function staleDashboardOnlySource() { + const source = readBackendReadySource() + const stale = source.replace( + 'HERMES_(?:BACKEND|DASHBOARD)_READY', + 'HERMES_DASHBOARD_READY' + ) + assert.notEqual(stale, source, 'test fixture failed to create the stale parser') + return stale +} + +test('backend-ready artifact source accepts both desktop readiness tokens', () => { + assert.doesNotThrow(() => { + assertBackendReadyArtifactSourceAcceptsBothTokens( + readBackendReadySource(), + PACKAGED_MAIN_MODULE + ) + }) +}) + +test('artifact guard rejects a stale dashboard-only readiness parser', () => { + assert.throws( + () => assertBackendReadyArtifactSourceAcceptsBothTokens(staleDashboardOnlySource()), + /HERMES_BACKEND_READY/ + ) +}) + +test('resolvePackagedAsarPath handles Windows/Linux and macOS app layouts', () => { + assert.equal( + resolvePackagedAsarPath({ appOutDir: '/tmp/win-unpacked', electronPlatformName: 'win32' }), + path.join('/tmp/win-unpacked', 'resources', 'app.asar') + ) + assert.equal( + resolvePackagedAsarPath({ appOutDir: '/tmp/linux-unpacked', electronPlatformName: 'linux' }), + path.join('/tmp/linux-unpacked', 'resources', 'app.asar') + ) + assert.equal( + resolvePackagedAsarPath({ + appOutDir: '/tmp/mac-arm64', + electronPlatformName: 'darwin', + packager: { appInfo: { productFilename: 'Hermes' } } + }), + path.join('/tmp/mac-arm64', 'Hermes.app', 'Contents', 'Resources', 'app.asar') + ) + assert.equal( + resolvePackagedAsarPath({ + appOutDir: '/tmp/Hermes.app', + electronPlatformName: 'darwin' + }), + path.join('/tmp/Hermes.app', 'Contents', 'Resources', 'app.asar') + ) +}) + +test('extractPackagedMainSource reads the packaged Electron main bundle from app.asar', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-artifact-')) + const asarPath = path.join(tempRoot, 'app.asar') + const expectedSource = readBackendReadySource() + const calls = [] + const fakeAsar = { + extractFile(archive, file) { + calls.push({ archive, file }) + return Buffer.from(expectedSource, 'utf8') + } + } + + try { + fs.writeFileSync(asarPath, 'fake asar archive', 'utf8') + assert.equal(extractPackagedMainSource(asarPath, { asarModule: fakeAsar }), expectedSource) + assert.deepEqual(calls, [{ archive: asarPath, file: PACKAGED_MAIN_MODULE }]) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('extractPackagedMainSource prefers an unpacked Electron main bundle', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-artifact-')) + const asarPath = path.join(tempRoot, 'app.asar') + const unpackedPath = unpackedPathForAsar(asarPath) + const expectedSource = readBackendReadySource() + const fakeAsar = { + extractFile() { + throw new Error('asar should not be consulted when unpacked bundle exists') + } + } + + try { + fs.writeFileSync(asarPath, 'fake asar archive', 'utf8') + fs.mkdirSync(path.dirname(unpackedPath), { recursive: true }) + fs.writeFileSync(unpackedPath, expectedSource, 'utf8') + assert.equal(extractPackagedMainSource(asarPath, { asarModule: fakeAsar }), expectedSource) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('assertPackagedBackendReadyArtifact validates the extracted parser marker', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-artifact-')) + const asarPath = path.join(tempRoot, 'app.asar') + const fakeAsar = { + extractFile() { + return Buffer.from(readBackendReadySource(), 'utf8') + } + } + + try { + fs.writeFileSync(asarPath, 'fake asar archive', 'utf8') + const result = assertPackagedBackendReadyArtifact(asarPath, { asarModule: fakeAsar }) + assert.equal(result.asarPath, asarPath) + assert.equal(result.module, PACKAGED_MAIN_MODULE) + assert.deepEqual(result.tokens, READY_TOKENS) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +})