diff --git a/electron-builder.json5 b/electron-builder.json5 index 18498df..ca053ef 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -20,10 +20,12 @@ "!CONTRIBUTING.md", "!LICENSE" ], + // Asset layout contract: "wallpapers/" under resourcesPath must align with + // assetBaseDir in electron/preload.ts (packaged branch). "extraResources": [ { "from": "public/wallpapers", - "to": "assets/wallpapers" + "to": "wallpapers" } ], 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 261d93f..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,21 +801,6 @@ export function registerIpcHandlers( } }); - // Return base path for assets so renderer can resolve file:// paths in production - ipcMain.handle("get-asset-base-path", () => { - try { - if (app.isPackaged) { - const assetPath = path.join(process.resourcesPath, "assets"); - return pathToFileURL(`${assetPath}${path.sep}`).toString(); - } - const assetPath = path.join(app.getAppPath(), "public", "assets"); - return pathToFileURL(`${assetPath}${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/nix/package.nix b/nix/package.nix index 195043f..13a8658 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -68,10 +68,10 @@ buildNpmPackage { cp -r node_modules "$out/lib/openscreen/" # Asset resolution: when app.isPackaged is false, the main process resolves - # assets at /public/assets/. Mirror the electron-builder - # extraResources layout so wallpapers load correctly. - mkdir -p "$out/lib/openscreen/public/assets" - cp -r public/wallpapers "$out/lib/openscreen/public/assets/wallpapers" + # assets at /public/. Place wallpapers at that root to match the + # packaged layout (electron-builder extraResources -> resources/wallpapers). + mkdir -p "$out/lib/openscreen/public" + cp -r public/wallpapers "$out/lib/openscreen/public/wallpapers" # Wrap system electron with the app directory mkdir -p "$out/bin" diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 4fb4193..78d6bb4 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, @@ -34,11 +34,11 @@ import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useScopedT } from "@/contexts/I18nContext"; -import { getAssetPath } from "@/lib/assetPath"; import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter"; import { cn } from "@/lib/utils"; +import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper"; import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; import { getTestId } from "@/utils/getTestId"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; @@ -123,11 +123,6 @@ function CustomSpeedInput({ ); } -const WALLPAPER_COUNT = 18; -const WALLPAPER_RELATIVE = Array.from( - { length: WALLPAPER_COUNT }, - (_, i) => `wallpapers/wallpaper${i + 1}.jpg`, -); const GRADIENTS = [ "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", @@ -326,24 +321,12 @@ export function SettingsPanel({ onWebcamSizePresetCommit, }: SettingsPanelProps) { const t = useScopedT("settings"); - const [wallpaperPaths, setWallpaperPaths] = useState([]); + // Resolved URLs are for DOM rendering only (backgroundImage). The canonical + // `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted + // on click — never the machine-specific file:// URL. + const wallpaperPreviewUrls = 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_RELATIVE.map((p) => getAssetPath(p))); - if (mounted) setWallpaperPaths(resolved); - } catch (_err) { - if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map((p) => `/${p}`)); - } - })(); - return () => { - mounted = false; - }; - }, []); const colorPalette = [ "#FF0000", "#FFD700", @@ -526,7 +509,7 @@ export function SettingsPanel({ setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); // If the removed image was selected, clear selection if (selected === imageUrl) { - onWallpaperChange(wallpaperPaths[0] || WALLPAPER_RELATIVE[0]); + onWallpaperChange(WALLPAPER_PATHS[0]); } }; @@ -1146,26 +1129,12 @@ export function SettingsPanel({ ); })} - {(wallpaperPaths.length > 0 - ? wallpaperPaths - : WALLPAPER_RELATIVE.map((p) => `/${p}`) - ).map((path) => { - const isSelected = (() => { - if (!selected) return false; - if (selected === path) return true; - try { - const clean = (s: string) => - s.replace(/^file:\/\//, "").replace(/^\//, ""); - if (clean(selected).endsWith(clean(path))) return true; - if (clean(path).endsWith(clean(selected))) return true; - } catch { - // Best-effort comparison; fallback to strict match. - } - return false; - })(); + {WALLPAPER_PATHS.map((canonicalPath, i) => { + const previewUrl = wallpaperPreviewUrls[i] ?? canonicalPath; + const isSelected = selected === canonicalPath; return (
onWallpaperChange(path)} + onClick={() => onWallpaperChange(canonicalPath)} role="button" /> ); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 6d21d13..0a03bf1 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -32,6 +32,7 @@ import { computeFrameStepTime } from "@/lib/frameStep"; import type { ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; +import { BackgroundLoadError } from "@/lib/wallpaper"; import { getAspectRatioValue, getNativeAspectRatioValue, @@ -1566,9 +1567,15 @@ export default function VideoEditor() { } } catch (error) { console.error("Export error:", error); - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - setExportError(errorMessage); - toast.error(`Export failed: ${errorMessage}`); + if (error instanceof BackgroundLoadError) { + const message = t("errors.exportBackgroundLoadFailed", { url: error.displayUrl }); + setExportError(message); + toast.error(message); + } else { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + setExportError(errorMessage); + toast.error(t("errors.exportFailedWithError", { error: errorMessage })); + } } finally { setIsExporting(false); exporterRef.current = null; @@ -1601,6 +1608,7 @@ export default function VideoEditor() { exportQuality, handleExportSaved, cursorTelemetry, + t, ], ); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index b798641..89a0a6c 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -18,7 +18,6 @@ import { useRef, useState, } from "react"; -import { getAssetPath } from "@/lib/assetPath"; import { getWebcamLayoutCssBoxShadow, type Size, @@ -26,6 +25,7 @@ import { type WebcamLayoutPreset, type WebcamSizePreset, } from "@/lib/compositeLayout"; +import { classifyWallpaper, DEFAULT_WALLPAPER, resolveImageWallpaperUrl } from "@/lib/wallpaper"; import { getCssClipPath } from "@/lib/webcamMaskShapes"; import { type AspectRatio, @@ -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,58 +1186,6 @@ const VideoPlayback = forwardRef( webcamVideo.currentTime = 0; }, [webcamVideoPath]); - useEffect(() => { - let mounted = true; - (async () => { - try { - if (!wallpaper) { - const def = await getAssetPath("wallpapers/wallpaper1.jpg"); - if (mounted) setResolvedWallpaper(def); - return; - } - - if ( - wallpaper.startsWith("#") || - wallpaper.startsWith("linear-gradient") || - wallpaper.startsWith("radial-gradient") - ) { - if (mounted) setResolvedWallpaper(wallpaper); - return; - } - - // If it's a data URL (custom uploaded image), use as-is - if (wallpaper.startsWith("data:")) { - if (mounted) setResolvedWallpaper(wallpaper); - return; - } - - // If it's an absolute web/http or file path, use as-is - if ( - wallpaper.startsWith("http") || - wallpaper.startsWith("file://") || - wallpaper.startsWith("/") - ) { - // If it's an absolute server path (starts with '/'), resolve via getAssetPath as well - if (wallpaper.startsWith("/")) { - const rel = wallpaper.replace(/^\//, ""); - const p = await getAssetPath(rel); - if (mounted) setResolvedWallpaper(p); - return; - } - if (mounted) setResolvedWallpaper(wallpaper); - return; - } - const p = await getAssetPath(wallpaper.replace(/^\//, "")); - if (mounted) setResolvedWallpaper(p); - } catch (_err) { - if (mounted) setResolvedWallpaper(wallpaper || "/wallpapers/wallpaper1.jpg"); - } - })(); - return () => { - mounted = false; - }; - }, [wallpaper]); - useEffect(() => { return () => { if (videoReadyRafRef.current) { diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 14dc240..8a17b9e 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -197,3 +197,62 @@ it("detects unsaved changes from differing snapshots", () => { expect(hasProjectUnsavedChanges("same", "same")).toBe(false); expect(hasProjectUnsavedChanges("current", "baseline")).toBe(true); }); + +describe("wallpaper legacy normalization", () => { + it("rewrites pre-fix packaged paths (resources/assets/wallpapers/…)", () => { + const normalized = normalizeProjectEditor({ + wallpaper: "file:///opt/Openscreen/resources/assets/wallpapers/wallpaper5.jpg", + }); + expect(normalized.wallpaper).toBe("/wallpapers/wallpaper5.jpg"); + }); + + it("rewrites new packaged layout (resources/wallpapers/…)", () => { + const normalized = normalizeProjectEditor({ + wallpaper: "file:///opt/Openscreen/resources/wallpapers/wallpaper3.jpg", + }); + expect(normalized.wallpaper).toBe("/wallpapers/wallpaper3.jpg"); + }); + + it("rewrites unpackaged dev layout (public/wallpapers/…)", () => { + const normalized = normalizeProjectEditor({ + wallpaper: "file:///home/user/project/public/wallpapers/wallpaper1.jpg", + }); + expect(normalized.wallpaper).toBe("/wallpapers/wallpaper1.jpg"); + }); + + it("rewrites Windows-style file URLs with drive letter", () => { + const normalized = normalizeProjectEditor({ + wallpaper: "file:///C:/Users/me/openscreen/resources/wallpapers/wallpaper2.jpg", + }); + expect(normalized.wallpaper).toBe("/wallpapers/wallpaper2.jpg"); + }); + + it("leaves canonical relative paths untouched", () => { + const normalized = normalizeProjectEditor({ wallpaper: "/wallpapers/wallpaper2.jpg" }); + expect(normalized.wallpaper).toBe("/wallpapers/wallpaper2.jpg"); + }); + + it("leaves data URIs untouched", () => { + const dataUri = "data:image/png;base64,AAA"; + expect(normalizeProjectEditor({ wallpaper: dataUri }).wallpaper).toBe(dataUri); + }); + + it("leaves colors and gradients untouched", () => { + expect(normalizeProjectEditor({ wallpaper: "#1a1a2e" }).wallpaper).toBe("#1a1a2e"); + expect( + normalizeProjectEditor({ wallpaper: "linear-gradient(90deg, red, blue)" }).wallpaper, + ).toBe("linear-gradient(90deg, red, blue)"); + }); + + it("does NOT rewrite user files outside the known install layout", () => { + const userPath = "file:///home/user/Pictures/wallpapers/wallpaper1.jpg"; + expect(normalizeProjectEditor({ wallpaper: userPath }).wallpaper).toBe(userPath); + }); + + it("falls back to default for bundled paths outside WALLPAPER_PATHS", () => { + const normalized = normalizeProjectEditor({ + wallpaper: "file:///opt/Openscreen/resources/wallpapers/wallpaper99.jpg", + }); + expect(normalized.wallpaper).toBe("/wallpapers/wallpaper1.jpg"); + }); +}); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index c085e0d..7259c1e 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -2,6 +2,7 @@ import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; import type { ProjectMedia } from "@/lib/recordingSession"; import { normalizeProjectMedia } from "@/lib/recordingSession"; +import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper"; import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; import { type AnnotationRegion, @@ -37,13 +38,23 @@ import { type ZoomRegion, } from "./types"; -const WALLPAPER_COUNT = 18; const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const); -export const WALLPAPER_PATHS = Array.from( - { length: WALLPAPER_COUNT }, - (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`, -); +// Pre-fix projects could persist resolved file:// URLs (machine-specific) for +// bundled wallpapers. Rewrite only paths that match a known install layout +// (resources/[assets/]wallpapers for packaged, public/wallpapers for dev) so +// a legitimate user file that happens to live in a folder named "wallpapers" +// elsewhere is never silently replaced. +const LEGACY_FILE_WALLPAPER_RE = + /^file:\/\/.*?\/(?:resources\/(?:assets\/)?|public\/)wallpapers\/(wallpaper\d+\.jpg)$/i; +const CANONICAL_WALLPAPERS = new Set(WALLPAPER_PATHS); + +function normalizeWallpaperValue(value: string): string { + const match = LEGACY_FILE_WALLPAPER_RE.exec(value); + if (!match) return value; + const canonical = `/wallpapers/${match[1]}`; + return CANONICAL_WALLPAPERS.has(canonical) ? canonical : DEFAULT_WALLPAPER; +} export const PROJECT_VERSION = 2; @@ -425,7 +436,10 @@ export function normalizeProjectEditor(editor: Partial): Pro const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY); return { - wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0], + wallpaper: + typeof editor.wallpaper === "string" + ? normalizeWallpaperValue(editor.wallpaper) + : DEFAULT_WALLPAPER, shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0, showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false, motionBlurAmount: isFiniteNumber(editor.motionBlurAmount) diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index cc19222..bd410da 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -17,6 +17,7 @@ import { DEFAULT_WEBCAM_POSITION, DEFAULT_WEBCAM_SIZE_PRESET, } from "@/components/video-editor/types"; +import { DEFAULT_WALLPAPER } from "@/lib/wallpaper"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; // Undoable state — selection IDs are intentionally excluded (undoing a @@ -46,7 +47,7 @@ export const INITIAL_EDITOR_STATE: EditorState = { speedRegions: [], annotationRegions: [], cropRegion: DEFAULT_CROP_REGION, - wallpaper: "/wallpapers/wallpaper1.jpg", + wallpaper: DEFAULT_WALLPAPER, shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index a171b16..254272d 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -14,6 +14,7 @@ "failedToSaveVideo": "Failed to save video", "exportFailed": "Export failed", "exportFailedWithError": "Export failed: {{error}}", + "exportBackgroundLoadFailed": "Export failed: could not load background image ({{url}})", "failedToSaveExport": "Failed to save export", "failedToSaveExportedVideo": "Failed to save exported video", "failedToRevealInFolder": "Error revealing in folder: {{error}}" diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 7956b75..c71368a 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -8,6 +8,7 @@ "failedToSaveVideo": "Error al guardar el video", "exportFailed": "La exportación falló", "exportFailedWithError": "La exportación falló: {{error}}", + "exportBackgroundLoadFailed": "La exportación falló: no se pudo cargar la imagen de fondo ({{url}})", "failedToSaveExport": "Error al guardar la exportación", "failedToSaveExportedVideo": "Error al guardar el video exportado", "failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}" diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 03596bd..ae48fdf 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -14,6 +14,7 @@ "failedToSaveVideo": "Échec de l'enregistrement de la vidéo", "exportFailed": "L'export a échoué", "exportFailedWithError": "L'export a échoué : {{error}}", + "exportBackgroundLoadFailed": "L'export a échoué : impossible de charger l'image d'arrière-plan ({{url}})", "failedToSaveExport": "Échec de l'enregistrement de l'export", "failedToSaveExportedVideo": "Échec de l'enregistrement de la vidéo exportée", "failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}" diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index 4db7d1f..eed0261 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -14,6 +14,7 @@ "failedToSaveVideo": "비디오 저장에 실패했습니다", "exportFailed": "내보내기에 실패했습니다", "exportFailedWithError": "내보내기 실패: {{error}}", + "exportBackgroundLoadFailed": "내보내기 실패: 배경 이미지를 불러올 수 없습니다 ({{url}})", "failedToSaveExport": "내보낸 파일 저장에 실패했습니다", "failedToSaveExportedVideo": "내보낸 비디오 저장에 실패했습니다", "failedToRevealInFolder": "폴더에서 파일 표시 오류: {{error}}" diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index dfa4cb1..c34d64b 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -8,6 +8,7 @@ "failedToSaveVideo": "Video kaydedilemedi", "exportFailed": "Dışa aktarım başarısız oldu", "exportFailedWithError": "Dışa aktarım başarısız: {{error}}", + "exportBackgroundLoadFailed": "Dışa aktarım başarısız: arka plan görüntüsü yüklenemedi ({{url}})", "failedToSaveExport": "Dışa aktarım kaydedilemedi", "failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi", "failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}" diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 1980354..f2eba2e 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -14,6 +14,7 @@ "failedToSaveVideo": "保存视频失败", "exportFailed": "导出失败", "exportFailedWithError": "导出失败:{{error}}", + "exportBackgroundLoadFailed": "导出失败:无法加载背景图片({{url}})", "failedToSaveExport": "保存导出文件失败", "failedToSaveExportedVideo": "保存导出的视频失败", "failedToRevealInFolder": "在文件夹中显示时出错:{{error}}" diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 73a3f4e..ee502fb 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -14,6 +14,7 @@ "failedToSaveVideo": "儲存影片失敗", "exportFailed": "匯出失敗", "exportFailedWithError": "匯出失敗:{{error}}", + "exportBackgroundLoadFailed": "匯出失敗:無法載入背景圖片({{url}})", "failedToSaveExport": "儲存匯出檔案失敗", "failedToSaveExportedVideo": "儲存匯出的影片失敗", "failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}" diff --git a/src/lib/assetPath.ts b/src/lib/assetPath.ts index 8188de5..edba758 100644 --- a/src/lib/assetPath.ts +++ b/src/lib/assetPath.ts @@ -1,9 +1,29 @@ +export class UnsafeAssetPathError extends Error { + constructor(segment: string) { + super(`Unsafe asset path segment: ${segment}`); + this.name = "UnsafeAssetPathError"; + } +} + +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(/^\/+/, "") .split("/") .filter(Boolean) - .map((part) => encodeURIComponent(part)) + .map((part) => { + const decoded = decodeURIComponent(part); + if (decoded === "." || decoded === "..") { + throw new UnsafeAssetPathError(decoded); + } + return encodeURIComponent(decoded); + }) .join("/"); } @@ -11,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 running in a dev server (http/https), prefer the web-served path - 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 { - // ignore and use fallback + if (typeof window === "undefined") { + return `/${encoded}`; } - // Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...' - 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 9b1cf6d..20a2b2d 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -45,6 +45,7 @@ import { type Size, type StyledRenderRect, } from "@/lib/compositeLayout"; +import { BackgroundLoadError, classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper"; import { drawCanvasClipPath } from "@/lib/webcamMaskShapes"; import { renderAnnotations } from "./annotationRenderer"; import { @@ -231,123 +232,93 @@ export class FrameRenderer { private async setupBackground(): Promise { const wallpaper = this.config.wallpaper; - // Create background canvas for separate rendering (not affected by zoom) const bgCanvas = document.createElement("canvas"); bgCanvas.width = this.config.width; bgCanvas.height = this.config.height; const bgCtx = bgCanvas.getContext("2d")!; - try { - // Render background based on type - if ( - wallpaper.startsWith("file://") || - wallpaper.startsWith("data:") || - wallpaper.startsWith("/") || - wallpaper.startsWith("http") - ) { - // Image background - const img = new Image(); - // Don't set crossOrigin for same-origin images to avoid CORS taint - // Only set it for cross-origin URLs - let imageUrl: string; - if (wallpaper.startsWith("http")) { - imageUrl = wallpaper; - if (!imageUrl.startsWith(window.location.origin)) { - img.crossOrigin = "anonymous"; - } - } else if (wallpaper.startsWith("file://") || wallpaper.startsWith("data:")) { - imageUrl = wallpaper; - } else { - imageUrl = window.location.origin + wallpaper; - } + const classified = classifyWallpaper(wallpaper); + if (classified.kind === "color") { + bgCtx.fillStyle = classified.value; + bgCtx.fillRect(0, 0, this.config.width, this.config.height); + } else if (classified.kind === "gradient") { + const parsedGradient = parseCssGradient(classified.value); + if (!parsedGradient) { + throw new BackgroundLoadError(classified.value); + } + const gradient = + parsedGradient.type === "linear" + ? (() => { + const points = getLinearGradientPoints( + resolveLinearGradientAngle(parsedGradient.descriptor), + this.config.width, + this.config.height, + ); + return bgCtx.createLinearGradient(points.x0, points.y0, points.x1, points.y1); + })() + : (() => { + const shape = getRadialGradientShape( + parsedGradient.descriptor, + this.config.width, + this.config.height, + ); + return bgCtx.createRadialGradient( + shape.cx, + shape.cy, + 0, + shape.cx, + shape.cy, + shape.radius, + ); + })(); + + parsedGradient.stops.forEach((stop) => { + gradient.addColorStop(stop.offset, stop.color); + }); + + bgCtx.fillStyle = gradient; + bgCtx.fillRect(0, 0, this.config.width, this.config.height); + } else { + const imageUrl = resolveImageWallpaperUrl(classified.path); + const img = new Image(); + if (imageUrl.startsWith("http") && !imageUrl.startsWith(window.location.origin)) { + img.crossOrigin = "anonymous"; + } + + try { await new Promise((resolve, reject) => { img.onload = () => resolve(); - img.onerror = (err) => { - console.error("[FrameRenderer] Failed to load background image:", imageUrl, err); - reject(new Error(`Failed to load background image: ${imageUrl}`)); - }; + img.onerror = (err) => reject(err); img.src = imageUrl; }); - - // Draw the image using cover and center positioning - const imgAspect = img.width / img.height; - const canvasAspect = this.config.width / this.config.height; - - let drawWidth, drawHeight, drawX, drawY; - - if (imgAspect > canvasAspect) { - drawHeight = this.config.height; - drawWidth = drawHeight * imgAspect; - drawX = (this.config.width - drawWidth) / 2; - drawY = 0; - } else { - drawWidth = this.config.width; - drawHeight = drawWidth / imgAspect; - drawX = 0; - drawY = (this.config.height - drawHeight) / 2; - } - - bgCtx.drawImage(img, drawX, drawY, drawWidth, drawHeight); - } else if (wallpaper.startsWith("#")) { - bgCtx.fillStyle = wallpaper; - bgCtx.fillRect(0, 0, this.config.width, this.config.height); - } else if ( - wallpaper.startsWith("linear-gradient") || - wallpaper.startsWith("radial-gradient") - ) { - const parsedGradient = parseCssGradient(wallpaper); - if (parsedGradient) { - const gradient = - parsedGradient.type === "linear" - ? (() => { - const points = getLinearGradientPoints( - resolveLinearGradientAngle(parsedGradient.descriptor), - this.config.width, - this.config.height, - ); - - return bgCtx.createLinearGradient(points.x0, points.y0, points.x1, points.y1); - })() - : (() => { - const shape = getRadialGradientShape( - parsedGradient.descriptor, - this.config.width, - this.config.height, - ); - - return bgCtx.createRadialGradient( - shape.cx, - shape.cy, - 0, - shape.cx, - shape.cy, - shape.radius, - ); - })(); - - parsedGradient.stops.forEach((stop) => { - gradient.addColorStop(stop.offset, stop.color); - }); - - bgCtx.fillStyle = gradient; - bgCtx.fillRect(0, 0, this.config.width, this.config.height); - } else { - console.warn("[FrameRenderer] Could not parse gradient, using black fallback"); - bgCtx.fillStyle = "#000000"; - bgCtx.fillRect(0, 0, this.config.width, this.config.height); - } - } else { - bgCtx.fillStyle = wallpaper; - bgCtx.fillRect(0, 0, this.config.width, this.config.height); + } catch (err) { + throw new BackgroundLoadError(imageUrl, err); } - } catch (error) { - console.error("[FrameRenderer] Error setting up background, using fallback:", error); - bgCtx.fillStyle = "#000000"; - bgCtx.fillRect(0, 0, this.config.width, this.config.height); + + const imgAspect = img.width / img.height; + const canvasAspect = this.config.width / this.config.height; + + let drawWidth: number; + let drawHeight: number; + let drawX: number; + let drawY: number; + + if (imgAspect > canvasAspect) { + drawHeight = this.config.height; + drawWidth = drawHeight * imgAspect; + drawX = (this.config.width - drawWidth) / 2; + drawY = 0; + } else { + drawWidth = this.config.width; + drawHeight = drawWidth / imgAspect; + drawX = 0; + drawY = (this.config.height - drawHeight) / 2; + } + + bgCtx.drawImage(img, drawX, drawY, drawWidth, drawHeight); } - // Store the background canvas for compositing this.backgroundSprite = bgCanvas; } diff --git a/src/lib/exporter/gifExporter.browser.test.ts b/src/lib/exporter/gifExporter.browser.test.ts index db9b144..1d96076 100644 --- a/src/lib/exporter/gifExporter.browser.test.ts +++ b/src/lib/exporter/gifExporter.browser.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; +import { BackgroundLoadError } from "../wallpaper"; import { GifExporter } from "./gifExporter"; import type { ExportProgress } from "./types"; @@ -40,4 +41,48 @@ describe("GifExporter (real browser)", () => { expect(finalizing.length).toBeGreaterThan(0); expect(finalizing.at(-1)!.percentage).toBe(100); }); + + it("exports successfully with an image wallpaper (served by Vite dev server)", async () => { + const exporter = new GifExporter({ + videoUrl: sampleVideoUrl, + width: 320, + height: 180, + frameRate: 15, + loop: true, + sizePreset: "medium", + wallpaper: "/wallpapers/wallpaper1.jpg", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + showBlur: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + }); + + const result = await exporter.export(); + expect(result.success, result.error).toBe(true); + expect(result.blob!.size).toBeGreaterThan(1024); + }); + + it("throws BackgroundLoadError when wallpaper fails to load (no silent black fallback)", async () => { + const exporter = new GifExporter({ + videoUrl: sampleVideoUrl, + width: 320, + height: 180, + frameRate: 15, + loop: true, + sizePreset: "medium", + wallpaper: "/wallpapers/does-not-exist.jpg", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + showBlur: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + }); + + const rejection = exporter.export(); + await expect(rejection).rejects.toBeInstanceOf(BackgroundLoadError); + await expect(rejection).rejects.toMatchObject({ + url: expect.stringContaining("does-not-exist"), + }); + }); }); diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 46ac6a0..f073d6b 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -8,6 +8,7 @@ import type { WebcamSizePreset, ZoomRegion, } from "@/components/video-editor/types"; +import { BackgroundLoadError } from "@/lib/wallpaper"; import { getPlatform } from "@/utils/platformUtils"; import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue"; import { FrameRenderer } from "./frameRenderer"; @@ -326,6 +327,9 @@ export class GifExporter { return { success: true, blob }; } catch (error) { + if (error instanceof BackgroundLoadError) { + throw error; + } console.error("GIF Export error:", error); return { success: false, diff --git a/src/lib/exporter/videoExporter.browser.test.ts b/src/lib/exporter/videoExporter.browser.test.ts index ec2b0f6..cca896f 100644 --- a/src/lib/exporter/videoExporter.browser.test.ts +++ b/src/lib/exporter/videoExporter.browser.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; +import { BackgroundLoadError } from "../wallpaper"; import type { ExportProgress } from "./types"; import { VideoExporter } from "./videoExporter"; @@ -40,4 +41,46 @@ describe("VideoExporter (real browser)", () => { expect(finalizing.length).toBeGreaterThan(0); expect(finalizing.at(-1)!.percentage).toBe(100); }); + + it("exports successfully with an image wallpaper (served by Vite dev server)", async () => { + const exporter = new VideoExporter({ + videoUrl: sampleVideoUrl, + width: 320, + height: 180, + frameRate: 15, + bitrate: 1_000_000, + wallpaper: "/wallpapers/wallpaper1.jpg", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + showBlur: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + }); + + const result = await exporter.export(); + expect(result.success, result.error).toBe(true); + expect(result.blob!.size).toBeGreaterThan(1024); + }); + + it("throws BackgroundLoadError when wallpaper fails to load (no silent black fallback)", async () => { + const exporter = new VideoExporter({ + videoUrl: sampleVideoUrl, + width: 320, + height: 180, + frameRate: 15, + bitrate: 1_000_000, + wallpaper: "/wallpapers/does-not-exist.jpg", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + showBlur: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + }); + + const rejection = exporter.export(); + await expect(rejection).rejects.toBeInstanceOf(BackgroundLoadError); + await expect(rejection).rejects.toMatchObject({ + url: expect.stringContaining("does-not-exist"), + }); + }); }); diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 44c1b88..cc8b7cf 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -7,6 +7,7 @@ import type { WebcamSizePreset, ZoomRegion, } from "@/components/video-editor/types"; +import { BackgroundLoadError } from "@/lib/wallpaper"; import { getPlatform } from "@/utils/platformUtils"; import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue"; import { AudioProcessor } from "./audioEncoder"; @@ -82,6 +83,10 @@ export class VideoExporter { return { success: false, error: "Export cancelled" }; } + if (normalizedError instanceof BackgroundLoadError) { + throw normalizedError; + } + if (encoderPreferences.length > 1) { console.warn( `[VideoExporter] ${encoderPreference} export attempt failed:`, diff --git a/src/lib/wallpaper.test.ts b/src/lib/wallpaper.test.ts new file mode 100644 index 0000000..02596aa --- /dev/null +++ b/src/lib/wallpaper.test.ts @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AssetBaseUnavailableError, UnsafeAssetPathError } from "./assetPath"; +import { + BackgroundLoadError, + classifyWallpaper, + DEFAULT_WALLPAPER, + resolveImageWallpaperUrl, + UnsafeImagePrefixError, + WALLPAPER_COUNT, + WALLPAPER_PATHS, +} from "./wallpaper"; + +describe("WALLPAPER_PATHS", () => { + it("contains WALLPAPER_COUNT entries", () => { + expect(WALLPAPER_PATHS).toHaveLength(WALLPAPER_COUNT); + }); + + it("DEFAULT_WALLPAPER is WALLPAPER_PATHS[0]", () => { + expect(DEFAULT_WALLPAPER).toBe(WALLPAPER_PATHS[0]); + }); +}); + +describe("classifyWallpaper", () => { + it("hex color", () => { + expect(classifyWallpaper("#1a1a2e")).toEqual({ kind: "color", value: "#1a1a2e" }); + }); + + it("rgb() color", () => { + expect(classifyWallpaper("rgb(1, 2, 3)")).toEqual({ kind: "color", value: "rgb(1, 2, 3)" }); + }); + + it("rgba() color", () => { + expect(classifyWallpaper("rgba(1, 2, 3, 0.5)")).toEqual({ + kind: "color", + value: "rgba(1, 2, 3, 0.5)", + }); + }); + + it("hsl() color", () => { + expect(classifyWallpaper("hsl(180, 50%, 50%)")).toEqual({ + kind: "color", + value: "hsl(180, 50%, 50%)", + }); + }); + + it("oklch() color", () => { + expect(classifyWallpaper("oklch(50% 0.1 180)")).toEqual({ + kind: "color", + value: "oklch(50% 0.1 180)", + }); + }); + + it("linear gradient", () => { + const v = "linear-gradient(90deg, red, blue)"; + expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v }); + }); + + it("radial gradient", () => { + const v = "radial-gradient(circle, red, blue)"; + expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v }); + }); + + it("conic gradient", () => { + const v = "conic-gradient(red, blue)"; + expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v }); + }); + + it("repeating-linear gradient", () => { + const v = "repeating-linear-gradient(45deg, red 0 10px, blue 10px 20px)"; + expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v }); + }); + + it("repeating-radial gradient", () => { + const v = "repeating-radial-gradient(circle, red, blue 20px)"; + expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v }); + }); + + it("leading-slash image path", () => { + expect(classifyWallpaper("/wallpapers/wallpaper1.jpg")).toEqual({ + kind: "image", + path: "/wallpapers/wallpaper1.jpg", + }); + }); + + it("http URL as image", () => { + expect(classifyWallpaper("https://example.com/bg.jpg")).toEqual({ + kind: "image", + path: "https://example.com/bg.jpg", + }); + }); + + it("file:// URL as image", () => { + expect(classifyWallpaper("file:///tmp/bg.jpg")).toEqual({ + kind: "image", + path: "file:///tmp/bg.jpg", + }); + }); + + it("data URI as image", () => { + expect(classifyWallpaper("data:image/png;base64,AAA")).toEqual({ + kind: "image", + path: "data:image/png;base64,AAA", + }); + }); + + it("named color falls back to color", () => { + expect(classifyWallpaper("red")).toEqual({ kind: "color", value: "red" }); + }); + + it("empty string falls back to black", () => { + expect(classifyWallpaper("")).toEqual({ kind: "color", value: "#000000" }); + }); + + it("trims whitespace", () => { + expect(classifyWallpaper(" #abcdef ")).toEqual({ kind: "color", value: "#abcdef" }); + }); + + it("DEFAULT_WALLPAPER classifies as image", () => { + expect(classifyWallpaper(DEFAULT_WALLPAPER)).toEqual({ + kind: "image", + path: DEFAULT_WALLPAPER, + }); + }); +}); + +describe("resolveImageWallpaperUrl", () => { + beforeEach(() => { + vi.stubGlobal("window", { + ...globalThis.window, + location: { protocol: "http:" }, + electronAPI: undefined, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("passes through http URL", () => { + expect(resolveImageWallpaperUrl("http://example.com/bg.jpg")).toBe("http://example.com/bg.jpg"); + }); + + it("passes through https URL", () => { + expect(resolveImageWallpaperUrl("https://example.com/bg.jpg")).toBe( + "https://example.com/bg.jpg", + ); + }); + + it("passes through file:// URL", () => { + expect(resolveImageWallpaperUrl("file:///tmp/bg.jpg")).toBe("file:///tmp/bg.jpg"); + }); + + it("passes through data URI", () => { + const uri = "data:image/png;base64,AAAA"; + expect(resolveImageWallpaperUrl(uri)).toBe(uri); + }); + + it("resolves leading-slash wallpaper path via http fallback", () => { + expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe( + "/wallpapers/wallpaper1.jpg", + ); + }); + + it("resolves bare relative wallpaper path", () => { + expect(resolveImageWallpaperUrl("wallpapers/wallpaper1.jpg")).toBe( + "/wallpapers/wallpaper1.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/ with UnsafeImagePrefixError as cause", () => { + try { + resolveImageWallpaperUrl("/etc/passwd"); + expect.fail("should have thrown"); + } catch (err) { + if (!(err instanceof BackgroundLoadError)) throw err; + expect(err.cause).toBeInstanceOf(UnsafeImagePrefixError); + } + }); + + it("wraps traversal attempts in BackgroundLoadError (preserves UnsafeAssetPathError as cause)", () => { + try { + resolveImageWallpaperUrl("/wallpapers/../etc/passwd"); + expect.fail("should have thrown"); + } catch (err) { + if (!(err instanceof BackgroundLoadError)) throw err; + expect(err.cause).toBeInstanceOf(UnsafeAssetPathError); + } + }); + + it("wraps percent-encoded traversal in BackgroundLoadError", () => { + try { + resolveImageWallpaperUrl("/wallpapers/%2e%2e/app.asar"); + expect.fail("should have thrown"); + } catch (err) { + if (!(err instanceof BackgroundLoadError)) throw err; + expect(err.cause).toBeInstanceOf(UnsafeAssetPathError); + } + }); + + it("resolves via electronAPI.assetBaseUrl when not http", () => { + vi.stubGlobal("window", { + ...globalThis.window, + location: { protocol: "file:" }, + electronAPI: { assetBaseUrl: "file:///opt/app/public/" }, + }); + expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe( + "file:///opt/app/public/wallpapers/wallpaper1.jpg", + ); + }); + + it("appends trailing slash to assetBaseUrl if missing", () => { + vi.stubGlobal("window", { + ...globalThis.window, + location: { protocol: "file:" }, + electronAPI: { assetBaseUrl: "file:///opt/app/public" }, + }); + expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe( + "file:///opt/app/public/wallpapers/wallpaper1.jpg", + ); + }); + + it("wraps AssetBaseUnavailableError in BackgroundLoadError when assetBaseUrl is empty", () => { + vi.stubGlobal("window", { + ...globalThis.window, + location: { protocol: "file:" }, + electronAPI: { assetBaseUrl: "" }, + }); + try { + resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg"); + expect.fail("should have thrown"); + } catch (err) { + if (!(err instanceof BackgroundLoadError)) throw err; + expect(err.cause).toBeInstanceOf(AssetBaseUnavailableError); + } + }); +}); + +describe("BackgroundLoadError", () => { + it("carries the failing URL and is instanceof Error", () => { + const err = new BackgroundLoadError("/home/user/secret/wallpaper.jpg"); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(BackgroundLoadError); + expect(err.url).toBe("/home/user/secret/wallpaper.jpg"); + expect(err.name).toBe("BackgroundLoadError"); + }); + + it("displayUrl hides parent directories to avoid leaking PII", () => { + const err = new BackgroundLoadError("file:///home/enrique/projects/openscreen/wallpaper1.jpg"); + expect(err.displayUrl).toBe("wallpaper1.jpg"); + }); + + it("displayUrl abbreviates data URIs", () => { + const err = new BackgroundLoadError("data:image/png;base64,AAA"); + expect(err.displayUrl).toBe("data:…"); + }); + + it("displayUrl returns sentinel for empty-basename URLs", () => { + const err = new BackgroundLoadError("file:///"); + expect(err.displayUrl).toBe("(unknown)"); + }); + + it("displayUrl returns sentinel for unparseable bare slash", () => { + const err = new BackgroundLoadError("/"); + expect(err.displayUrl).toBe("(unknown)"); + }); + + it("preserves cause when provided", () => { + const cause = new Error("inner"); + const err = new BackgroundLoadError("file:///missing.jpg", cause); + expect(err.cause).toBe(cause); + }); +}); diff --git a/src/lib/wallpaper.ts b/src/lib/wallpaper.ts new file mode 100644 index 0000000..6974a04 --- /dev/null +++ b/src/lib/wallpaper.ts @@ -0,0 +1,96 @@ +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)"; + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index d76ee15..b7a0735 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,126 +1,2 @@ /// /// - -interface ProcessedDesktopSource { - id: string; - name: string; - display_id: string; - thumbnail: string | null; - appIcon: string | null; -} - -interface CursorTelemetryPoint { - timeMs: number; - cx: number; - cy: number; -} - -interface Window { - electronAPI: { - getSources: (opts: Electron.SourcesOptions) => Promise; - switchToEditor: () => Promise; - switchToHud: () => Promise; - startNewRecording: () => Promise<{ success: boolean; error?: string }>; - openSourceSelector: () => Promise; - selectSource: (source: ProcessedDesktopSource) => Promise; - getSelectedSource: () => Promise; - requestCameraAccess: () => Promise<{ - success: boolean; - granted: boolean; - status: string; - error?: string; - }>; - storeRecordedVideo: ( - videoData: ArrayBuffer, - fileName: string, - ) => Promise<{ - success: boolean; - path?: string; - session?: import("./lib/recordingSession").RecordingSession; - message?: string; - error?: string; - }>; - storeRecordedSession: ( - payload: import("./lib/recordingSession").StoreRecordedSessionInput, - ) => Promise<{ - success: boolean; - path?: string; - session?: import("./lib/recordingSession").RecordingSession; - message?: string; - error?: string; - }>; - getRecordedVideoPath: () => Promise<{ - success: boolean; - path?: string; - message?: string; - error?: string; - }>; - getAssetBasePath: () => Promise; - setRecordingState: (recording: boolean) => Promise; - getCursorTelemetry: (videoPath?: string) => Promise<{ - success: boolean; - samples: CursorTelemetryPoint[]; - message?: string; - error?: string; - }>; - onStopRecordingFromTray: (callback: () => void) => () => void; - openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; - saveExportedVideo: ( - videoData: ArrayBuffer, - fileName: string, - ) => Promise<{ - success: boolean; - path?: string; - message?: string; - canceled?: boolean; - }>; - openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; - setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; - setCurrentRecordingSession: ( - session: import("./lib/recordingSession").RecordingSession | null, - ) => Promise<{ - success: boolean; - session?: import("./lib/recordingSession").RecordingSession; - }>; - getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; - getCurrentRecordingSession: () => Promise<{ - success: boolean; - session?: import("./lib/recordingSession").RecordingSession; - }>; - clearCurrentVideoPath: () => Promise<{ success: boolean }>; - saveProjectFile: ( - projectData: unknown, - suggestedName?: string, - existingProjectPath?: string, - ) => Promise<{ - success: boolean; - path?: string; - message?: string; - canceled?: boolean; - error?: string; - }>; - loadProjectFile: () => Promise<{ - success: boolean; - path?: string; - project?: unknown; - message?: string; - canceled?: boolean; - error?: string; - }>; - loadCurrentProjectFile: () => Promise<{ - success: boolean; - path?: string; - project?: unknown; - message?: string; - canceled?: boolean; - error?: string; - }>; - onMenuLoadProject: (callback: () => void) => () => void; - onMenuSaveProject: (callback: () => void) => () => void; - onMenuSaveProjectAs: (callback: () => void) => () => void; - setMicrophoneExpanded: (expanded: boolean) => void; - setHasUnsavedChanges: (hasChanges: boolean) => void; - onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void; - setLocale: (locale: string) => Promise; - }; -}