resolve asset base path synchronously from preload

Every consumer of /wallpapers/*.jpg — SettingsPanel, VideoPlayback,
frameRenderer — was doing async IPC round trips, useEffect dances, and
Promise.all for a value that is a build-time constant per process. Each
consumer showed briefly-empty or briefly-404ing state on first paint
until the handler's reply resolved.

The asset base URL depends only on process.defaultApp and
process.resourcesPath / __dirname — all available in preload at
context-bridge time. Compute once there, expose as a sync string.

- preload.ts resolves baseDir (process.resourcesPath packaged,
  <appRoot>/public unpackaged) and emits assetBaseUrl synchronously.
- get-asset-base-path IPC handler + main-process branching deleted.
- getAssetPath() is now sync. Returns string, not Promise<string>.
  Throws AssetBaseUnavailableError (new) when electronAPI.assetBaseUrl
  is missing — catastrophic preload failure, not silent 404.
- resolveImageWallpaperUrl() sync; same sync throw semantics.
- SettingsPanel: Promise.all + useState + useEffect collapse to one
  useMemo. First paint has real URLs, no 18× ERR_FILE_NOT_FOUND, no
  flicker.
- VideoPlayback: wallpaper-resolve useEffect collapses to useMemo.
- frameRenderer.setupBackground: drops the await.
- electronAPI type decls updated in both .d.ts files.
- 35 unit tests updated to reflect sync signature + new
  AssetBaseUnavailableError contract.

Silent-fallback behavior from getAssetPath (returning /relative when
electronAPI failed) is gone. Renderers now surface preload failures
instead of rendering 404s.
This commit is contained in:
Enriquefft
2026-04-24 18:33:03 -05:00
parent 86c1c483d4
commit 702b733074
10 changed files with 83 additions and 132 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ interface Window {
status: string; status: string;
error?: string; error?: string;
}>; }>;
getAssetBasePath: () => Promise<string | null>; assetBaseUrl: string;
storeRecordedVideo: ( storeRecordedVideo: (
videoData: ArrayBuffer, videoData: ArrayBuffer,
fileName: string, fileName: string,
+1 -17
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath } from "node:url";
import { import {
app, app,
BrowserWindow, BrowserWindow,
@@ -801,22 +801,6 @@ export function registerIpcHandlers(
} }
}); });
// Return base path for assets so renderer can resolve file:// paths in production.
// Packaged: electron-builder extraResources copies public/wallpapers -> resources/wallpapers.
// Unpackaged: wallpapers live at <appPath>/public/wallpapers.
// Single convention: "<base>/wallpapers/x.jpg" resolves in both modes.
ipcMain.handle("get-asset-base-path", () => {
try {
const baseDir = app.isPackaged
? process.resourcesPath
: path.join(app.getAppPath(), "public");
return pathToFileURL(`${baseDir}${path.sep}`).toString();
} catch (err) {
console.error("Failed to resolve asset base path:", err);
return null;
}
});
/** /**
* Handles saving an exported video file. * Handles saving an exported video file.
* Shows a save dialog, normalizes the file path for the current OS, * Shows a save dialog, normalizes the file path for the current OS,
+14 -4
View File
@@ -1,17 +1,27 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
// Asset base URL is a build-time constant per process; resolve once here so
// the renderer can consume it synchronously. Packaged: electron-builder
// extraResources copies public/wallpapers -> resources/wallpapers (see
// electron-builder.json5). Unpackaged: wallpapers live at <appRoot>/public/,
// and __dirname in dist-electron resolves to <appRoot>/dist-electron/.
const isPackagedProcess = !process.defaultApp;
const assetBaseDir = isPackagedProcess
? process.resourcesPath
: path.join(__dirname, "..", "public");
const assetBaseUrl = pathToFileURL(`${assetBaseDir}${path.sep}`).toString();
contextBridge.exposeInMainWorld("electronAPI", { contextBridge.exposeInMainWorld("electronAPI", {
assetBaseUrl,
hudOverlayHide: () => { hudOverlayHide: () => {
ipcRenderer.send("hud-overlay-hide"); ipcRenderer.send("hud-overlay-hide");
}, },
hudOverlayClose: () => { hudOverlayClose: () => {
ipcRenderer.send("hud-overlay-close"); ipcRenderer.send("hud-overlay-close");
}, },
getAssetBasePath: async () => {
// ask main process for the correct base path (production vs dev)
return await ipcRenderer.invoke("get-asset-base-path");
},
getSources: async (opts: Electron.SourcesOptions) => { getSources: async (opts: Electron.SourcesOptions) => {
return await ipcRenderer.invoke("get-sources", opts); return await ipcRenderer.invoke("get-sources", opts);
}, },
+2 -17
View File
@@ -14,7 +14,7 @@ import {
Upload, Upload,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Accordion, Accordion,
@@ -321,24 +321,9 @@ export function SettingsPanel({
onWebcamSizePresetCommit, onWebcamSizePresetCommit,
}: SettingsPanelProps) { }: SettingsPanelProps) {
const t = useScopedT("settings"); const t = useScopedT("settings");
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]); const wallpaperPaths = useMemo(() => WALLPAPER_PATHS.map(resolveImageWallpaperUrl), []);
const [customImages, setCustomImages] = useState<string[]>([]); const [customImages, setCustomImages] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let mounted = true;
(async () => {
try {
const resolved = await Promise.all(WALLPAPER_PATHS.map((p) => resolveImageWallpaperUrl(p)));
if (mounted) setWallpaperPaths(resolved);
} catch (_err) {
if (mounted) setWallpaperPaths([...WALLPAPER_PATHS]);
}
})();
return () => {
mounted = false;
};
}, []);
const colorPalette = [ const colorPalette = [
"#FF0000", "#FF0000",
"#FFD700", "#FFD700",
+11 -23
View File
@@ -1108,7 +1108,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame); videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
}; };
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null); const resolvedWallpaper = useMemo<string | null>(() => {
const source = wallpaper || DEFAULT_WALLPAPER;
const classified = classifyWallpaper(source);
if (classified.kind !== "image") return classified.value;
try {
return resolveImageWallpaperUrl(classified.path);
} catch (err) {
console.warn("[VideoPlayback] wallpaper resolve failed:", err);
return null;
}
}, [wallpaper]);
const webcamCssBoxShadow = useMemo( const webcamCssBoxShadow = useMemo(
() => getWebcamLayoutCssBoxShadow(webcamLayoutPreset), () => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
[webcamLayoutPreset], [webcamLayoutPreset],
@@ -1176,28 +1186,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamVideo.currentTime = 0; webcamVideo.currentTime = 0;
}, [webcamVideoPath]); }, [webcamVideoPath]);
useEffect(() => {
let mounted = true;
(async () => {
const source = wallpaper || DEFAULT_WALLPAPER;
const classified = classifyWallpaper(source);
if (classified.kind !== "image") {
if (mounted) setResolvedWallpaper(classified.value);
return;
}
try {
const resolved = await resolveImageWallpaperUrl(classified.path);
if (mounted) setResolvedWallpaper(resolved);
} catch (err) {
console.warn("[VideoPlayback] wallpaper resolve failed:", err);
if (mounted) setResolvedWallpaper(null);
}
})();
return () => {
mounted = false;
};
}, [wallpaper]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (videoReadyRafRef.current) { if (videoReadyRafRef.current) {
+20 -24
View File
@@ -5,6 +5,13 @@ export class UnsafeAssetPathError extends Error {
} }
} }
export class AssetBaseUnavailableError extends Error {
constructor() {
super("electronAPI.assetBaseUrl is not available; preload did not load correctly");
this.name = "AssetBaseUnavailableError";
}
}
function encodeRelativeAssetPath(relativePath: string): string { function encodeRelativeAssetPath(relativePath: string): string {
return relativePath return relativePath
.replace(/^\/+/, "") .replace(/^\/+/, "")
@@ -24,33 +31,22 @@ function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`; return value.endsWith("/") ? value : `${value}/`;
} }
export async function getAssetPath(relativePath: string): Promise<string> { export function getAssetPath(relativePath: string): string {
const encodedRelativePath = encodeRelativeAssetPath(relativePath); const encoded = encodeRelativeAssetPath(relativePath);
try { if (typeof window === "undefined") {
if (typeof window !== "undefined") { return `/${encoded}`;
if (
window.location &&
window.location.protocol &&
window.location.protocol.startsWith("http")
) {
return `/${encodedRelativePath}`;
}
if (window.electronAPI && typeof window.electronAPI.getAssetBasePath === "function") {
const base = await window.electronAPI.getAssetBasePath();
if (base) {
return new URL(encodedRelativePath, ensureTrailingSlash(base)).toString();
}
}
}
} catch (err) {
if (err instanceof UnsafeAssetPathError) {
throw err;
}
} }
return `/${encodedRelativePath}`; if (window.location?.protocol?.startsWith("http")) {
return `/${encoded}`;
}
const base = window.electronAPI?.assetBaseUrl;
if (!base) {
throw new AssetBaseUnavailableError();
}
return new URL(encoded, ensureTrailingSlash(base)).toString();
} }
export default getAssetPath; export default getAssetPath;
+1 -1
View File
@@ -280,7 +280,7 @@ export class FrameRenderer {
bgCtx.fillStyle = gradient; bgCtx.fillStyle = gradient;
bgCtx.fillRect(0, 0, this.config.width, this.config.height); bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} else { } else {
const imageUrl = await resolveImageWallpaperUrl(classified.path); const imageUrl = resolveImageWallpaperUrl(classified.path);
const img = new Image(); const img = new Image();
if (imageUrl.startsWith("http") && !imageUrl.startsWith(window.location.origin)) { if (imageUrl.startsWith("http") && !imageUrl.startsWith(window.location.origin)) {
img.crossOrigin = "anonymous"; img.crossOrigin = "anonymous";
+31 -43
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { UnsafeAssetPathError } from "./assetPath"; import { AssetBaseUnavailableError, UnsafeAssetPathError } from "./assetPath";
import { import {
BackgroundLoadError, BackgroundLoadError,
classifyWallpaper, classifyWallpaper,
@@ -125,99 +125,87 @@ describe("resolveImageWallpaperUrl", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("passes through http URL", async () => { it("passes through http URL", () => {
expect(await resolveImageWallpaperUrl("http://example.com/bg.jpg")).toBe( expect(resolveImageWallpaperUrl("http://example.com/bg.jpg")).toBe("http://example.com/bg.jpg");
"http://example.com/bg.jpg",
);
}); });
it("passes through https URL", async () => { it("passes through https URL", () => {
expect(await resolveImageWallpaperUrl("https://example.com/bg.jpg")).toBe( expect(resolveImageWallpaperUrl("https://example.com/bg.jpg")).toBe(
"https://example.com/bg.jpg", "https://example.com/bg.jpg",
); );
}); });
it("passes through file:// URL", async () => { it("passes through file:// URL", () => {
expect(await resolveImageWallpaperUrl("file:///tmp/bg.jpg")).toBe("file:///tmp/bg.jpg"); expect(resolveImageWallpaperUrl("file:///tmp/bg.jpg")).toBe("file:///tmp/bg.jpg");
}); });
it("passes through data URI", async () => { it("passes through data URI", () => {
const uri = "data:image/png;base64,AAAA"; const uri = "data:image/png;base64,AAAA";
expect(await resolveImageWallpaperUrl(uri)).toBe(uri); expect(resolveImageWallpaperUrl(uri)).toBe(uri);
}); });
it("resolves leading-slash wallpaper path via http fallback", async () => { it("resolves leading-slash wallpaper path via http fallback", () => {
expect(await resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe( expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"/wallpapers/wallpaper1.jpg", "/wallpapers/wallpaper1.jpg",
); );
}); });
it("resolves bare relative wallpaper path", async () => { it("resolves bare relative wallpaper path", () => {
expect(await resolveImageWallpaperUrl("wallpapers/wallpaper1.jpg")).toBe( expect(resolveImageWallpaperUrl("wallpapers/wallpaper1.jpg")).toBe(
"/wallpapers/wallpaper1.jpg", "/wallpapers/wallpaper1.jpg",
); );
}); });
it("encodes special characters in path segments", async () => { it("encodes special characters in path segments", () => {
expect(await resolveImageWallpaperUrl("/wallpapers/my image.jpg")).toBe( expect(resolveImageWallpaperUrl("/wallpapers/my image.jpg")).toBe("/wallpapers/my%20image.jpg");
"/wallpapers/my%20image.jpg",
);
}); });
it("rejects image paths outside /wallpapers/", async () => { it("rejects image paths outside /wallpapers/", () => {
await expect(resolveImageWallpaperUrl("/etc/passwd")).rejects.toBeInstanceOf( expect(() => resolveImageWallpaperUrl("/etc/passwd")).toThrow(BackgroundLoadError);
BackgroundLoadError,
);
}); });
it("rejects traversal attempts", async () => { it("rejects traversal attempts", () => {
await expect(resolveImageWallpaperUrl("/wallpapers/../etc/passwd")).rejects.toBeInstanceOf( expect(() => resolveImageWallpaperUrl("/wallpapers/../etc/passwd")).toThrow(
UnsafeAssetPathError, UnsafeAssetPathError,
); );
}); });
it("rejects percent-encoded traversal", async () => { it("rejects percent-encoded traversal", () => {
await expect(resolveImageWallpaperUrl("/wallpapers/%2e%2e/app.asar")).rejects.toBeInstanceOf( expect(() => resolveImageWallpaperUrl("/wallpapers/%2e%2e/app.asar")).toThrow(
UnsafeAssetPathError, UnsafeAssetPathError,
); );
}); });
it("resolves via electronAPI when not http", async () => { it("resolves via electronAPI.assetBaseUrl when not http", () => {
vi.stubGlobal("window", { vi.stubGlobal("window", {
...globalThis.window, ...globalThis.window,
location: { protocol: "file:" }, location: { protocol: "file:" },
electronAPI: { electronAPI: { assetBaseUrl: "file:///opt/app/public/" },
getAssetBasePath: vi.fn().mockResolvedValue("file:///opt/app/public/"),
},
}); });
expect(await resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe( expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"file:///opt/app/public/wallpapers/wallpaper1.jpg", "file:///opt/app/public/wallpapers/wallpaper1.jpg",
); );
}); });
it("electronAPI branch appends trailing slash if missing", async () => { it("appends trailing slash to assetBaseUrl if missing", () => {
vi.stubGlobal("window", { vi.stubGlobal("window", {
...globalThis.window, ...globalThis.window,
location: { protocol: "file:" }, location: { protocol: "file:" },
electronAPI: { electronAPI: { assetBaseUrl: "file:///opt/app/public" },
getAssetBasePath: vi.fn().mockResolvedValue("file:///opt/app/public"),
},
}); });
expect(await resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe( expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"file:///opt/app/public/wallpapers/wallpaper1.jpg", "file:///opt/app/public/wallpapers/wallpaper1.jpg",
); );
}); });
it("falls back to leading-slash relative when electronAPI returns null", async () => { it("throws loudly when assetBaseUrl is empty (no silent fallback)", () => {
vi.stubGlobal("window", { vi.stubGlobal("window", {
...globalThis.window, ...globalThis.window,
location: { protocol: "file:" }, location: { protocol: "file:" },
electronAPI: { electronAPI: { assetBaseUrl: "" },
getAssetBasePath: vi.fn().mockResolvedValue(null),
},
}); });
expect(await resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe( expect(() => resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toThrow(
"/wallpapers/wallpaper1.jpg", AssetBaseUnavailableError,
); );
}); });
}); });
+1 -1
View File
@@ -37,7 +37,7 @@ export function classifyWallpaper(value: string): WallpaperClassification {
const ALLOWED_IMAGE_PREFIX = "/wallpapers/"; const ALLOWED_IMAGE_PREFIX = "/wallpapers/";
export async function resolveImageWallpaperUrl(imagePath: string): Promise<string> { export function resolveImageWallpaperUrl(imagePath: string): string {
if ( if (
imagePath.startsWith("http://") || imagePath.startsWith("http://") ||
imagePath.startsWith("https://") || imagePath.startsWith("https://") ||
+1 -1
View File
@@ -55,7 +55,7 @@ interface Window {
message?: string; message?: string;
error?: string; error?: string;
}>; }>;
getAssetBasePath: () => Promise<string | null>; assetBaseUrl: string;
setRecordingState: (recording: boolean) => Promise<void>; setRecordingState: (recording: boolean) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{ getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean; success: boolean;