fix(installer): stamp Hermes icon onto Hermes.exe via rcedit (no winCodeSign)

The unpacked Hermes.exe showed the stock Electron icon + name in the
taskbar because build.win.signAndEditExecutable=false disables BOTH
electron-builder's signing AND its rcedit metadata/icon stamping. That
flag is load-bearing: enabling it re-triggers signtool -> winCodeSign,
whose macOS symlinks crash 7-Zip on non-admin Windows (unfixable dead end).

Decouple identity-stamping from signing entirely: after npm run pack,
run rcedit ourselves on the produced exe.
- Add rcedit as a direct devDependency of apps/desktop (the transitive
  electron-winstaller copy is fragile).
- apps/desktop/scripts/set-exe-identity.cjs: Node helper that calls
  rcedit's named export to set icon + ProductName/FileDescription/
  CompanyName. Node builds argv natively — avoids the PowerShell->exe
  ->JSON double-escaping that broke the app-builder rcedit path.
- install.ps1 Set-DesktopExeIdentity invokes the script after the build,
  before shortcuts. Best-effort: failure keeps the stock icon, never
  fails the install. rcedit is a pure PE editor — no signtool, no
  winCodeSign, no symlinks.

Verified locally: stamping a copy of the built Hermes.exe embeds the
32x32 icon and sets ProductName=Hermes.

Also fix update-path success-screen flash: in update mode the installer
hands off + exits in ~600ms, so don't route to the 'launch Hermes'
success view (it flashed before the window closed).
This commit is contained in:
emozilla 2026-05-29 00:50:14 -04:00
parent aeebe1afa7
commit 25488de4ba
5 changed files with 252 additions and 8 deletions

View file

@ -206,7 +206,13 @@ export async function initialize(): Promise<void> {
installRoot: payload.installRoot,
currentStage: null
})
$route.set('success')
// Install: show the "launch Hermes" success screen. Update: this is a
// hand-off — the installer relaunches the desktop and exits within a
// few hundred ms, so routing to success just flashes that screen
// before the window closes. Stay on progress until we exit.
if ($mode.get() !== 'update') {
$route.set('success')
}
break
case 'failed':
$bootstrap.set({

View file

@ -116,6 +116,7 @@
"globals": "^16.5.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"rcedit": "^5.0.2",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5",

View file

@ -0,0 +1,87 @@
#!/usr/bin/env node
// set-exe-identity.cjs — 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. Invoked from install.ps1's Install-Desktop
// after `npm run pack`.
//
// USAGE
// node scripts/set-exe-identity.cjs <path-to-Hermes.exe>
//
// Exits 0 on success, non-zero on failure. install.ps1 treats failure as
// non-fatal (worst case: stock icon, not a broken app).
const path = require('node:path')
const fs = require('node:fs')
async function main() {
const exe = process.argv[2]
if (!exe) {
console.error('[set-exe-identity] usage: set-exe-identity.cjs <path-to-exe>')
process.exit(2)
}
if (!fs.existsSync(exe)) {
console.error(`[set-exe-identity] target exe not found: ${exe}`)
process.exit(2)
}
// Icon lives beside this script's package root: apps/desktop/assets/icon.ico
const desktopRoot = path.resolve(__dirname, '..')
const icon = path.join(desktopRoot, 'assets', 'icon.ico')
if (!fs.existsSync(icon)) {
console.error(`[set-exe-identity] icon not found: ${icon}`)
process.exit(2)
}
// rcedit is a direct devDependency of apps/desktop, so it resolves whether
// we're run from the desktop dir or the repo root (workspace hoist).
// rcedit@5 exports a NAMED `rcedit` function (CommonJS: { rcedit }), not a
// default export.
let rcedit
try {
const mod = require('rcedit')
rcedit = typeof mod === 'function' ? mod : mod.rcedit
if (typeof rcedit !== 'function') {
throw new Error(`unexpected rcedit export shape: ${typeof mod} keys=${Object.keys(mod)}`)
}
} catch (err) {
console.error(`[set-exe-identity] could not load rcedit module: ${err.message}`)
process.exit(3)
}
console.log(`[set-exe-identity] stamping ${exe}`)
console.log(`[set-exe-identity] icon: ${icon}`)
try {
await rcedit(exe, {
icon,
'version-string': {
ProductName: 'Hermes',
FileDescription: 'Hermes',
CompanyName: 'Nous Research',
LegalCopyright: 'Copyright (c) 2026 Nous Research'
}
})
} catch (err) {
console.error(`[set-exe-identity] rcedit failed: ${err.message}`)
process.exit(1)
}
console.log('[set-exe-identity] done — Hermes icon + identity stamped')
}
main()

92
package-lock.json generated
View file

@ -176,6 +176,7 @@
"globals": "^16.5.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"rcedit": "^5.0.2",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5",
@ -10888,6 +10889,54 @@
"node": ">= 8"
}
},
"node_modules/cross-spawn-windows-exe": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/cross-spawn-windows-exe/-/cross-spawn-windows-exe-1.2.0.tgz",
"integrity": "sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/malept"
},
{
"type": "tidelift",
"url": "https://tidelift.com/subscription/pkg/npm-cross-spawn-windows-exe?utm_medium=referral&utm_source=npm_fund"
}
],
"license": "Apache-2.0",
"dependencies": {
"@malept/cross-spawn-promise": "^1.1.0",
"is-wsl": "^2.2.0",
"which": "^2.0.2"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/cross-spawn-windows-exe/node_modules/@malept/cross-spawn-promise": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz",
"integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/malept"
},
{
"type": "tidelift",
"url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund"
}
],
"license": "Apache-2.0",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@ -14928,6 +14977,22 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"dev": true,
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
@ -15264,6 +15329,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
@ -18930,6 +19008,20 @@
"rc": "cli.js"
}
},
"node_modules/rcedit": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/rcedit/-/rcedit-5.0.2.tgz",
"integrity": "sha512-dgysxaeXZ4snLpPjn8aVtHvZDCx+aRcvZbaWBgl1poU6OPustMvOkj9a9ZqASQ6i5Y5szJ13LSvglEOwrmgUxA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn-windows-exe": "^1.1.0"
},
"engines": {
"node": ">= 22.12.0"
}
},
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",

View file

@ -2014,13 +2014,13 @@ function Install-Desktop {
# launchable binary the Tauri installer can spawn.
#
# CSC_IDENTITY_AUTO_DISCOVERY=false tells electron-builder we are
# NOT signing the output. This short-circuits the winCodeSign fetch +
# extraction entirely (which fails on non-admin Windows due to a
# macOS-symlink extraction crash electron-builder hasn't fixed in
# years). We never had a signing cert to use, so the apparatus was
# dead weight that broke fresh installs. The produced Hermes.exe
# is functionally identical — just unsigned, same as it would be
# if signing had been attempted but no cert was configured.
# NOT signing the output. Combined with signAndEditExecutable=false in
# apps/desktop/package.json's build.win block, electron-builder never
# invokes signtool and therefore never fetches/extracts winCodeSign
# (whose macOS symlinks crash 7-Zip on non-admin Windows — a dead end we
# are NOT trying to work around). The Hermes icon + product name are
# stamped onto Hermes.exe by our own rcedit step (Set-DesktopExeIdentity)
# AFTER this build, completely decoupled from electron-builder signing.
#
# WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD explicitly cleared as
# belt-and-suspenders: if the user's environment has them set
@ -2086,6 +2086,13 @@ function Install-Desktop {
throw "Desktop build completed but no Hermes.exe was found under $desktopDir\release\*-unpacked\"
}
# 3b. Stamp the Hermes icon + identity onto Hermes.exe ourselves.
# electron-builder's own rcedit step is disabled (signAndEditExecutable
# =false) because enabling it drags in signtool -> winCodeSign -> the
# unfixable symlink crash. So we run rcedit directly on the produced
# exe — no signing, no winCodeSign, just PE resource editing.
Set-DesktopExeIdentity -TargetExe $desktopExe -DesktopDir $desktopDir
# 4. Create Start Menu + Desktop shortcuts pointing DIRECTLY at the packed
# Hermes.exe. We deliberately do NOT point them at `hermes desktop`: that
# command rebuilds (npm install + electron-builder) on every launch,
@ -2095,6 +2102,57 @@ function Install-Desktop {
New-DesktopShortcuts -TargetExe $desktopExe
}
function Set-DesktopExeIdentity {
# Stamp the Hermes icon + version metadata into Hermes.exe via rcedit,
# delegated to apps/desktop/scripts/set-exe-identity.cjs (which uses the
# `rcedit` npm package — a direct devDependency, so always present).
#
# Why a Node script instead of calling rcedit from PowerShell: passing
# arguments through PowerShell -> exe -> JSON parsers double-escapes
# Windows backslashes and breaks app-builder's --args JSON. Letting Node
# build the rcedit argv natively sidesteps all shell-quoting hazards.
#
# This replaces electron-builder's built-in signAndEditExecutable step
# (kept false, because enabling it re-triggers signtool -> winCodeSign ->
# the macOS-symlink 7-Zip crash on non-admin Windows). rcedit is a pure PE
# resource editor — no signing, no certs, no winCodeSign, no symlinks.
#
# Best-effort: a stamping failure must not fail an otherwise-good install
# (worst case is the stock Electron icon, not a broken app).
param(
[Parameter(Mandatory = $true)][string]$TargetExe,
[Parameter(Mandatory = $true)][string]$DesktopDir
)
$nodeExe = Get-Command node -ErrorAction SilentlyContinue
if (-not $nodeExe) {
Write-Warn "node not on PATH; cannot stamp Hermes.exe identity (it will keep the stock Electron icon)"
return
}
$script = Join-Path $DesktopDir "scripts\set-exe-identity.cjs"
if (-not (Test-Path $script)) {
Write-Warn "set-exe-identity.cjs not found at $script; skipping exe identity stamp"
return
}
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
# Run from $DesktopDir so the script's `require('rcedit')` resolves against
# the desktop workspace's node_modules.
Push-Location $DesktopDir
& $nodeExe.Source $script $TargetExe 2>&1 | ForEach-Object { "$_" }
$code = $LASTEXITCODE
Pop-Location
$ErrorActionPreference = $prevEAP
if ($code -eq 0) {
Write-Success "Stamped Hermes icon + identity onto $TargetExe"
} else {
Write-Warn "Exe identity stamp failed (exit $code); Hermes.exe keeps the stock Electron icon"
}
}
function New-DesktopShortcuts {
param([Parameter(Mandatory = $true)][string]$TargetExe)