702b733074
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.
88 lines
2.4 KiB
TypeScript
88 lines
2.4 KiB
TypeScript
import { getAssetPath } from "@/lib/assetPath";
|
|
|
|
export const WALLPAPER_COUNT = 18;
|
|
|
|
export const WALLPAPER_PATHS: readonly string[] = Array.from(
|
|
{ length: WALLPAPER_COUNT },
|
|
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
|
|
);
|
|
|
|
export const DEFAULT_WALLPAPER = WALLPAPER_PATHS[0];
|
|
|
|
export type WallpaperClassification =
|
|
| { kind: "color"; value: string }
|
|
| { kind: "gradient"; value: string }
|
|
| { kind: "image"; path: string };
|
|
|
|
const GRADIENT_RE = /^(linear|radial|conic)-gradient\(/;
|
|
const COLOR_FUNC_RE = /^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(/;
|
|
const IMAGE_URL_RE = /^(\/|https?:\/\/|file:\/\/|data:)/;
|
|
|
|
export function classifyWallpaper(value: string): WallpaperClassification {
|
|
const trimmed = value.trim();
|
|
if (trimmed === "") {
|
|
return { kind: "color", value: "#000000" };
|
|
}
|
|
if (trimmed.startsWith("#") || COLOR_FUNC_RE.test(trimmed)) {
|
|
return { kind: "color", value: trimmed };
|
|
}
|
|
if (GRADIENT_RE.test(trimmed)) {
|
|
return { kind: "gradient", value: trimmed };
|
|
}
|
|
if (IMAGE_URL_RE.test(trimmed)) {
|
|
return { kind: "image", path: trimmed };
|
|
}
|
|
return { kind: "color", value: trimmed };
|
|
}
|
|
|
|
const ALLOWED_IMAGE_PREFIX = "/wallpapers/";
|
|
|
|
export function resolveImageWallpaperUrl(imagePath: string): string {
|
|
if (
|
|
imagePath.startsWith("http://") ||
|
|
imagePath.startsWith("https://") ||
|
|
imagePath.startsWith("file://") ||
|
|
imagePath.startsWith("data:")
|
|
) {
|
|
return imagePath;
|
|
}
|
|
const withLeadingSlash = imagePath.startsWith("/") ? imagePath : `/${imagePath}`;
|
|
if (!withLeadingSlash.startsWith(ALLOWED_IMAGE_PREFIX)) {
|
|
throw new BackgroundLoadError(
|
|
imagePath,
|
|
new Error(`Image wallpaper path must live under ${ALLOWED_IMAGE_PREFIX}`),
|
|
);
|
|
}
|
|
return getAssetPath(withLeadingSlash.slice(1));
|
|
}
|
|
|
|
export class BackgroundLoadError extends Error {
|
|
readonly url: string;
|
|
readonly cause?: unknown;
|
|
|
|
constructor(url: string, cause?: unknown) {
|
|
super(`Failed to load background image: ${displayBasename(url)}`);
|
|
this.name = "BackgroundLoadError";
|
|
this.url = url;
|
|
this.cause = cause;
|
|
}
|
|
|
|
get displayUrl(): string {
|
|
return displayBasename(this.url);
|
|
}
|
|
}
|
|
|
|
function displayBasename(url: string): string {
|
|
if (url.startsWith("data:")) {
|
|
return "data:…";
|
|
}
|
|
try {
|
|
const parsed = new URL(url);
|
|
const last = parsed.pathname.split("/").filter(Boolean).pop();
|
|
return last ? decodeURIComponent(last) : url;
|
|
} catch {
|
|
const last = url.split("/").filter(Boolean).pop();
|
|
return last ?? url;
|
|
}
|
|
}
|