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;
error?: string;
}>;
getAssetBasePath: () => Promise<string | null>;
assetBaseUrl: string;
storeRecordedVideo: (
videoData: ArrayBuffer,
fileName: string,
+1 -17
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { fileURLToPath } from "node:url";
import {
app,
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.
* 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 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", {
assetBaseUrl,
hudOverlayHide: () => {
ipcRenderer.send("hud-overlay-hide");
},
hudOverlayClose: () => {
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) => {
return await ipcRenderer.invoke("get-sources", opts);
},
+2 -17
View File
@@ -14,7 +14,7 @@ import {
Upload,
X,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
Accordion,
@@ -321,24 +321,9 @@ export function SettingsPanel({
onWebcamSizePresetCommit,
}: SettingsPanelProps) {
const t = useScopedT("settings");
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
const wallpaperPaths = useMemo(() => WALLPAPER_PATHS.map(resolveImageWallpaperUrl), []);
const [customImages, setCustomImages] = useState<string[]>([]);
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 = [
"#FF0000",
"#FFD700",
+11 -23
View File
@@ -1108,7 +1108,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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(
() => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
[webcamLayoutPreset],
@@ -1176,28 +1186,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamVideo.currentTime = 0;
}, [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(() => {
return () => {
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 {
return relativePath
.replace(/^\/+/, "")
@@ -24,33 +31,22 @@ function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`;
}
export async function getAssetPath(relativePath: string): Promise<string> {
const encodedRelativePath = encodeRelativeAssetPath(relativePath);
export function getAssetPath(relativePath: string): string {
const encoded = encodeRelativeAssetPath(relativePath);
try {
if (typeof window !== "undefined") {
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;
}
if (typeof window === "undefined") {
return `/${encoded}`;
}
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;
+1 -1
View File
@@ -280,7 +280,7 @@ export class FrameRenderer {
bgCtx.fillStyle = gradient;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} else {
const imageUrl = await resolveImageWallpaperUrl(classified.path);
const imageUrl = resolveImageWallpaperUrl(classified.path);
const img = new Image();
if (imageUrl.startsWith("http") && !imageUrl.startsWith(window.location.origin)) {
img.crossOrigin = "anonymous";
+31 -43
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { UnsafeAssetPathError } from "./assetPath";
import { AssetBaseUnavailableError, UnsafeAssetPathError } from "./assetPath";
import {
BackgroundLoadError,
classifyWallpaper,
@@ -125,99 +125,87 @@ describe("resolveImageWallpaperUrl", () => {
vi.unstubAllGlobals();
});
it("passes through http URL", async () => {
expect(await resolveImageWallpaperUrl("http://example.com/bg.jpg")).toBe(
"http://example.com/bg.jpg",
);
it("passes through http URL", () => {
expect(resolveImageWallpaperUrl("http://example.com/bg.jpg")).toBe("http://example.com/bg.jpg");
});
it("passes through https URL", async () => {
expect(await resolveImageWallpaperUrl("https://example.com/bg.jpg")).toBe(
it("passes through https URL", () => {
expect(resolveImageWallpaperUrl("https://example.com/bg.jpg")).toBe(
"https://example.com/bg.jpg",
);
});
it("passes through file:// URL", async () => {
expect(await resolveImageWallpaperUrl("file:///tmp/bg.jpg")).toBe("file:///tmp/bg.jpg");
it("passes through file:// URL", () => {
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";
expect(await resolveImageWallpaperUrl(uri)).toBe(uri);
expect(resolveImageWallpaperUrl(uri)).toBe(uri);
});
it("resolves leading-slash wallpaper path via http fallback", async () => {
expect(await resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
it("resolves leading-slash wallpaper path via http fallback", () => {
expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"/wallpapers/wallpaper1.jpg",
);
});
it("resolves bare relative wallpaper path", async () => {
expect(await resolveImageWallpaperUrl("wallpapers/wallpaper1.jpg")).toBe(
it("resolves bare relative wallpaper path", () => {
expect(resolveImageWallpaperUrl("wallpapers/wallpaper1.jpg")).toBe(
"/wallpapers/wallpaper1.jpg",
);
});
it("encodes special characters in path segments", async () => {
expect(await resolveImageWallpaperUrl("/wallpapers/my image.jpg")).toBe(
"/wallpapers/my%20image.jpg",
);
it("encodes special characters in path segments", () => {
expect(resolveImageWallpaperUrl("/wallpapers/my image.jpg")).toBe("/wallpapers/my%20image.jpg");
});
it("rejects image paths outside /wallpapers/", async () => {
await expect(resolveImageWallpaperUrl("/etc/passwd")).rejects.toBeInstanceOf(
BackgroundLoadError,
);
it("rejects image paths outside /wallpapers/", () => {
expect(() => resolveImageWallpaperUrl("/etc/passwd")).toThrow(BackgroundLoadError);
});
it("rejects traversal attempts", async () => {
await expect(resolveImageWallpaperUrl("/wallpapers/../etc/passwd")).rejects.toBeInstanceOf(
it("rejects traversal attempts", () => {
expect(() => resolveImageWallpaperUrl("/wallpapers/../etc/passwd")).toThrow(
UnsafeAssetPathError,
);
});
it("rejects percent-encoded traversal", async () => {
await expect(resolveImageWallpaperUrl("/wallpapers/%2e%2e/app.asar")).rejects.toBeInstanceOf(
it("rejects percent-encoded traversal", () => {
expect(() => resolveImageWallpaperUrl("/wallpapers/%2e%2e/app.asar")).toThrow(
UnsafeAssetPathError,
);
});
it("resolves via electronAPI when not http", async () => {
it("resolves via electronAPI.assetBaseUrl when not http", () => {
vi.stubGlobal("window", {
...globalThis.window,
location: { protocol: "file:" },
electronAPI: {
getAssetBasePath: vi.fn().mockResolvedValue("file:///opt/app/public/"),
},
electronAPI: { assetBaseUrl: "file:///opt/app/public/" },
});
expect(await resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"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", {
...globalThis.window,
location: { protocol: "file:" },
electronAPI: {
getAssetBasePath: vi.fn().mockResolvedValue("file:///opt/app/public"),
},
electronAPI: { assetBaseUrl: "file:///opt/app/public" },
});
expect(await resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"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", {
...globalThis.window,
location: { protocol: "file:" },
electronAPI: {
getAssetBasePath: vi.fn().mockResolvedValue(null),
},
electronAPI: { assetBaseUrl: "" },
});
expect(await resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"/wallpapers/wallpaper1.jpg",
expect(() => resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toThrow(
AssetBaseUnavailableError,
);
});
});
+1 -1
View File
@@ -37,7 +37,7 @@ export function classifyWallpaper(value: string): WallpaperClassification {
const ALLOWED_IMAGE_PREFIX = "/wallpapers/";
export async function resolveImageWallpaperUrl(imagePath: string): Promise<string> {
export function resolveImageWallpaperUrl(imagePath: string): string {
if (
imagePath.startsWith("http://") ||
imagePath.startsWith("https://") ||
+1 -1
View File
@@ -55,7 +55,7 @@ interface Window {
message?: string;
error?: string;
}>;
getAssetBasePath: () => Promise<string | null>;
assetBaseUrl: string;
setRecordingState: (recording: boolean) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;