diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 9cd3a13a17921..6e162a006b800 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -184,6 +184,7 @@ import { revalidatePooledRemoteBackends, revalidateRemoteConnection } from './remote-liveness' +import { missingRendererAssets } from './renderer-bundle' import { attachRendererConsoleCapture, formatRendererBoundaryReport } from './renderer-log' import { buildSessionWindowUrl, @@ -3566,10 +3567,39 @@ function resolveWebDist() { function resolveRendererIndex() { const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')] - const found = candidates.find(fileExists) + const present = candidates.filter(fileExists) - if (found) { - return found + // index.html and the hashed chunks it names are one generation. An update + // that replaces only one of the two shipped copies (app.asar vs + // app.asar.unpacked) leaves a TORN copy: the window loads, then dies on the + // first lazy import with "Failed to fetch dynamically imported module" and + // every restart reloads the same torn copy. Prefer a copy whose modules are + // all present, so the intact generation heals the boot by itself. + for (const candidate of present) { + const missing = missingRendererAssets(candidate) + + if (missing.length === 0) { + return candidate + } + + rememberLog( + `[renderer] skipping torn renderer bundle at ${candidate}: ` + + `${missing.length} module file(s) named by index.html are missing ` + + `(${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''})` + ) + } + + if (present.length > 0) { + // Every copy is torn. Load the first one anyway — the boundary's error is + // still better than a blank window — but say what is wrong and how to fix + // it, because no amount of restarting repairs a torn bundle. + rememberLog( + `[renderer] every renderer bundle is incomplete (${present.join(', ')}). ` + + `The last update replaced the app while its files were locked. ` + + `Repair with: hermes desktop --force-build` + ) + + return present[0] } // Nothing on disk. A packaged build with no renderer bundle blank-pages with diff --git a/apps/desktop/electron/renderer-bundle.ts b/apps/desktop/electron/renderer-bundle.ts new file mode 100644 index 0000000000000..f977729e8db39 --- /dev/null +++ b/apps/desktop/electron/renderer-bundle.ts @@ -0,0 +1,71 @@ +/** + * Renderer bundle generation check. + * + * `index.html` and the hashed chunks under `dist/assets/` are ONE generation: + * every `lazy()` route resolves to a filename baked into that generation's + * module graph. A self-update that replaces the package while its files are + * locked (antivirus, a still-running instance, an interrupted Windows replace) + * can leave the two copies electron-builder ships — inside `app.asar` and, + * because `asarUnpack` lists `dist/**`, beside it in `app.asar.unpacked` — + * from DIFFERENT generations. The window then loads an `index.html` whose + * chunks are gone and dies on the first lazy import: + * + * Failed to fetch dynamically imported module: + * …/app.asar/dist/assets/shiki-block-COiz1pEN.js + * + * The app looks permanently broken (every relaunch reloads the same torn copy), + * yet the OTHER copy is usually intact. This makes that checkable, so the + * loader can prefer a complete generation and only report a repair when both + * are torn. + * + * Pure + injectable so it is testable without booting Electron. `fs` here is + * Electron's asar-aware fs: paths inside `app.asar` read like real files. + */ + +import fs from 'node:fs' +import path from 'node:path' + +// The modules the browser fetches before any app code runs: Vite emits them as +// `