What I’m building
A fully offline Electron desktop app (Windows, packaged via electron-builder) that ships a single 360° virtual tour. The React/Vite UI lives in dist/ (inside app.asar), and ~300 panorama PNGs + an .obj model live in a tour_assets/ folder next to the .exe so they can be swapped without rebuilding the installer.
The renderer loads via mainWindow.loadURL(“app://linea/”). Three.js’s TextureLoader then fetches each panorama from app://linea/tour_assets/Render_XXXX.png.
What happens
Every panorama request fails in the renderer with the same pattern:
GET app://linea/tour_assets/Render_0001.png net::ERR_FAILED 200 (OK)
The status is 200 (OK) — the file is being found and served — but Chromium ultimately rejects the response with ERR_FAILED. Three.js’s ImageLoader then retries forever and the tour never starts.
DevTools → Network → clicking the failed request → “Headers” tab → only shows:
Failed to load response data: No data found for resource with given identifier
So I can’t see the actual response headers either.
The file is definitely on disk and readable:
D:…\tour_assets\Render_0001.png exists, ~20 MB, valid 8K equirectangular PNG
JavaScript bundle, CSS, and index.html all load fine over app:// — only the loads (which three.js does with crossOrigin=“anonymous”) fail.
Environment
electron: ^31.3.0
OS: Windows 11
Renderer entry: app://linea/ (HashRouter)
Three.js: ^0.160.1 — uses TextureLoader which sets crossOrigin=“anonymous” on the underlying by default
Package built with vite build → dist/ lives inside app.asar; tour_assets/ ships beside the .exe
What I’ve already tried (in electron/main.js)
All four approaches return 200 OK followed by net::ERR_FAILED for requests:
protocol.handle(“app”, req => new Response(buffer, { headers: { “Content-Type”: “image/png”, “Access-Control-Allow-Origin”: “*”, “Content-Length”: …, “Cache-Control”: “no-store” } })) — fresh fs.promises.readFile per request so no Buffer reuse / detach.
protocol.handle(“app”, req => net.fetch(pathToFileURL(filePath).toString())) — Electron’s recommended modern path.
protocol.registerFileProtocol(“app”, (req, cb) => cb(filePath)) — the deprecated-but-stable API that just hands Chromium the file path.
Setting webSecurity: false in webPreferences (last resort) — still fails the same way.
I’ve also tried, in every variant:
protocol.registerSchemesAsPrivileged([{ scheme: “app”, privileges: { standard, secure, supportFetchAPI, stream, corsEnabled, bypassCSP } }]) registered at module top-level (before app.whenReady).
app.commandLine.appendSwitch(“disable-features”, “BlockInsecurePrivateNetworkRequests”).
session.defaultSession.clearStorageData({ storages: [“serviceworkers”, “shadercache”, “cachestorage”] }) on launch, in case a previous failed response was being cached.
Toggling sandbox, contextIsolation, nodeIntegration.
JS/CSS/HTML all load — only panorama loads fail. Switching the renderer to load via file:// directly is something I’d like to avoid because vite’s relative URLs and tour_assets being external to the asar both get awkward.
What I’m hoping to figure out
Why does net::ERR_FAILED follow a 200 on Electron 31 / Windows when the response body is fully provided, with explicit CORS + MIME headers + privileged scheme?
Is there a known Electron 31 issue with crossOrigin=“anonymous” requests against a custom standard + secure + corsEnabled + bypassCSP scheme that I’m hitting?
If webSecurity: false doesn’t fix it either, what’s left? Is there something I should be doing in webRequest.onHeadersReceived to inject CORS headers post-protocol-handler?
I’ll attach my current electron/main.js and the exact console output below.
If anyone needs more, I can upload a src.zip of the full project (React renderer + Electron main + vite config) — just ask. Thanks in advance.