From 702b7330744f1a4f9b66eae95a2d597841381720 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Fri, 24 Apr 2026 18:33:03 -0500 Subject: [PATCH] resolve asset base path synchronously from preload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, /public unpackaged) and emits assetBaseUrl synchronously. - get-asset-base-path IPC handler + main-process branching deleted. - getAssetPath() is now sync. Returns string, not Promise. 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. --- electron/electron-env.d.ts | 2 +- electron/ipc/handlers.ts | 18 +---- electron/preload.ts | 18 ++++- src/components/video-editor/SettingsPanel.tsx | 19 +---- src/components/video-editor/VideoPlayback.tsx | 34 +++------ src/lib/assetPath.ts | 44 +++++------ src/lib/exporter/frameRenderer.ts | 2 +- src/lib/wallpaper.test.ts | 74 ++++++++----------- src/lib/wallpaper.ts | 2 +- src/vite-env.d.ts | 2 +- 10 files changed, 83 insertions(+), 132 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index e20cf7f..dff8029 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -37,7 +37,7 @@ interface Window { status: string; error?: string; }>; - getAssetBasePath: () => Promise; + assetBaseUrl: string; storeRecordedVideo: ( videoData: ArrayBuffer, fileName: string, diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 6aec971..2b72241 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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 /public/wallpapers. - // Single convention: "/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, diff --git a/electron/preload.ts b/electron/preload.ts index 6aa066f..ec5181c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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 /public/, +// and __dirname in dist-electron resolves to /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); }, diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 4ebdf33..91840bc 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -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([]); + const wallpaperPaths = useMemo(() => WALLPAPER_PATHS.map(resolveImageWallpaperUrl), []); const [customImages, setCustomImages] = useState([]); const fileInputRef = useRef(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", diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index d356012..89a0a6c 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -1108,7 +1108,17 @@ const VideoPlayback = forwardRef( videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame); }; - const [resolvedWallpaper, setResolvedWallpaper] = useState(null); + const resolvedWallpaper = useMemo(() => { + 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( 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) { diff --git a/src/lib/assetPath.ts b/src/lib/assetPath.ts index 7ba1015..edba758 100644 --- a/src/lib/assetPath.ts +++ b/src/lib/assetPath.ts @@ -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 { - 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; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index e7362eb..20a2b2d 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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"; diff --git a/src/lib/wallpaper.test.ts b/src/lib/wallpaper.test.ts index f4fe08e..f1cb80b 100644 --- a/src/lib/wallpaper.test.ts +++ b/src/lib/wallpaper.test.ts @@ -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, ); }); }); diff --git a/src/lib/wallpaper.ts b/src/lib/wallpaper.ts index 449d2e2..c86ce43 100644 --- a/src/lib/wallpaper.ts +++ b/src/lib/wallpaper.ts @@ -37,7 +37,7 @@ export function classifyWallpaper(value: string): WallpaperClassification { const ALLOWED_IMAGE_PREFIX = "/wallpapers/"; -export async function resolveImageWallpaperUrl(imagePath: string): Promise { +export function resolveImageWallpaperUrl(imagePath: string): string { if ( imagePath.startsWith("http://") || imagePath.startsWith("https://") || diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index d76ee15..bdcb537 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -55,7 +55,7 @@ interface Window { message?: string; error?: string; }>; - getAssetBasePath: () => Promise; + assetBaseUrl: string; setRecordingState: (recording: boolean) => Promise; getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean;