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:
Vendored
+1
-1
@@ -37,7 +37,7 @@ interface Window {
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
getAssetBasePath: () => Promise<string | null>;
|
||||
assetBaseUrl: string;
|
||||
storeRecordedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
fileName: string,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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://") ||
|
||||
|
||||
Vendored
+1
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user