fix(desktop): resolve electronDist dynamically + self-heal blocked installs (supersedes #48081/#48082) (#48091)

* fix(desktop): resolve electronDist dynamically + self-heal blocked installs

Supersedes the static-path approach (#48081) and the install-step self-heal
(#48082) with a fix that removes the whole failure class instead of chasing each
symptom. Three distinct faults converged into the June desktop-build outage; this
closes all three.

Root cause (the part #48081 left open — "Gap B"):
  build.electronDist was a static relative path in apps/desktop/package.json, but
  npm workspace hoisting is NOT deterministic — depending on the npm version and
  what else is installed, npm nests the workspace-only electron devDep under
  apps/desktop/node_modules/electron OR hoists it to the repo root. A static path
  matches only one layout, so a clean install intermittently fails with "The
  specified electronDist does not exist". #48081 re-pointed the path at the
  nested layout (correct today) but electron-builder reads electronDist
  STATICALLY, so any future hoist change silently breaks it again — only caught
  by a CI invariant, never self-corrected.

Fix:
- scripts/run-electron-builder.cjs: resolve electron the way Node's runtime does
  — require.resolve("electron/package.json") walks node_modules from the desktop
  project upward and finds electron wherever npm actually put it. The path can
  never drift out of sync with the install layout again, on any OS/npm version.
    * dist present -> pass -c.electronDist=<abs>/dist so electron-builder reuses
      the unpacked runtime (keeps the #38673 fast path that dodges the 26.8.x
      missing-binary re-unpack bug).
    * dist absent  -> omit electronDist; electron-builder fetches Electron itself
      via @electron/get honoring electronVersion + ELECTRON_MIRROR.
  package.json: builder script now runs the wrapper; the static build.electronDist
  is removed (the resolver owns it).
- main.py / install.sh / install.ps1: on a dependency-install failure where the
  electron package staged but its dist is missing (electron's install.js
  process.exit(1) on a blocked/throttled binary download — #47266/#47917/#48021),
  repopulate the dist via electron's downloader (canonical, then npmmirror.com)
  and CONTINUE to the build instead of aborting. npm runs postinstall LAST, so
  the only casualty is electron/dist; bailing here is what made the pack-time
  mirror self-heal unreachable on a blocked network. Hard-fail only when electron
  never staged at all (a genuine dependency error).
- The pack-time mirror fallback now retries the build even when the pre-fetch
  can't populate the dist: the wrapper lets electron-builder download Electron
  itself via the mirror, so the retry is no longer a no-op (it was, when
  electronDist was a static path).

The exact 40.10.2 pin (already on main) keeps the third mode — the native
@electron-internal/extract-zip win32 binding that 40.10.3/40.10.4 ship without a
published prebuild — from recurring.

Tests:
- test_desktop_electron_pin.py: replace the static-path-matches-lockfile
  invariant with contracts that there is no hardcoded electronDist to drift, the
  builder script routes through the resolver, and the resolver uses Node module
  resolution + injects -c.electronDist.
- test_gui_command.py: install-failure self-heal continues to build; genuine
  (electron-never-staged) install failure still hard-fails; pack retries under
  the mirror even when the pre-fetch is blocked.

Salvages/supersedes the overlapping community work in #48003 (sitkarev),
#48012 (omegazheng), #48033 (james47kjv), and #48082.

Co-authored-by: sitkarev <59806492+sitkarev@users.noreply.github.com>
Co-authored-by: omegazheng <zheng@omegasys.eu>
Co-authored-by: james47kjv <220877172+james47kjv@users.noreply.github.com>

* fix(desktop): narrow Electron self-heal to real missing-dist failures

Follow-up on #48091 to remove the remaining misdiagnosis risk from the
installer/build fallback path (#46785 concern): only take the Electron
repair/retry path when Electron's package files are staged and dist is actually
missing/corrupt.

- main.py: add _electron_pkg_staged_missing_dist() and use it to gate install
  failure recovery; fail fast for unrelated npm install errors.
- main.py/install.sh/install.ps1: run cache purge + retry only when dist is
  missing; do not retry unrelated tsc/vite/build failures under an
  Electron-specific narrative.
- install.sh/install.ps1: tighten install-stage self-heal guard to require both
  package.json + install.js and missing dist.
- tests: add coverage that install failure hard-fails when Electron dist already
  exists, and update retry test to reflect the tightened recovery condition.

Validation:
- Python tests: 64 passed
- install.sh-related tests included in the run
- Real mac build on this machine:
  - npm ci at repo root: success
  - cd apps/desktop && npm run pack: success
  - electron-builder packaged darwin arm64 and used custom unpacked Electron dist

* refactor(desktop): trim electron self-heal helpers and comments

Deduplicate mirror-retry into _try_redownload_electron_dist / shell
counterparts; shorten wrapper and install-script commentary without
changing recovery semantics.

---------

Co-authored-by: sitkarev <59806492+sitkarev@users.noreply.github.com>
Co-authored-by: omegazheng <zheng@omegasys.eu>
Co-authored-by: james47kjv <220877172+james47kjv@users.noreply.github.com>
This commit is contained in:
brooklyn! 2026-06-17 18:48:35 -05:00 committed by GitHub
parent acc8916ac7
commit c1f9eb0ec4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 310 additions and 247 deletions

View file

@ -2161,12 +2161,10 @@ function Clear-ElectronBuildCache {
return $removed
}
# Return the Electron package directory the desktop workspace installs. npm may
# nest workspace-only dev dependencies under apps\desktop\node_modules instead
# of hoisting them to the repo root; which layout you get depends on the npm
# version and what else is installed. apps\desktop\package.json points
# electron-builder's electronDist there, so prefer the workspace-local package
# and fall back to the root hoist.
# Last-resort Electron mirror after GitHub download fails (#47266).
$script:DesktopElectronFallbackMirror = "https://npmmirror.com/mirrors/electron/"
# Electron package dir — workspace-local nest first, then root hoist.
function Get-ElectronDir {
param([string]$InstallDir)
$desktopLocal = Join-Path $InstallDir 'apps\desktop\node_modules\electron'
@ -2174,11 +2172,7 @@ function Get-ElectronDir {
return (Join-Path $InstallDir 'node_modules\electron')
}
# True when the desktop workspace electronDist holds a usable Electron binary.
# electron-builder reads the binary from build.electronDist since #38673, so
# this is the exact file whose absence makes a pack fail with "The specified
# electronDist does not exist". A dist dir that exists but is missing
# electron.exe (partial extraction / aborted postinstall) is NOT ok.
# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.cjs).
function Test-ElectronDist {
param([string]$InstallDir)
$electronDir = Get-ElectronDir -InstallDir $InstallDir
@ -2186,22 +2180,7 @@ function Test-ElectronDist {
return (Test-Path -LiteralPath $distExe)
}
# (Re)populate the desktop Electron dist via electron's own downloader.
#
# Since #38673 the desktop build pins build.electronDist, so electron-builder
# reads the Electron binary straight from there and never downloads it during
# `npm run pack`. That dist tree is produced by the electron package's
# postinstall (install.js) during `npm ci`. When that download is
# blocked/throttled (GitHub's release host is unreachable in some regions -
# #47266), dist is missing and re-running pack only re-throws "The specified
# electronDist does not exist". The mirror fallback therefore has to drive THIS
# downloader, not another pack.
#
# No-op (returns $true) when the dist binary is already present. Otherwise drops a
# partial dist + version marker (electron's install.js short-circuits when
# path.txt already matches) and runs the downloader once, optionally via a
# mirror. Best-effort: never throws. Returns $true iff the dist binary exists
# afterward.
# Best-effort: run electron/install.js to populate dist/ (optional mirror).
function Restore-ElectronDist {
param([string]$InstallDir, [string]$Mirror)
if (Test-ElectronDist -InstallDir $InstallDir) { return $true }
@ -2234,6 +2213,23 @@ function Restore-ElectronDist {
return (Test-Path -LiteralPath $distExe)
}
function Test-ElectronPkgStagedMissingDist {
param([string]$InstallDir)
$electronDir = Get-ElectronDir -InstallDir $InstallDir
return (
(Test-Path -LiteralPath (Join-Path $electronDir 'package.json')) -and
(Test-Path -LiteralPath (Join-Path $electronDir 'install.js')) -and
(-not (Test-ElectronDist -InstallDir $InstallDir))
)
}
function Try-RestoreElectronDist {
param([string]$InstallDir)
if (Restore-ElectronDist -InstallDir $InstallDir) { return $true }
if ($env:ELECTRON_MIRROR) { return $false }
return Restore-ElectronDist -InstallDir $InstallDir -Mirror $script:DesktopElectronFallbackMirror
}
function Install-Desktop {
# Build apps/desktop into a launchable Hermes.exe. Only called from
# Stage-Desktop, which is itself only included in the manifest when
@ -2329,10 +2325,16 @@ function Install-Desktop {
}
$ErrorActionPreference = $prevEAP
if ($code -ne 0) {
Show-NpmCertHint ($npmOut -join "`n") | Out-Null
throw "desktop workspace npm install failed (exit $code) -- see lines above for cause"
if (Test-ElectronPkgStagedMissingDist -InstallDir $InstallDir) {
Write-Warn "Desktop dependency install failed with a missing Electron dist; attempting self-heal..."
Try-RestoreElectronDist -InstallDir $InstallDir | Out-Null
} else {
Show-NpmCertHint ($npmOut -join "`n") | Out-Null
throw "desktop workspace npm install failed (exit $code) -- see lines above for cause"
}
} else {
Write-Success "Desktop workspace dependencies installed"
}
Write-Success "Desktop workspace dependencies installed"
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Pop-Location
@ -2375,57 +2377,34 @@ function Install-Desktop {
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
$code = $LASTEXITCODE
if ($code -ne 0) {
# A corrupt cached Electron zip makes `pack` fail with an opaque
# ENOENT on the final `electron` -> `Hermes` rename: app-builder's
# unpack-electron extracted a partial tree (missing the binary) from
# the bad zip, and re-running reuses the poisoned cache forever.
# Purge the cached download + any stale unpacked output and retry
# once; @electron/get re-downloads with its own SHASUM check. Without
# this a corrupt download hard-fails the whole installer.
$purged = @(Clear-ElectronBuildCache -DesktopDir $desktopDir)
# electronDist is pinned to node_modules\electron\dist (#38673):
# electron-builder reads the Electron binary from there and `pack`
# never downloads it, so purging the cache + re-running pack can't by
# itself repopulate a missing/partial dist. When the dist is actually
# gone, re-run electron's own downloader so the retry has a binary to
# read. Gated on the dist check so an unrelated build failure
# (tsc/vite) doesn't trigger a pointless ~200MB refetch.
$purged = @()
$restored = $false
if (-not (Test-ElectronDist -InstallDir $InstallDir)) {
$purged = @(Clear-ElectronBuildCache -DesktopDir $desktopDir)
$restored = Restore-ElectronDist -InstallDir $InstallDir
}
if ($purged.Count -gt 0 -or $restored) {
if ($restored) {
Write-Warn "Desktop build failed - refreshed the Electron download, retrying once:"
foreach ($p in $purged) { Write-Info " - $p" }
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
$code = $LASTEXITCODE
}
}
# Still failing and the user hasn't pinned their own mirror: GitHub's
# Electron release host is likely blocked/throttled (the repeating
# "retrying" log). Retry once via npmmirror.com — the de-facto Electron
# community mirror (Alibaba). @electron/get SHASUM-checks the download,
# but the SHASUMS come from the same mirror, so that guards against a
# corrupt/partial download, NOT a compromised mirror: an explicit trust
# trade-off we only make AFTER the canonical GitHub download has failed,
# and we never override a user-pinned ELECTRON_MIRROR.
if ($code -ne 0 -and -not $env:ELECTRON_MIRROR) {
$mirror = "https://npmmirror.com/mirrors/electron/"
$mirror = $script:DesktopElectronFallbackMirror
Write-Warn "Desktop build still failing - the Electron download from GitHub looks blocked."
Write-Warn "Re-downloading Electron via a public mirror ($mirror), then rebuilding:"
Write-Info " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
# electronDist is pinned (#38673), so `npm run pack` never downloads
# Electron - the mirror only helps if it drives electron's own
# downloader. Re-fetch the binary through the mirror first; otherwise
# the retry just re-reads the same missing dist and re-throws
# "The specified electronDist does not exist" (#47266).
$haveDist = Test-ElectronDist -InstallDir $InstallDir
if (-not $haveDist) { $haveDist = Restore-ElectronDist -InstallDir $InstallDir -Mirror $mirror }
if ($haveDist) {
if (-not (Test-ElectronDist -InstallDir $InstallDir)) {
Restore-ElectronDist -InstallDir $InstallDir -Mirror $mirror | Out-Null
}
$prevMirror = $env:ELECTRON_MIRROR
$env:ELECTRON_MIRROR = $mirror
try {
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
$code = $LASTEXITCODE
} else {
Write-Warn "Could not re-download Electron from the mirror (node_modules\electron\dist still missing)"
} finally {
$env:ELECTRON_MIRROR = $prevMirror
}
}
$ErrorActionPreference = $prevEAP

View file

@ -2398,21 +2398,10 @@ _desktop_pack() {
fi
}
# Public Electron mirror used as a last-resort fallback when GitHub's release
# host is blocked/throttled (the repeating "retrying" symptom). npmmirror.com is
# the de-facto Electron community mirror (Alibaba). @electron/get SHASUM-checks
# the download, but the SHASUMS come from the same mirror — that guards against a
# corrupt/partial download, NOT a compromised mirror. Reaching for it is an
# explicit trust trade-off we only make AFTER the canonical GitHub download has
# failed, and we never override a user-pinned ELECTRON_MIRROR.
# Last-resort Electron mirror after GitHub download fails (#47266).
DESKTOP_ELECTRON_FALLBACK_MIRROR="https://npmmirror.com/mirrors/electron/"
# Return the Electron package directory the desktop workspace installs. npm may
# nest workspace-only dev dependencies under apps/desktop/node_modules instead
# of hoisting them to the repo root; which layout you get depends on the npm
# version and what else is installed. apps/desktop/package.json points
# electron-builder's electronDist there, so prefer the workspace-local package
# and fall back to the root hoist. $1 = the workspace root holding node_modules.
# Electron package dir — workspace-local nest first, then root hoist.
_electron_dir() {
local install_dir="$1"
if [ -d "$install_dir/apps/desktop/node_modules/electron" ]; then
@ -2422,12 +2411,7 @@ _electron_dir() {
fi
}
# True (returns 0) when the desktop workspace electronDist holds a usable
# Electron binary. electron-builder reads the binary from build.electronDist
# since #38673, so this is the exact file whose absence makes a pack fail with
# "The specified electronDist does not exist". A dist dir that exists but is
# missing the binary (partial extraction / aborted postinstall) is NOT ok.
# $1 = the workspace root holding node_modules.
# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.cjs).
_electron_dist_ok() {
local install_dir="$1"
local electron_dir
@ -2439,22 +2423,7 @@ _electron_dist_ok() {
fi
}
# (Re)populate the desktop Electron dist via electron's own downloader.
#
# Since #38673 the desktop build pins build.electronDist, so electron-builder
# reads the Electron binary straight from there and never downloads it during
# `npm run pack`. That dist tree is produced by the electron package's
# postinstall (install.js) during `npm ci`. When that download is
# blocked/throttled (GitHub's release host is unreachable in some regions -
# #47266), dist is missing and re-running pack only re-throws "The specified
# electronDist does not exist". The mirror fallback therefore has to drive THIS
# downloader, not another pack.
#
# No-op (returns 0) when the dist binary is already present. Otherwise drops a
# partial dist + version marker (electron's install.js short-circuits when
# path.txt already matches) and runs the downloader once. $1 = the workspace root
# holding node_modules; optional $2 = an ELECTRON_MIRROR base URL. Best-effort:
# returns 0 iff the dist binary exists afterward.
# Best-effort: run electron/install.js to populate dist/ (optional mirror).
_restore_electron_dist() {
local install_dir="$1"
local mirror="${2:-}"
@ -2476,6 +2445,19 @@ _restore_electron_dist() {
_electron_dist_ok "$install_dir"
}
_electron_pkg_staged_missing_dist() {
local install_dir="$1"
local electron_dir
electron_dir="$(_electron_dir "$install_dir")"
[ -f "$electron_dir/package.json" ] && [ -f "$electron_dir/install.js" ] && ! _electron_dist_ok "$install_dir"
}
_restore_electron_dist_with_fallback() {
local install_dir="$1"
_restore_electron_dist "$install_dir" \
|| { [ -z "${ELECTRON_MIRROR:-}" ] && _restore_electron_dist "$install_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; }
}
# Build apps/desktop into a launchable native app. Mirrors install.ps1's
# Install-Desktop: a root-level npm install so the apps/* workspace resolves
# the desktop's own deps (Electron ~150MB), then `npm run pack`
@ -2517,7 +2499,12 @@ install_desktop() {
# `tsc -b` failing with no obvious cause. Fall back to `npm install`
# only if `npm ci` is unavailable or the lockfile is out of sync.
log_info "Installing desktop workspace dependencies (includes Electron ~150MB, 1-3min)..."
( cd "$INSTALL_DIR" && npm ci ) || ( cd "$INSTALL_DIR" && npm install ) || {
if ( cd "$INSTALL_DIR" && npm ci ) || ( cd "$INSTALL_DIR" && npm install ); then
log_success "Desktop workspace dependencies installed"
elif _electron_pkg_staged_missing_dist "$INSTALL_DIR"; then
log_warn "Desktop dependency install failed with a missing Electron dist; attempting self-heal..."
_restore_electron_dist_with_fallback "$INSTALL_DIR" || true
else
log_error "Desktop workspace npm install failed"
# Common cause: a previous 'sudo npm'/'sudo npx' left root-owned files in
# ~/.npm, so this non-root install can't write the shared cache. npm hides
@ -2530,8 +2517,7 @@ install_desktop() {
log_info "Then re-run this installer, or build manually:"
log_info " cd \"$INSTALL_DIR\" && npm ci && cd apps/desktop && npm run pack"
return 1
}
log_success "Desktop workspace dependencies installed"
fi
# 2. Build, with up to three escalating attempts so a transient/blocked
# Electron download self-heals instead of failing the whole install:
@ -2545,21 +2531,13 @@ install_desktop() {
if _desktop_pack "$desktop_dir"; then
pack_ok=true
else
# (b) Corrupt cached Electron zip is the most common self-healable cause.
local purged
purged="$(clear_electron_build_cache "$desktop_dir")"
# electronDist is pinned to node_modules/electron/dist (#38673):
# electron-builder reads the binary from there and `pack` never downloads
# it, so purging the cache + re-running pack can't by itself repopulate a
# missing/partial dist. When the dist is actually gone, re-run electron's
# own downloader so the retry has a binary to read. Gated on the dist
# check so an unrelated build failure (tsc/vite) doesn't trigger a
# pointless ~200MB refetch.
local purged=""
local restored=false
if ! _electron_dist_ok "$INSTALL_DIR"; then
purged="$(clear_electron_build_cache "$desktop_dir")"
if _restore_electron_dist "$INSTALL_DIR"; then restored=true; fi
fi
if [ -n "$purged" ] || [ "$restored" = true ]; then
if [ "$restored" = true ]; then
log_warn "Desktop build failed; refreshed the Electron download and retrying once..."
if _desktop_pack "$desktop_dir"; then
pack_ok=true
@ -2567,27 +2545,14 @@ install_desktop() {
fi
fi
# (c) Still failing and the user hasn't pinned their own mirror: the GitHub
# release host is likely blocked/throttled. Re-download the Electron
# binary via a public mirror, then retry. The mirror MUST drive
# electron's own downloader — `npm run pack` reads the pinned electronDist
# and never downloads, so a mirror passed only to pack is a no-op (#47266).
# (c) GitHub blocked → mirror fallback (#47266).
if [ "$pack_ok" = false ] && [ -z "${ELECTRON_MIRROR:-}" ]; then
log_warn "Desktop build still failing — the Electron download from GitHub looks blocked."
log_warn "Re-downloading Electron via a public mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR), then rebuilding..."
log_warn " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
local have_dist=false
if _electron_dist_ok "$INSTALL_DIR"; then
have_dist=true
elif _restore_electron_dist "$INSTALL_DIR" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then
have_dist=true
fi
if [ "$have_dist" = true ]; then
if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then
pack_ok=true
fi
else
log_warn "Could not re-download Electron from the mirror (node_modules/electron/dist still missing)"
_electron_dist_ok "$INSTALL_DIR" || _restore_electron_dist "$INSTALL_DIR" "$DESKTOP_ELECTRON_FALLBACK_MIRROR" || true
if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then
pack_ok=true
fi
fi