diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 1d528cdc..c1046879 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -62,6 +62,17 @@ interface Window { message?: string; error?: string; }>; + storeBackgroundImage: ( + imageData: ArrayBuffer, + fileName: string, + mimeType?: string, + ) => Promise<{ + success: boolean; + path?: string; + url?: string; + message?: string; + error?: string; + }>; getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; @@ -81,7 +92,7 @@ interface Window { openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; pickExportSavePath: ( fileName: string, - exportFolder?: string, + options?: { autoSaveToDownloads?: boolean; exportFolder?: string }, ) => Promise<{ success: boolean; path?: string; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 431cda1f..ac1fca96 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,8 +1,9 @@ +import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const nodeRequire = createRequire(import.meta.url); @@ -32,8 +33,11 @@ import { RECORDINGS_DIR } from "../main"; const PROJECT_FILE_EXTENSION = "openscreen"; const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); +const BACKGROUND_IMAGES_DIR = path.join(app.getPath("userData"), "background-images"); const RECORDING_SESSION_SUFFIX = ".session.json"; const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]); +const ALLOWED_BACKGROUND_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png"]); +const ALLOWED_BACKGROUND_IMAGE_TYPES = new Set(["image/jpeg", "image/png"]); /** * Paths explicitly approved by the user via file picker dialogs or project loads. @@ -80,6 +84,41 @@ function hasAllowedImportVideoExtension(filePath: string): boolean { return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase()); } +function resolveBackgroundImageOutputPath(fileName: string, mimeType?: string): string { + const normalizedType = mimeType?.trim().toLowerCase() ?? ""; + const extension = path.extname(fileName).toLowerCase(); + + if (normalizedType && !ALLOWED_BACKGROUND_IMAGE_TYPES.has(normalizedType)) { + throw new Error(`Unsupported background image type: ${normalizedType}`); + } + + if (!ALLOWED_BACKGROUND_IMAGE_EXTENSIONS.has(extension)) { + throw new Error(`Unsupported background image extension: ${extension || "(none)"}`); + } + + return path.join(BACKGROUND_IMAGES_DIR, `${randomUUID()}${extension}`); +} + +async function resolveAvailableDownloadPath(fileName: string): Promise { + const downloadsDir = app.getPath("downloads"); + const extension = path.extname(fileName); + const baseName = path.basename(fileName, extension); + let targetPath = path.join(downloadsDir, fileName); + let counter = 1; + + while ( + await fs + .access(targetPath) + .then(() => true) + .catch(() => false) + ) { + targetPath = path.join(downloadsDir, `${baseName} (${counter})${extension}`); + counter += 1; + } + + return targetPath; +} + async function approveReadableVideoPath( filePath?: string | null, trustedDirs?: string[], @@ -806,6 +845,30 @@ export function registerIpcHandlers( } }); + ipcMain.handle( + "store-background-image", + async (_, imageData: ArrayBuffer, fileName: string, mimeType?: string) => { + try { + const targetPath = resolveBackgroundImageOutputPath(fileName, mimeType); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, Buffer.from(imageData)); + return { + success: true, + path: targetPath, + url: pathToFileURL(targetPath).toString(), + message: "Background image stored successfully", + }; + } catch (error) { + console.error("Failed to store background image:", error); + return { + success: false, + message: "Failed to store background image", + error: String(error), + }; + } + }, + ); + ipcMain.handle("get-recorded-video-path", async () => { try { if (currentRecordingSession?.screenVideoPath) { @@ -1012,57 +1075,69 @@ export function registerIpcHandlers( * @returns Object with success status, optional file path, and error details. */ - ipcMain.handle("pick-export-save-path", async (_, fileName: string, exportFolder?: string) => { - try { - const isGif = fileName.toLowerCase().endsWith(".gif"); - const filters = isGif - ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] - : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; - - // Prefer the user's last export folder if it still exists, otherwise fall - // back to ~/Downloads. Validation must happen here because the renderer - // can't stat the filesystem. - let defaultDir = app.getPath("downloads"); - if (exportFolder) { - try { - const stats = await fs.stat(exportFolder); - if (stats.isDirectory()) { - defaultDir = exportFolder; + ipcMain.handle( + "pick-export-save-path", + async ( + _, + fileName: string, + options?: { autoSaveToDownloads?: boolean; exportFolder?: string }, + ) => { + try { + const isGif = fileName.toLowerCase().endsWith(".gif"); + const filters = isGif + ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] + : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; + + if (options?.autoSaveToDownloads) { + return { success: true, path: await resolveAvailableDownloadPath(fileName) }; + } + + // Prefer the user's last export folder if it still exists, otherwise fall + // back to ~/Downloads. Validation must happen here because the renderer + // can't stat the filesystem. + const exportFolder = options?.exportFolder; + let defaultDir = app.getPath("downloads"); + if (exportFolder) { + try { + const stats = await fs.stat(exportFolder); + if (stats.isDirectory()) { + defaultDir = exportFolder; + } + } catch (err) { + console.warn( + `Could not access remembered export folder "${exportFolder}", falling back to Downloads:`, + err, + ); } - } catch (err) { - console.warn( - `Could not access remembered export folder "${exportFolder}", falling back to Downloads:`, - err, - ); } - } - const dialogOptions = buildDialogOptions( - { - title: isGif - ? mainT("dialogs", "fileDialogs.saveGif") - : mainT("dialogs", "fileDialogs.saveVideo"), - defaultPath: path.join(defaultDir, fileName), - filters, - properties: ["createDirectory", "showOverwriteConfirmation"], - }, - getMainWindow(), - ); - const result = await dialog.showSaveDialog(dialogOptions); + const dialogOptions = buildDialogOptions( + { + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), + defaultPath: path.join(defaultDir, fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }, + getMainWindow(), + ); + const result = await dialog.showSaveDialog(dialogOptions); - if (result.canceled || !result.filePath) { - return { success: false, canceled: true, message: "Export canceled" }; - } + if (result.canceled || !result.filePath) { + return { success: false, canceled: true, message: "Export canceled" }; + } - return { success: true, path: path.normalize(result.filePath) }; - } catch (error) { - console.error("Failed to show save dialog:", error); - return { - success: false, - message: "Failed to show save dialog", - error: String(error), - }; - } - }); + return { success: true, path: path.normalize(result.filePath) }; + } catch (error) { + console.error("Failed to show save dialog:", error); + return { + success: false, + message: "Failed to show save dialog", + error: String(error), + }; + } + }, + ); ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => { try { diff --git a/electron/preload.ts b/electron/preload.ts index 5980b4ce..c6df8af2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -53,6 +53,9 @@ contextBridge.exposeInMainWorld("electronAPI", { storeRecordedSession: (payload: StoreRecordedSessionInput) => { return ipcRenderer.invoke("store-recorded-session", payload); }, + storeBackgroundImage: (imageData: ArrayBuffer, fileName: string, mimeType?: string) => { + return ipcRenderer.invoke("store-background-image", imageData, fileName, mimeType); + }, getRecordedVideoPath: () => { return ipcRenderer.invoke("get-recorded-video-path"); @@ -74,8 +77,11 @@ contextBridge.exposeInMainWorld("electronAPI", { openExternalUrl: (url: string) => { return ipcRenderer.invoke("open-external-url", url); }, - pickExportSavePath: (fileName: string, exportFolder?: string) => { - return ipcRenderer.invoke("pick-export-save-path", fileName, exportFolder); + pickExportSavePath: ( + fileName: string, + options?: { autoSaveToDownloads?: boolean; exportFolder?: string }, + ) => { + return ipcRenderer.invoke("pick-export-save-path", fileName, options); }, writeExportToPath: (videoData: ArrayBuffer, filePath: string) => { return ipcRenderer.invoke("write-export-to-path", videoData, filePath); diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 9ef66b14..2bf03c7c 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -50,6 +50,7 @@ import { getTestId } from "@/utils/getTestId"; import ColorPicker from "../ui/color-picker"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; +import { BACKGROUND_IMAGE_ACCEPT, isSupportedBackgroundImageType } from "./backgroundImageUpload"; import { CropControl } from "./CropControl"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import type { @@ -229,6 +230,8 @@ interface SettingsPanelProps { cursorHighlightSupportsClicks?: boolean; selected: string; onWallpaperChange: (path: string) => void; + customImages?: string[]; + onCustomImagesChange?: (images: string[]) => void; selectedZoomDepth?: ZoomDepth | null; onZoomDepthChange?: (depth: ZoomDepth) => void; selectedZoomCustomScale?: number | null; @@ -269,6 +272,8 @@ interface SettingsPanelProps { // Export format settings exportFormat?: ExportFormat; onExportFormatChange?: (format: ExportFormat) => void; + autoSaveExportToDownloads?: boolean; + onAutoSaveExportToDownloadsChange?: (enabled: boolean) => void; gifFrameRate?: GifFrameRate; onGifFrameRateChange?: (rate: GifFrameRate) => void; gifLoop?: boolean; @@ -330,6 +335,8 @@ export function SettingsPanel({ cursorHighlightSupportsClicks = false, selected, onWallpaperChange, + customImages = [], + onCustomImagesChange, selectedZoomDepth, onZoomDepthChange, selectedZoomCustomScale, @@ -369,6 +376,8 @@ export function SettingsPanel({ onExportQualityChange, exportFormat = "mp4", onExportFormatChange, + autoSaveExportToDownloads = false, + onAutoSaveExportToDownloadsChange, gifFrameRate = 15, onGifFrameRateChange, gifLoop = true, @@ -412,7 +421,6 @@ export function SettingsPanel({ // `/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); const colorPalette = [ "#FF0000", @@ -578,15 +586,13 @@ export function SettingsPanel({ } }; - const handleImageUpload = (event: React.ChangeEvent) => { + const handleImageUpload = async (event: React.ChangeEvent) => { const files = event.target.files; if (!files || files.length === 0) return; const file = files[0]; - // Validate file type - only allow JPG/JPEG - const validTypes = ["image/jpeg", "image/jpg"]; - if (!validTypes.includes(file.type)) { + if (!isSupportedBackgroundImageType(file.type, file.name)) { toast.error(t("imageUpload.invalidFileType"), { description: t("imageUpload.jpgOnly"), }); @@ -594,31 +600,31 @@ export function SettingsPanel({ return; } - const reader = new FileReader(); - - reader.onload = (e) => { - const dataUrl = e.target?.result as string; - if (dataUrl) { - setCustomImages((prev) => [...prev, dataUrl]); - onWallpaperChange(dataUrl); - toast.success(t("imageUpload.uploadSuccess")); + try { + const imageData = await file.arrayBuffer(); + const result = await window.electronAPI.storeBackgroundImage(imageData, file.name, file.type); + if (!result.success || !result.url) { + toast.error(t("imageUpload.failedToUpload"), { + description: result.message || result.error || t("imageUpload.errorReading"), + }); + return; } - }; - - reader.onerror = () => { + onCustomImagesChange?.([...customImages, result.url]); + onWallpaperChange(result.url); + toast.success(t("imageUpload.uploadSuccess")); + } catch { toast.error(t("imageUpload.failedToUpload"), { description: t("imageUpload.errorReading"), }); - }; - - reader.readAsDataURL(file); - // Reset input so the same file can be selected again - event.target.value = ""; + } finally { + // Reset input so the same file can be selected again + event.target.value = ""; + } }; const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => { event.stopPropagation(); - setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); + onCustomImagesChange?.(customImages.filter((img) => img !== imageUrl)); // If the removed image was selected, clear selection if (selected === imageUrl) { onWallpaperChange(WALLPAPER_PATHS[0]); @@ -1629,7 +1635,7 @@ export function SettingsPanel({ type="file" ref={fileInputRef} onChange={handleImageUpload} - accept=".jpg,.jpeg,image/jpeg" + accept={BACKGROUND_IMAGE_ACCEPT} className="hidden" />