Files
openscreen/src/lib/wallpaper.ts
T
Enriquefft e06e40dbc2 clean review nits: typed prefix sentinel, instanceof narrowing, drop dead re-export
- Replace anonymous Error in resolveImageWallpaperUrl with typed
  UnsafeImagePrefixError, mirroring UnsafeAssetPathError so cause
  chains stay discriminable.
- Replace `(err as BackgroundLoadError).cause` casts in wallpaper
  tests with instanceof narrowing (no `as` per project rules).
- Remove unused `WALLPAPER_PATHS` re-export from projectPersistence;
  consumers import directly from @/lib/wallpaper (SSOT).
2026-04-24 22:34:00 -05:00

97 lines
2.7 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 = /^(repeating-)?(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 class UnsafeImagePrefixError extends Error {
constructor(prefix: string) {
super(`Image wallpaper path must live under ${prefix}`);
this.name = "UnsafeImagePrefixError";
}
}
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 UnsafeImagePrefixError(ALLOWED_IMAGE_PREFIX));
}
try {
return getAssetPath(withLeadingSlash.slice(1));
} catch (cause) {
if (cause instanceof BackgroundLoadError) throw cause;
throw new BackgroundLoadError(imagePath, cause);
}
}
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) : "(unknown)";
} catch {
const last = url.split("/").filter(Boolean).pop();
return last ?? "(unknown)";
}
}