Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 20 additions & 6 deletions apps/desktop/scripts/after-pack.mjs
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
}
Expand Down
99 changes: 99 additions & 0 deletions apps/desktop/scripts/backend-ready-artifact.mjs
Original file line number Diff line number Diff line change
@@ -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
}
137 changes: 137 additions & 0 deletions apps/desktop/scripts/backend-ready-artifact.test.mjs
Original file line number Diff line number Diff line change
@@ -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 })
}
})