Skip to content

fix(desktop): resolve the packed app by its configured product name - #85960

Open
vadimcomanescu wants to merge 1 commit into
NousResearch:mainfrom
vadimcomanescu:fix/desktop-packaged-app-product-name
Open

fix(desktop): resolve the packed app by its configured product name#85960
vadimcomanescu wants to merge 1 commit into
NousResearch:mainfrom
vadimcomanescu:fix/desktop-packaged-app-product-name

Conversation

@vadimcomanescu

@vadimcomanescu vadimcomanescu commented Aug 14, 2026

Copy link
Copy Markdown

What does this PR do?

The desktop build/update/uninstall chain looks for the app it just built under hardcoded stock names — Hermes.app, Hermes.exe, hermes — instead of the name electron-builder was configured to write. Any build whose build.productName / build.executableName differ from the stock values packs successfully and is then reported as not built: hermes desktop --build-only treats the rebuild as having produced no launchable app, exits nonzero, and the updater rolls back to the previous version.

The two halves of the same feature disagree about what the app is called:

  • electron-builder derives every packed path from build.productName / build.executableName (apps/desktop/package.json:172-186).
  • The repo already reads them back on the build side — apps/desktop/scripts/before-pack.mjs:121 computes the exe name as `${context.packager?.appInfo?.productFilename || 'Hermes'}.exe` before preserving the rollback tree.
  • The update / launch / uninstall side never got the same treatment and still hardcodes the stock spellings.

One changed string in package.json therefore turns one-click update into a permanent rollback loop, and it surfaces as "the update did not apply", which points nowhere near package.json. Who hits it: anyone testing a renamed build locally, distro packagers who set a different product name, OEM/white-label builds, and downstream redistributions.

This is a field report, not a hypothetical: it was hit on a renamed downstream build, where the first one-click update run aborted exactly here. Credit where it is due — the rollback path did its job and kept the machine on the previous working build with an honest dialog, which is why this is a fixable annoyance and not a brick.

Reproduction on a clean checkout:

# apps/desktop/package.json → "build": { "productName": "Aurora",
#                                        "executableName": "Aurora" }
cd apps/desktop && npm run pack     # succeeds, writes release/mac-arm64/Aurora.app
hermes desktop --build-only
# ✗ --build-only produced no launchable app at: apps/desktop/release   → exit 1

scripts/install.sh reports the same non-event as Desktop build completed but no app was found under <dir>/release/, and install.ps1 as Desktop build completed but no Hermes.exe was found … — three different messages for one cause: the app is there, under a different name.

This PR does not rename anything or add a branding surface. It reads the two keys electron-builder already consumes, at the places that look for electron-builder's output. Stock builds are unaffected byte-for-byte: the stock names remain the fallback whenever package.json is missing, unreadable, or unparseable.

Related Issue

No existing issue covers this. Searched open and closed issues and PRs for productName, Hermes.exe, and packaged-app resolution first — the nearby hits (#53040 before-pack cleanup, #37762 universal DMG) are different failures in the same area, and neither touches name resolution. Happy to file a tracker entry if you would rather have one to link.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

File Site Change
hermes_cli/main.py _desktop_packaged_executable() new _desktop_product_names() helper; all three platform branches build their candidate list from it
hermes_cli/main.py _ensure_desktop_exe_launchable(), _desktop_macos_relaunchable_fixup() integrity-gate and rollback messages name the executable that actually exists
hermes_cli/gui_uninstall.py desktop_userdata_dir(), packaged_gui_app_paths() packaged-install locations and Electron's userData directory derive from the same product name
scripts/desktop-update/posix.sh mac_swap() the rebuilt bundle is located by the configured product name
scripts/install.sh install_desktop() post-pack app discovery on macOS and Linux uses the configured names
scripts/install.ps1 Install-Desktop post-pack exe discovery uses build.executableName via a new Get-DesktopExecutableName

Two derivation mechanisms, chosen per language, both reading the same source of truth:

  • Python and PowerShell have a JSON reader in the standard library, so they read build.executableName / build.productName straight from apps/desktop/package.json — the keys electron-builder itself consumes. (ConvertFrom-Json ships in Windows PowerShell 5.1, so the installer stays 5.1-safe.)
  • install.sh and posix.sh are POSIX shell and cannot assume a JSON parser, so they read the same keys through node -p. node is guaranteed present on any path that could have produced a packed tree — that tree is npm run pack's output. Both fall back to the stock names when node is unreachable.

Two details worth a reviewer's eye:

  • The macOS glob stays on the part upstream owns (release/mac*), and the product name is joined as a literal path segment, so a product name containing glob metacharacters cannot widen which trees are searched. Selection below the changed hunk (win32 arch preference, newest-mtime tiebreak) is untouched.
  • The Linux branch probes executableName, its lowercase form, then the product name and its lowercase form — preserving the existing hermes / Hermes double-probe behavior for the stock pair rather than narrowing it.

apps/desktop/electron/desktop-uninstall.ts needs no change: it already resolves the bundle from the running executable's own path (resolveRemovableAppPath), which is name-independent.

How to Test

  1. Stock behavior unchanged: scripts/run_tests.sh tests/hermes_cli/ -q — every existing desktop-update and uninstall test passes unedited. test_stock_names_still_resolve_without_a_build_config pins the no-package.json fallback that every existing install depends on.
  2. Renamed build, macOS: set build.productName / build.executableName to Aurora in apps/desktop/package.json, cd apps/desktop && npm run pack, then hermes desktop --build-only. Before: exits nonzero, "not built". After: exits 0 and reports the packed bundle.
  3. Renamed build, Windows: same edit, npm run pack, then run install.ps1's desktop stage — before it throws "no Hermes.exe was found", after it finds Aurora.exe and writes the shortcuts against it.
  4. Renamed build, Linux: same edit, npm run pack, then bash scripts/install.sh --include-desktop — before, no app is found; after, it resolves release/linux-unpacked/aurora.
  5. One-click update, end to end: npm run update:repro:fresh from apps/desktop with the renamed package.json — the bundle swap in posix.sh finds the rebuilt app instead of leaving the old one in place.
  6. Uninstall: with the renamed build installed, hermes uninstall --gui --dry-run lists the renamed bundle and userData directory.

New tests follow the existing shape in this area — synthetic release trees under tmp_path, platform faked with monkeypatch.setattr(…sys, "platform", …) — so they run on every host with no new marks or fixtures.

One filesystem detail, handled rather than papered over: test_linux_binary_resolves_from_either_spelling packs release/linux-unpacked/aurora and originally asserted that exact string back. On a case-insensitive filesystem (stock macOS APFS, NTFS) Path("Aurora").exists() is true after writing aurora, so the probe legitimately returns the capitalized spelling and a string comparison fails — a property of the host filesystem, not of the resolution under test. The test now compares by file identity (resolved.samefile(exe), plus the parent directory and a case-folded name check), so it is green on both filesystem kinds without touching the production probe order:

  • case-insensitive (this machine, stock APFS): 12 passed, 4 skipped
  • case-sensitive (same files, pytest --basetemp on a case-sensitive APFS volume): 12 passed, 4 skipped

It still fails on unfixed code — with hermes_cli/main.py reverted to main, all three renamed-build cases fail on assert resolved is not None, while the stock-name control keeps passing.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(desktop): …)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits) — one commit
  • I've run pytest tests/ -q and all tests pass — I ran the full suite via scripts/run_tests.sh and it is not literally all-green on this machine, so here is the exact accounting instead of a checked box: 2873 files, 31542 passed, 77 failed, 310 skipped. 76 of those 77 are in 21 files this PR does not touch (tests/tools/, tests/plugins/, tests/gateway/, optional-dependency and local-environment failures); re-running exactly those 21 files on a pristine 9166530942 worktree on the same machine reproduces 76 failed, so they are pre-existing and unrelated. The 77th was the case-insensitive-filesystem comparison described above, fixed in the test since that run — this PR's own files are now green on both case-sensitive and case-insensitive filesystems.
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 26.5 (arm64), Python 3.11. The Windows and Linux branches are covered by the platform-faked unit tests; bash -n scripts/install.sh, bash -n scripts/desktop-update/posix.sh, and a [Parser]::ParseFile parse of scripts/install.ps1 under pwsh are all clean. python scripts/check-windows-footguns.py --all (what CI runs) → no footguns found, 962 files scanned.

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — the derivation is documented in the docstrings/comments at each site; no user-facing docs surface changes
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A, no config keys; the keys read are electron-builder's own
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A, no architecture change
  • I've considered cross-platform impact (Windows, macOS) — this PR is the cross-platform fix: all three platform branches and both installers are covered, and per CONTRIBUTING rule 12 the install.sh and install.ps1 changes were made in lockstep
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Screenshots / Logs

On this machine's stock (case-insensitive) APFS:

$ scripts/run_tests.sh tests/hermes_cli/test_desktop_exe_integrity.py tests/hermes_cli/test_gui_uninstall.py -q
[100.0% |    16/~16 | ✓12 | ✗ 0] ✓ tests/hermes_cli/test_desktop_exe_integrity.py (5✓ 4s, 1.2s)
=== Summary: 2 files, 12 tests passed, 0 failed, 4 skipped (100% complete) in 1.2s ===

Same two files, same machine, with pytest's temp root on a case-sensitive APFS volume:

$ pytest tests/hermes_cli/test_desktop_exe_integrity.py tests/hermes_cli/test_gui_uninstall.py -q --basetemp=/Volumes/CSPROBE/pytest
ss.....ss.......                                                         [100%]
12 passed, 4 skipped in 0.21s

The three renamed-build cases against unfixed production code (hermes_cli/main.py reverted to main, tests kept) — the stock-name control still passes, which is what makes the other three meaningful:

E       assert None is not None
FAILED tests/hermes_cli/test_desktop_exe_integrity.py::test_mac_bundle_resolves_from_the_configured_product_name
FAILED tests/hermes_cli/test_desktop_exe_integrity.py::test_windows_exe_resolves_from_the_configured_executable_name
FAILED tests/hermes_cli/test_desktop_exe_integrity.py::test_linux_binary_resolves_from_either_spelling
3 failed, 1 passed, 5 deselected

Notes for reviewers

  • Fallbacks are unchanged behavior, deliberately. Every derivation falls back to the stock names, so an install with no reachable package.json (a packaged-only install with no checkout, a partially wiped tree) behaves exactly as it does today.
  • Known remaining gap, called out rather than hidden: for a packaged-only install of a renamed build with no checkout on disk, gui_uninstall still falls back to the stock name and will not find the bundle. Closing that means reading the name from the installed app's own package.json inside the bundle — a different lookup with its own failure modes. Happy to add it here or in a follow-up, your call.
  • Why not glob *.app / *-unpacked/* by shape? It needs no config read, but it makes the checker accept whatever is in the tree — including a stale bundle from a previous product name. Deriving from the build config keeps the check exact: the app we were configured to build, or nothing.

@vadimcomanescu
vadimcomanescu force-pushed the fix/desktop-packaged-app-product-name branch from 135e7d3 to b08ea0e Compare August 14, 2026 08:39
@vadimcomanescu

Copy link
Copy Markdown
Author

Amended the branch (force-push, no reviews yet) to close the one rough edge the original body flagged, so nobody has to read a caveat to explain a red test.

test_linux_binary_resolves_from_either_spelling packed release/linux-unpacked/aurora and asserted that exact string back. On a case-insensitive filesystem — stock macOS APFS, NTFS — the Aurora probe opens the same file, so the resolver legitimately returned the capitalized spelling and the string comparison failed. That was a property of the host filesystem, not of the resolution under test: upstream CI never saw it (the Linux lane is case-sensitive, and tests-os.yml selects only -m macos_only / -m windows_only, which these tests do not carry), but a maintainer's first local run on a Mac would have.

The test now compares by file identity instead of by string:

resolved = cli_main._desktop_packaged_executable(desktop_dir)

assert resolved is not None
assert resolved.samefile(exe)
assert resolved.parent == exe.parent
assert resolved.name.lower() == "aurora"

Production code is untouched by the amendment — the probe order in _desktop_packaged_executable is exactly what it was.

Evidence, same machine:

  • stock case-insensitive APFS: 12 passed, 4 skipped
  • case-sensitive APFS volume (pytest --basetemp onto it): 12 passed, 4 skipped
  • with hermes_cli/main.py reverted to main: all three renamed-build cases fail on assert resolved is not None, while test_stock_names_still_resolve_without_a_build_config keeps passing — so the relaxed assertion still catches the bug it was written for, and the no-regression control is still doing its job.

The body has been updated to describe this final state.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard comp/desktop Electron desktop app (apps/desktop/*) area/install-update Installer, updater, packaging, wheels, doctor sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 14, 2026
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

fix(desktop): resolve the packed app by its configured product name

  1. _desktop_product_name() (hermes_cli/gui_uninstall.py) derives the name from the current checkout at uninstall time. If a renamed build ("Aurora") was installed and the checkout is later removed, replaced, or hermes uninstall --gui runs from a different HERMES_HOME, the computed name falls back to "Hermes" and the actual bundle / NSIS dir / userData are missed. Persisting the packed product name in the build stamp written at install (_write_desktop_build_stamp) would make uninstall independent of the current tree.
  2. Same class for desktop_userdata_dir(): Electron's userData is keyed by the product name at install time; re-deriving it from the current checkout can point at a different directory than the running app uses.
  3. Five near-duplicate implementations of "read productName/executableName with fallback" (gui_uninstall.py, hermes_cli/main.py, scripts/desktop-update/posix.sh, scripts/install.sh, scripts/install.ps1) with slightly different precedence — e.g. gui_uninstall reads build.productName/pkg.productName while main.py also handles build.executableName. They can drift independently; a single shared source (or documented precedence in one place) would keep them consistent.
  4. sorted(release_dir.glob("mac*")) changes candidate order vs the old glob: lexically mac < mac-arm64, so when both exist the x64 bundle wins deterministically. The Windows path has an explicit _expected_windows_pe_machines preference — confirm this lexical ordering is the intended macOS arch preference.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/install-update Installer, updater, packaging, wheels, doctor comp/cli CLI entry point, hermes_cli/, setup wizard comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants