From 57705a3d80bae6804eea95c7b3b7a9490e73c72c Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 27 Jul 2026 09:29:17 +0200 Subject: [PATCH] fix(compositor): bypass Electron's asar-transparent fs when probing for real asset files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveSceneAssetPath`'s candidate-probe loop (from the previous commit) used plain `fs.existsSync`, which Electron patches to read *through* an asar archive — from JS's point of view, `.../app.asar/dist/wallpapers/x.jpg` "exists". So the probe locked onto the VITE_PUBLIC candidate (pointing into the asar) on every call and never reached the real `resourcesPath` files, making the previous fix a no-op in the actual packaged app: rebuilt and relaunched it, and the exact same "wallpaper image ... introuvable" errors came right back. Confirmed the mechanism by running a probe script inside the packaged binary via `ELECTRON_RUN_AS_NODE=1`: patched `fs.existsSync` on the asar-internal path answered `true`; the native addon (raw `fopen`/`CreateFile`, no knowledge of asar) still gets ENOENT on that same path. `original-fs` is Electron's own escape hatch for this — same API, unpatched. Route the existence check through it, falling back to plain `fs` where `original-fs` doesn't exist (outside Electron, i.e. the vitest suite), since plain Node's `fs` was never patched in the first place there. Re-verified live after rebuilding: the same project's bundled wallpaper and themed cursor sprite now render in the packaged app, and the "introuvable"/ENOENT log lines are gone. --- .../services/compositorViewService.ts | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index e9189db81..ab114e775 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -24,15 +24,30 @@ import type { const localRequire: NodeRequire = createRequire(import.meta.url) as unknown as NodeRequire; /** - * The native compositor is a separate process and can only read absolute - * filesystem paths — it can't resolve renderer-relative asset URLs like - * `/wallpapers/wallpaper1.jpg` or fetch `http(s)://`/`data:` URLs. Bundled - * wallpapers live under `process.env.VITE_PUBLIC` (dev: `/public`, - * packaged: the renderer dist), so rewrite an image background's root-relative - * path to that absolute location before handing the scene to the addon. Other - * schemes (data:, http:) are left as-is; the native side falls back to a flat - * colour when it can't load them. Malformed JSON passes through untouched. + * `existsSync` that answers for the REAL filesystem, not Electron's asar-transparent view of it. + * + * Electron patches `fs` so JS can read `.../app.asar/dist/wallpapers/x.jpg` as if it were a plain + * file — that's the entire point of asar. But `existsSync` inherits the same patch: called on an + * asar-internal path it returns `true`, even though nothing outside Electron's own patched `fs` + * can ever open that path — the Rust addon calls the raw OS `fopen`/`CreateFile`, which has no + * concept of asar and gets ENOENT. A candidate-probe loop built on the patched `fs.existsSync` + * therefore locks onto the WRONG candidate (VITE_PUBLIC, pointing into the asar) before ever + * trying the right one (`resourcesPath`, real files on disk) — confirmed by running this exact + * check inside the packaged binary: patched `existsSync` on the asar path answered `true`. + * + * `original-fs` is Electron's escape hatch for precisely this: the same API, unpatched. It only + * exists inside Electron, so fall back to plain `node:fs` where it doesn't — under plain Node + * (tests) there is no asar patch to route around in the first place, so plain `fs` already tells + * the truth there. */ +function realExistsSync(candidate: string): boolean { + try { + return (localRequire("original-fs") as typeof fs).existsSync(candidate); + } catch { + return fs.existsSync(candidate); + } +} + /** * Bases holding the `wallpapers/` and `cursors/` trees, most specific first. * @@ -64,7 +79,7 @@ function sceneAssetBaseDirs(): string[] { export function resolveSceneAssetPath(relativePath: string): string | null { for (const base of sceneAssetBaseDirs()) { const candidate = path.join(base, relativePath); - if (fs.existsSync(candidate)) { + if (realExistsSync(candidate)) { return candidate; } }