Skip to content

fix(build): install per-machine so the Windows installer elevates (#264) - #268

Merged
dbfx merged 3 commits into
mainfrom
fix/installer-per-machine-elevation
Jul 27, 2026
Merged

fix(build): install per-machine so the Windows installer elevates (#264)#268
dbfx merged 3 commits into
mainfrom
fix/installer-per-machine-elevation

Conversation

@dbfx

@dbfx dbfx commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #264.

The mismatch

win.requestedExecutionLevel: requireAdministrator is applied only to Kudu.exe's manifest — app-builder-lib patches it in winPackager.signAndEditResourcesresEdit.patchManifestExecutionLevel, and nothing else in the toolchain reads the option. It never reaches the NSIS script.

The installer's own execution level comes from nsis.perMachine, which was unset and therefore false. So installer.nsi compiled with:

!ifdef INSTALL_MODE_PER_ALL_USERS
  RequestExecutionLevel admin
!else
  RequestExecutionLevel user   ; <- us
!endif

Kudu-Setup-*.exe never asked for elevation, installed per-user into %LOCALAPPDATA%\Programs\Kudu, and then had to launch a binary whose manifest demands admin. Running the installer elevated by hand made the whole chain consistent — which is the workaround in the issue.

Two more consequences of the same mismatch

  • Local privilege escalation. %LOCALAPPDATA%\Programs is writable by the user, and applyAutoLaunchWin32 registers Kudu.exe as a scheduled task with RunLevel HighestAvailable. An auto-elevating binary living in a user-writable directory is replaceable by any process running as that user.
  • Wrong update metadata. updateInfo.isAdminRightsRequired is derived from perMachine only (NsisTarget.js), so the published latest.yml advertised false for an app that cannot run without admin. The reporter flagged this independently.

The change

nsis.perMachine: true. The installer now requests admin up front (UAC at launch), installs to Program Files, and sets isAdminRightsRequired so electron-updater elevates when applying updates.

Existing per-user installs migrate automatically: electron-builder's install section runs uninstallOldVersion HKEY_CURRENT_USER whenever the install mode is all.

Adds src/main/build-config.test.ts pinning the app manifest and installer execution levels together — nothing in the app's own code paths can catch a regression here, since it only manifests once a user runs the installer.

Confidence

The elevation mismatch, the EoP, and the metadata bug are all confirmed by reading the app-builder-lib 26.8.1 sources.

The hang itself is probable cause, not proven. I traced .onInit and the install section looking for the reported spin (one thread at 100%, zero I/O delta, no child processes) and every candidate loop — ALLOW_ONLY_ONE_INSTALLER_INSTANCE, GetDParameter, _CHECK_APP_RUNNING, GetProcessInfo — is bounded, and the ones that could stall would spawn children or do I/O. The issue is n=1 and unreproduced here. This PR fixes the configuration defect the reporter's workaround points at and is correct on its own merits regardless; if a non-elevated hang survives it, that's a separate NSIS-level bug.

`win.requestedExecutionLevel: requireAdministrator` is applied only to
Kudu.exe's manifest — app-builder-lib patches it in signAndEditResources
and it never reaches the NSIS script. With `nsis.perMachine` unset it
defaults to false, so installer.nsi compiled with
`RequestExecutionLevel user`: Kudu-Setup-*.exe never asked for elevation,
installed into %LOCALAPPDATA%\Programs\Kudu, and then tried to launch a
binary whose manifest demands admin. Running the installer elevated by
hand made the whole chain consistent, which is why that path worked.

Two further consequences of the mismatch:

- %LOCALAPPDATA%\Programs is user-writable and applyAutoLaunchWin32
  registers Kudu.exe with RunLevel HighestAvailable, so an auto-elevating
  binary sat in a directory any process running as the user can rewrite.
- updateInfo.isAdminRightsRequired is derived from perMachine, so the
  published metadata advertised false for an app that cannot run without
  admin.

perMachine: true makes the installer request admin up front, install to
Program Files, and set isAdminRightsRequired. Existing per-user installs
are migrated by electron-builder, which runs uninstallOldVersion against
HKEY_CURRENT_USER when the install mode is all.

Adds a test pinning the app manifest and installer execution levels
together, since nothing in the app's own code paths can catch this.
dbfx added 2 commits July 27, 2026 12:23
The js-yaml devDependency added alongside the per-machine fix desynced
package-lock.json — npm pruned the nested conventional-commits-parser
and conventional-commits-filter entries it considered extraneous locally,
which every CI job then rejected with `npm ci` EUSAGE.

Restore package.json and package-lock.json to their state on main and
read the two options the test cares about directly. The config is flat
and hand-maintained, so a top-level block lookup is enough and costs no
lockfile churn.
`flushes in batches so a large clean never buffers everything` creates and
deletes 1200 real files. That count is load-bearing — flushPending() fires
every 500 records, so proving a remainder flush on top of two full batches
needs more than 1000 items — and it is the slowest test in the suite at
roughly 2s locally. On the Windows CI runner it crossed the 5s default and
timed out.

Raise the timeout for that one test rather than shrinking the fixture and
losing the third-batch assertion. Unrelated to the installer fix; it
surfaced as a flake on this branch.
@dbfx
dbfx merged commit f12ba9d into main Jul 27, 2026
9 checks passed
@dbfx
dbfx deleted the fix/installer-per-machine-elevation branch July 27, 2026 10:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows installer hangs at 100% CPU (single thread, zero I/O) when run without elevation

1 participant