Files
hermes-agent/apps/desktop/scripts/set-exe-identity.mjs
teknium1 7009611013 fix(desktop): document the afterExtract ordering, trim tests, refresh stale hook references
Follow-up to the salvaged #106846 commit (@JoaoMarcos44):

- after-extract.mjs: spell out WHY the stamp moved (electron-builder's
  beforeCopyExtraFiles rebuilds the PE with resedit for the ELECTRONASAR
  resource; rcedit then cannot commit to that exe, deterministically —
  #105629), and why disableAsarIntegrity was not taken.
- after-extract.test.mjs: two invariants — the hook wiring (afterExtract set,
  afterPack unset, ASAR integrity still on) and the stamp target
  (electron.exe on win32, nothing on other platforms). Red on origin/main.
- set-exe-identity.mjs / scripts/install.ps1: comments still named the
  afterPack hook / after-pack.mjs.
2026-09-19 02:31:27 -07:00

127 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
// set-exe-identity.mjs — stamp the Hermes icon + version metadata onto the
// built Hermes.exe using rcedit, completely decoupled from electron-builder's
// signing path.
//
// WHY THIS EXISTS
// ---------------
// apps/desktop/package.json sets build.win.signAndEditExecutable=false. That
// flag is load-bearing: turning electron-builder's own exe-editing ON also
// re-enables its signtool step, which fetches winCodeSign-2.6.0.7z, whose
// macOS symlinks crash 7-Zip on non-admin Windows (no Developer Mode = no
// SeCreateSymbolicLinkPrivilege). That is an unfixable dead end — we do NOT
// try to extract winCodeSign.
//
// The cost of disabling signAndEditExecutable is that electron-builder also
// skips rcedit, so the unpacked Hermes.exe keeps the stock Electron icon and
// "Electron" taskbar name. This script restores the icon + identity by calling
// rcedit DIRECTLY. rcedit is a pure PE resource editor: no signing, no certs,
// no winCodeSign, no symlinks.
//
// HOW IT RUNS
// -----------
// Primarily as an electron-builder `afterExtract` hook (scripts/after-extract.mjs),
// so EVERY packed build — first install, `hermes desktop`, the installer's
// --update rebuild, or a dev's manual `npm run pack` — gets a branded exe from
// one place. Previously this stamp lived only in install.ps1, so the update
// path (which rebuilds via `hermes desktop --build-only`, never install.ps1)
// shipped a stock "Electron" exe. Keeping it in afterExtract closes that gap.
//
// Also runnable standalone for ad-hoc re-stamping:
// node scripts/set-exe-identity.mjs <path-to-Hermes.exe>
//
// Exits 0 on success, non-zero on failure when run as a CLI. As a hook,
// stampExeIdentity() resolves on success and rejects on failure; the caller
// (after-extract.mjs) swallows the rejection so a stamp failure never fails an
// otherwise-good build (worst case: stock icon, not a broken app).
import { resolve, join } from 'node:path'
import { existsSync } from 'node:fs'
import { rcedit } from 'rcedit'
import { isMain } from './utils.mjs'
// A real-time file scanner (AV/EDR) holds a short exclusive handle on a freshly
// written exe; rcedit's resource commit then fails with "Unable to commit
// changes" and succeeds seconds later on identical input. Delays sized to the
// field report: the lock was still held 5 s after a first attempt in some runs.
const RCEDIT_COMMIT_RETRY_DELAYS_MS = [500, 1000, 2000]
// A failure to spawn the rcedit binary itself (missing or not executable) is
// permanent; waiting 3.5 s on it only delays after-extract.mjs's warning. The npm
// rcedit wrapper surfaces the spawn error as `originalError` on its rejection,
// while a non-zero rcedit exit carries a numeric `code`.
const RCEDIT_PERMANENT_SPAWN_CODES = new Set(['ENOENT', 'EACCES'])
function isPermanentRceditFailure(err) {
return RCEDIT_PERMANENT_SPAWN_CODES.has(err?.originalError?.code ?? err?.code)
}
function wait(delay) {
return new Promise(resolve => setTimeout(resolve, delay))
}
// Stamp the Hermes icon + identity onto `exe`. Resolves on success, throws on
// failure. `desktopRoot` defaults to this script's package root so the icon and
// the rcedit dependency resolve regardless of cwd.
async function stampExeIdentity(
exe,
desktopRoot = resolve(import.meta.dirname, '..'),
{ rcedit: runRcedit = rcedit, sleep = wait } = {}
) {
if (!exe || !existsSync(exe)) {
throw new Error(`target exe not found: ${exe}`)
}
// Icon lives at apps/desktop/assets/icon.ico
const icon = join(desktopRoot, 'assets', 'icon.ico')
if (!existsSync(icon)) {
throw new Error(`icon not found: ${icon}`)
}
console.log(`[set-exe-identity] stamping ${exe}`)
console.log(`[set-exe-identity] icon: ${icon}`)
const options = {
icon,
'version-string': {
ProductName: 'Hermes',
FileDescription: 'Hermes',
CompanyName: 'Nous Research',
LegalCopyright: 'Copyright (c) 2026 Nous Research'
}
}
for (let attempt = 0; ; attempt += 1) {
try {
await runRcedit(exe, options)
break
} catch (err) {
const delay = RCEDIT_COMMIT_RETRY_DELAYS_MS[attempt]
if (delay === undefined || isPermanentRceditFailure(err)) {
throw err
}
console.warn(`[set-exe-identity] rcedit failed; retrying in ${delay}ms (${err.message})`)
await sleep(delay)
}
}
console.log('[set-exe-identity] done — Hermes icon + identity stamped')
}
export { RCEDIT_COMMIT_RETRY_DELAYS_MS, stampExeIdentity }
// CLI entry point: `node scripts/set-exe-identity.mjs <exe>`.
if (isMain(import.meta.url)) {
const exe = process.argv[2]
if (!exe) {
console.error('[set-exe-identity] usage: set-exe-identity.mjs <path-to-exe>')
process.exit(2)
}
stampExeIdentity(exe).catch(err => {
console.error(`[set-exe-identity] ${err.message}`)
process.exit(1)
})
}