From a38454a7fb03a5e736c52abf58a6ed5c280bb63a Mon Sep 17 00:00:00 2001 From: AbhinRustagi Date: Sat, 2 May 2026 01:02:42 +0530 Subject: [PATCH 01/12] feat: update saveExportedVideo fn signature --- electron/electron-env.d.ts | 1 + electron/preload.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 85d8294..f04b7c3 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -76,6 +76,7 @@ interface Window { saveExportedVideo: ( videoData: ArrayBuffer, fileName: string, + exportFolder?: string, ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; diff --git a/electron/preload.ts b/electron/preload.ts index 46e16f0..ec221b0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -68,8 +68,8 @@ contextBridge.exposeInMainWorld("electronAPI", { openExternalUrl: (url: string) => { return ipcRenderer.invoke("open-external-url", url); }, - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { - return ipcRenderer.invoke("save-exported-video", videoData, fileName); + saveExportedVideo: (videoData: ArrayBuffer, fileName: string, exportFolder?: string) => { + return ipcRenderer.invoke("save-exported-video", videoData, fileName, exportFolder); }, openVideoFilePicker: () => { return ipcRenderer.invoke("open-video-file-picker"); From c40727672ffa206d7f57f38a2913906f03dfcbe0 Mon Sep 17 00:00:00 2001 From: AbhinRustagi Date: Sat, 2 May 2026 01:05:17 +0530 Subject: [PATCH 02/12] feat: implement handlers to store last export location --- electron/ipc/handlers.ts | 102 ++++++++++++-------- src/components/video-editor/VideoEditor.tsx | 19 +++- src/lib/userPreferences.ts | 18 ++++ 3 files changed, 94 insertions(+), 45 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 95ed797..9a8e9ca 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -822,54 +822,72 @@ export function registerIpcHandlers( * @returns Object with success status, optional file path, and error details. */ - ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => { - try { - // Determine file type from extension - const isGif = fileName.toLowerCase().endsWith(".gif"); - const filters = isGif - ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] - : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; + ipcMain.handle( + "save-exported-video", + async (_, videoData: ArrayBuffer, fileName: string, exportFolder?: string) => { + try { + // Determine file type from extension + const isGif = fileName.toLowerCase().endsWith(".gif"); + const filters = isGif + ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] + : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; - const result = await dialog.showSaveDialog({ - title: isGif - ? mainT("dialogs", "fileDialogs.saveGif") - : mainT("dialogs", "fileDialogs.saveVideo"), - defaultPath: path.join(app.getPath("downloads"), fileName), - filters, - properties: ["createDirectory", "showOverwriteConfirmation"], - }); + // 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; + } + } catch { + // Folder was moved or deleted since the last export; keep Downloads. + } + } - if (result.canceled || !result.filePath) { + const result = await dialog.showSaveDialog({ + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), + defaultPath: path.join(defaultDir, fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }); + + if (result.canceled || !result.filePath) { + return { + success: false, + canceled: true, + message: "Export canceled", + }; + } + + // --- FIX: Normalize the path for Windows compatibility --- + const normalizedPath = path.normalize(result.filePath); + + // Ensure the parent directory exists (Windows may fail if the folder is missing) + await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); + // --- END FIX --- + + await fs.writeFile(normalizedPath, Buffer.from(videoData)); + + return { + success: true, + path: normalizedPath, + message: "Video exported successfully", + }; + } catch (error) { + console.error("Failed to save exported video:", error); return { success: false, - canceled: true, - message: "Export canceled", + message: "Failed to save exported video", + error: String(error), }; } - - // --- FIX: Normalize the path for Windows compatibility --- - const normalizedPath = path.normalize(result.filePath); - - // Ensure the parent directory exists (Windows may fail if the folder is missing) - await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); - // --- END FIX --- - - await fs.writeFile(normalizedPath, Buffer.from(videoData)); - - return { - success: true, - path: normalizedPath, - message: "Video exported successfully", - }; - } catch (error) { - console.error("Failed to save exported video:", error); - return { - success: false, - message: "Failed to save exported video", - error: String(error), - }; - } - }); + }, + ); ipcMain.handle("open-video-file-picker", async () => { try { const result = await dialog.showOpenDialog({ diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7adc558..cf174fa 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -31,7 +31,7 @@ import { import { computeFrameStepTime } from "@/lib/frameStep"; import type { ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; -import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; +import { loadUserPreferences, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences"; import { BackgroundLoadError } from "@/lib/wallpaper"; import { getAspectRatioValue, @@ -1285,6 +1285,10 @@ export default function VideoEditor() { const handleExportSaved = useCallback( (formatLabel: "GIF" | "Video", filePath: string) => { setExportedFilePath(filePath); + const folder = parentDirectoryOf(filePath); + if (folder) { + saveUserPreferences({ exportFolder: folder }); + } toast.success( t("export.exportedSuccessfully", { format: formatLabel, @@ -1309,6 +1313,7 @@ export default function VideoEditor() { const saveResult = await window.electronAPI.saveExportedVideo( unsavedExport.arrayBuffer, unsavedExport.fileName, + loadUserPreferences().exportFolder ?? undefined, ); if (saveResult.canceled) { toast.info("Export canceled"); @@ -1410,7 +1415,11 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + loadUserPreferences().exportFolder ?? undefined, + ); if (saveResult.canceled) { setUnsavedExport({ arrayBuffer, fileName, format: "gif" }); @@ -1550,7 +1559,11 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + loadUserPreferences().exportFolder ?? undefined, + ); if (saveResult.canceled) { setUnsavedExport({ arrayBuffer, fileName, format: "mp4" }); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index e060788..6947da5 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -23,6 +23,8 @@ export interface UserPreferences { exportQuality: ExportQuality; /** Default export format */ exportFormat: ExportFormat; + /** Folder used for the most recent successful export, if any */ + exportFolder: string | null; } const DEFAULT_PREFS: UserPreferences = { @@ -30,6 +32,7 @@ const DEFAULT_PREFS: UserPreferences = { aspectRatio: "16:9", exportQuality: "good", exportFormat: "mp4", + exportFolder: null, }; function safeJsonParse(text: string | null): Record | null { @@ -76,9 +79,24 @@ export function loadUserPreferences(): UserPreferences { raw.exportFormat === "gif" || raw.exportFormat === "mp4" ? (raw.exportFormat as ExportFormat) : DEFAULT_PREFS.exportFormat, + exportFolder: + typeof raw.exportFolder === "string" && raw.exportFolder.length > 0 + ? raw.exportFolder + : DEFAULT_PREFS.exportFolder, }; } +/** + * Extracts the parent directory from a saved file path. Handles both POSIX + * and Windows separators since the path comes from the OS save dialog. + * Returns null if no separator is found. + */ +export function parentDirectoryOf(filePath: string): string | null { + const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")); + if (lastSep <= 0) return null; + return filePath.slice(0, lastSep); +} + /** * Persist user preferences to localStorage. * Only the explicitly provided fields are updated. From b801c1ccea42522e752fc9b72a733d492e262400 Mon Sep 17 00:00:00 2001 From: AbhinRustagi Date: Sat, 2 May 2026 01:19:44 +0530 Subject: [PATCH 03/12] fix: resolve comments --- src/lib/userPreferences.test.ts | 26 ++++++++++++++++++++++++++ src/lib/userPreferences.ts | 17 ++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/lib/userPreferences.test.ts diff --git a/src/lib/userPreferences.test.ts b/src/lib/userPreferences.test.ts new file mode 100644 index 0000000..5ba9fce --- /dev/null +++ b/src/lib/userPreferences.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { parentDirectoryOf } from "./userPreferences"; + +describe("parentDirectoryOf", () => { + it("returns the directory for a POSIX path", () => { + expect(parentDirectoryOf("/Users/me/Movies/clip.mp4")).toBe("/Users/me/Movies"); + }); + + it("returns the directory for a Windows path", () => { + expect(parentDirectoryOf("C:\\Users\\me\\Movies\\clip.mp4")).toBe("C:\\Users\\me\\Movies"); + }); + + it("preserves the POSIX root when the file is at /", () => { + expect(parentDirectoryOf("/video.mp4")).toBe("/"); + }); + + it("preserves the Windows drive root with its trailing separator", () => { + expect(parentDirectoryOf("C:\\video.mp4")).toBe("C:\\"); + expect(parentDirectoryOf("D:/video.mp4")).toBe("D:/"); + }); + + it("returns null when no separator is present", () => { + expect(parentDirectoryOf("video.mp4")).toBeNull(); + expect(parentDirectoryOf("")).toBeNull(); + }); +}); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 6947da5..2c9db6f 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -89,11 +89,26 @@ export function loadUserPreferences(): UserPreferences { /** * Extracts the parent directory from a saved file path. Handles both POSIX * and Windows separators since the path comes from the OS save dialog. + * + * Root directories are preserved with their trailing separator so that the + * value is still a valid directory path: + * "/video.mp4" -> "/" + * "C:\\video.mp4" -> "C:\\" + * * Returns null if no separator is found. */ export function parentDirectoryOf(filePath: string): string | null { const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")); - if (lastSep <= 0) return null; + if (lastSep < 0) return null; + + // POSIX root, e.g. "/video.mp4" -> "/" + if (lastSep === 0) return filePath[0]; + + // Windows drive root, e.g. "C:\\video.mp4" -> "C:\\" + if (lastSep === 2 && /^[A-Za-z]:[/\\]/.test(filePath)) { + return filePath.slice(0, lastSep + 1); + } + return filePath.slice(0, lastSep); } From b3469c469b474a3a3aa32b909088b8611ee96f58 Mon Sep 17 00:00:00 2001 From: makaradam Date: Sat, 2 May 2026 12:28:04 +0200 Subject: [PATCH 04/12] feat: replace native OS close dialog with custom in-app dialog --- electron/electron-env.d.ts | 2 + electron/main.ts | 43 ++++------ electron/preload.ts | 8 ++ .../video-editor/UnsavedChangesDialog.tsx | 78 +++++++++++++++++++ src/components/video-editor/VideoEditor.tsx | 31 ++++++++ 5 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 src/components/video-editor/UnsavedChangesDialog.tsx diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 85d8294..f4b379f 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -143,6 +143,8 @@ interface Window { setMicrophoneExpanded: (expanded: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void; onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void; + onRequestCloseConfirm: (callback: () => void) => () => void; + sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => void; setLocale: (locale: string) => Promise; }; } diff --git a/electron/main.ts b/electron/main.ts index ad0a33f..5540419 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url"; import { app, BrowserWindow, - dialog, ipcMain, Menu, nativeImage, @@ -288,35 +287,27 @@ function createEditorWindowWrapper() { event.preventDefault(); - const choice = dialog.showMessageBoxSync(mainWindow!, { - type: "warning", - buttons: [ - mainT("dialogs", "unsavedChanges.saveAndClose"), - mainT("dialogs", "unsavedChanges.discardAndClose"), - mainT("common", "actions.cancel"), - ], - defaultId: 0, - cancelId: 2, - title: mainT("dialogs", "unsavedChanges.title"), - message: mainT("dialogs", "unsavedChanges.message"), - detail: mainT("dialogs", "unsavedChanges.detail"), - }); - const windowToClose = mainWindow; if (!windowToClose || windowToClose.isDestroyed()) return; - if (choice === 0) { - // Save & Close — tell renderer to save, then close - windowToClose.webContents.send("request-save-before-close"); - ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => { - if (!shouldClose) return; + // Ask renderer to show the custom in-app dialog + windowToClose.webContents.send("request-close-confirm"); + + ipcMain.once("close-confirm-response", (_, choice: "save" | "discard" | "cancel") => { + if (!windowToClose || windowToClose.isDestroyed()) return; + + if (choice === "save") { + // Tell renderer to save the project, then close when done + windowToClose.webContents.send("request-save-before-close"); + ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => { + if (!shouldClose) return; + forceCloseEditorWindow(windowToClose); + }); + } else if (choice === "discard") { forceCloseEditorWindow(windowToClose); - }); - } else if (choice === 1) { - // Discard & Close - forceCloseEditorWindow(windowToClose); - } - // choice === 2: Cancel — do nothing, window stays open + } + // "cancel": do nothing, window stays open + }); }); } diff --git a/electron/preload.ts b/electron/preload.ts index 46e16f0..2e065bd 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -163,4 +163,12 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("request-save-before-close", listener); return () => ipcRenderer.removeListener("request-save-before-close", listener); }, + onRequestCloseConfirm: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("request-close-confirm", listener); + return () => ipcRenderer.removeListener("request-close-confirm", listener); + }, + sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => { + ipcRenderer.send("close-confirm-response", choice); + }, }); diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx new file mode 100644 index 0000000..9b8ee03 --- /dev/null +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -0,0 +1,78 @@ +import { Save, Trash2, X } from "lucide-react"; +import { useScopedT } from "@/contexts/I18nContext"; + +interface UnsavedChangesDialogProps { + isOpen: boolean; + onSaveAndClose: () => void; + onDiscardAndClose: () => void; + onCancel: () => void; +} + +export function UnsavedChangesDialog({ + isOpen, + onSaveAndClose, + onDiscardAndClose, + onCancel, +}: UnsavedChangesDialogProps) { + const td = useScopedT("dialogs"); + const tc = useScopedT("common"); + + if (!isOpen) return null; + + return ( + <> +
+
+
+ OpenScreen +

+ {td("unsavedChanges.title")} +

+ +
+ +

{td("unsavedChanges.message")}

+

{td("unsavedChanges.detail")}

+ +
+ + + +
+
+ + ); +} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7adc558..14c695a 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -74,6 +74,7 @@ import { type ZoomFocusMode, type ZoomRegion, } from "./types"; +import { UnsavedChangesDialog } from "./UnsavedChangesDialog"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; export default function VideoEditor() { @@ -144,6 +145,7 @@ export default function VideoEditor() { format: string; } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); + const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false); const playerContainerRef = useRef(null); const videoPlaybackRef = useRef(null); @@ -524,6 +526,28 @@ export default function VideoEditor() { return () => cleanup(); }, [saveProject]); + useEffect(() => { + const cleanup = window.electronAPI.onRequestCloseConfirm(() => { + setShowCloseConfirmDialog(true); + }); + return () => cleanup(); + }, []); + + const handleCloseConfirmSave = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("save"); + }, []); + + const handleCloseConfirmDiscard = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("discard"); + }, []); + + const handleCloseConfirmCancel = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("cancel"); + }, []); + const handleSaveProject = useCallback(async () => { await saveProject(false); }, [saveProject]); @@ -2066,6 +2090,13 @@ export default function VideoEditor() { exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined } /> + +
); } From 36076aaf2a3efd77213d11474c38d81177e1e7be Mon Sep 17 00:00:00 2001 From: makaradam Date: Sat, 2 May 2026 13:08:52 +0200 Subject: [PATCH 05/12] fix: address code review feedback on custom close dialog --- electron/main.ts | 7 ++- .../video-editor/UnsavedChangesDialog.tsx | 63 +++++++++---------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 5540419..94f0a42 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -252,6 +252,7 @@ function updateTrayMenu(recording: boolean = false) { let editorHasUnsavedChanges = false; let isForceClosing = false; +let isCloseConfirmInFlight = false; ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => { editorHasUnsavedChanges = hasChanges; @@ -283,9 +284,10 @@ function createEditorWindowWrapper() { editorHasUnsavedChanges = false; mainWindow.on("close", (event) => { - if (isForceClosing || !editorHasUnsavedChanges) return; + if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return; event.preventDefault(); + isCloseConfirmInFlight = true; const windowToClose = mainWindow; if (!windowToClose || windowToClose.isDestroyed()) return; @@ -294,6 +296,7 @@ function createEditorWindowWrapper() { windowToClose.webContents.send("request-close-confirm"); ipcMain.once("close-confirm-response", (_, choice: "save" | "discard" | "cancel") => { + isCloseConfirmInFlight = false; if (!windowToClose || windowToClose.isDestroyed()) return; if (choice === "save") { @@ -306,7 +309,7 @@ function createEditorWindowWrapper() { } else if (choice === "discard") { forceCloseEditorWindow(windowToClose); } - // "cancel": do nothing, window stays open + // "cancel": flag reset, window stays open }); }); } diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx index 9b8ee03..a0623ba 100644 --- a/src/components/video-editor/UnsavedChangesDialog.tsx +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -1,4 +1,11 @@ -import { Save, Trash2, X } from "lucide-react"; +import { Save, Trash2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { useScopedT } from "@/contexts/I18nContext"; interface UnsavedChangesDialogProps { @@ -17,41 +24,33 @@ export function UnsavedChangesDialog({ const td = useScopedT("dialogs"); const tc = useScopedT("common"); - if (!isOpen) return null; - return ( - <> -
-
-
- OpenScreen -

- {td("unsavedChanges.title")} -

- -
+ !open && onCancel()}> + + +
+ + + {td("unsavedChanges.title")} + +
+

{td("unsavedChanges.message")}

-

{td("unsavedChanges.detail")}

+ + {td("unsavedChanges.detail")} +
-
- + + ); } From b2cc7226135117165e0b0fc539a913b5e4246d54 Mon Sep 17 00:00:00 2001 From: makaradam Date: Sat, 2 May 2026 13:43:20 +0200 Subject: [PATCH 06/12] fix: use getAssetPath for logo so it resolves correctly in packaged app --- src/components/video-editor/UnsavedChangesDialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx index a0623ba..f3f88dc 100644 --- a/src/components/video-editor/UnsavedChangesDialog.tsx +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -7,6 +7,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useScopedT } from "@/contexts/I18nContext"; +import getAssetPath from "@/lib/assetPath"; interface UnsavedChangesDialogProps { isOpen: boolean; @@ -30,7 +31,7 @@ export function UnsavedChangesDialog({
Date: Sat, 2 May 2026 14:33:14 +0200 Subject: [PATCH 07/12] fix: use relative path for logo so it resolves in packaged app ./openscreen.png resolves correctly both in dev (Vite serves public/) and in production (loadFile sets base to dist/, where public assets land inside the asar). getAssetPath points to extraResources, which is the wrong location for bundled dist assets. --- src/components/video-editor/UnsavedChangesDialog.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx index f3f88dc..902b142 100644 --- a/src/components/video-editor/UnsavedChangesDialog.tsx +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -7,7 +7,6 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useScopedT } from "@/contexts/I18nContext"; -import getAssetPath from "@/lib/assetPath"; interface UnsavedChangesDialogProps { isOpen: boolean; @@ -31,7 +30,7 @@ export function UnsavedChangesDialog({
Date: Sat, 2 May 2026 14:36:59 +0200 Subject: [PATCH 08/12] fix: scope IPC close-confirm responses to the originating window Both ipcMain.once handlers now check event.sender.id against windowToClose.webContents.id and ignore messages from any other renderer, preventing cross-window response mix-ups if multiple editor windows are ever open simultaneously. --- electron/main.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 94f0a42..3e0b232 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -295,14 +295,16 @@ function createEditorWindowWrapper() { // Ask renderer to show the custom in-app dialog windowToClose.webContents.send("request-close-confirm"); - ipcMain.once("close-confirm-response", (_, choice: "save" | "discard" | "cancel") => { + ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => { + if (event.sender.id !== windowToClose?.webContents.id) return; isCloseConfirmInFlight = false; if (!windowToClose || windowToClose.isDestroyed()) return; if (choice === "save") { // Tell renderer to save the project, then close when done windowToClose.webContents.send("request-save-before-close"); - ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => { + ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => { + if (event.sender.id !== windowToClose?.webContents.id) return; if (!shouldClose) return; forceCloseEditorWindow(windowToClose); }); From 25cfd2777fa3a10fd93e463b2eb435bc93d2dae0 Mon Sep 17 00:00:00 2001 From: AbhinRustagi Date: Fri, 8 May 2026 05:24:40 +0530 Subject: [PATCH 09/12] fix: resolve comments --- electron/electron-env.d.ts | 8 +++++++- electron/ipc/handlers.ts | 10 ++++++++-- src/components/video-editor/VideoEditor.tsx | 13 +++++++++---- src/lib/userPreferences.ts | 8 ++++++++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f04b7c3..5e26c44 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -77,7 +77,13 @@ interface Window { videoData: ArrayBuffer, fileName: string, exportFolder?: string, - ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + error?: string; + }>; openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; setCurrentRecordingSession: ( diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 9a8e9ca..cb8d217 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -842,8 +842,14 @@ export function registerIpcHandlers( if (stats.isDirectory()) { defaultDir = exportFolder; } - } catch { - // Folder was moved or deleted since the last export; keep Downloads. + } catch (err) { + // Stat can fail because the folder was moved/deleted (expected) or + // because of a permission error (worth surfacing). Either way we + // fall back to Downloads, but log so debugging isn't blind. + console.warn( + `Could not access remembered export folder "${exportFolder}", falling back to Downloads:`, + err, + ); } } diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index cf174fa..46957a3 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -31,7 +31,12 @@ import { import { computeFrameStepTime } from "@/lib/frameStep"; import type { ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; -import { loadUserPreferences, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences"; +import { + getExportFolder, + loadUserPreferences, + parentDirectoryOf, + saveUserPreferences, +} from "@/lib/userPreferences"; import { BackgroundLoadError } from "@/lib/wallpaper"; import { getAspectRatioValue, @@ -1313,7 +1318,7 @@ export default function VideoEditor() { const saveResult = await window.electronAPI.saveExportedVideo( unsavedExport.arrayBuffer, unsavedExport.fileName, - loadUserPreferences().exportFolder ?? undefined, + getExportFolder(), ); if (saveResult.canceled) { toast.info("Export canceled"); @@ -1418,7 +1423,7 @@ export default function VideoEditor() { const saveResult = await window.electronAPI.saveExportedVideo( arrayBuffer, fileName, - loadUserPreferences().exportFolder ?? undefined, + getExportFolder(), ); if (saveResult.canceled) { @@ -1562,7 +1567,7 @@ export default function VideoEditor() { const saveResult = await window.electronAPI.saveExportedVideo( arrayBuffer, fileName, - loadUserPreferences().exportFolder ?? undefined, + getExportFolder(), ); if (saveResult.canceled) { diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 2c9db6f..5b7bc86 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -112,6 +112,14 @@ export function parentDirectoryOf(filePath: string): string | null { return filePath.slice(0, lastSep); } +/** + * Returns the remembered export folder as `string | undefined`, suitable for + * passing directly to IPC handlers that treat absence as "use the default". + */ +export function getExportFolder(): string | undefined { + return loadUserPreferences().exportFolder ?? undefined; +} + /** * Persist user preferences to localStorage. * Only the explicitly provided fields are updated. From c9980b0dca53ae6bfc46fd8f6544d333b8458a4d Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Thu, 7 May 2026 23:22:32 -0400 Subject: [PATCH 10/12] fix: tests + how to write them --- .github/workflows/ci.yml | 1 + docs/tests/writing-tests.md | 149 ++++++++++++++++++ package-lock.json | 91 +++++------ .../tutorialHelpTranslations.test.ts | 6 + src/lib/blurEffects.test.ts | 2 +- vitest.config.ts | 1 + 6 files changed, 194 insertions(+), 56 deletions(-) create mode 100644 docs/tests/writing-tests.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4194797..3c9e8ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,7 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: npm run test - run: npm run test:browser:install - run: npm run test:browser diff --git a/docs/tests/writing-tests.md b/docs/tests/writing-tests.md new file mode 100644 index 0000000..09ede7e --- /dev/null +++ b/docs/tests/writing-tests.md @@ -0,0 +1,149 @@ +# Writing Tests + +This project uses [Vitest](https://vitest.dev/) for both unit/integration tests and browser tests. There are two separate configs — each targets a different set of files. + +## Unit tests + +**Config:** `vitest.config.ts` +**Runs in:** jsdom (simulated DOM, no real browser) +**File pattern:** `src/**/*.test.ts` — anything that does **not** end in `.browser.test.ts` +**CI command:** `npm run test` + +Use unit tests for pure logic, utility functions, data transformations, and anything that doesn't need real browser APIs (Canvas, WebCodecs, MediaRecorder, etc.). + +### File placement + +Co-locate the test file next to the source file, or put it in a `__tests__/` folder in the same directory. + +``` +src/lib/compositeLayout.ts +src/lib/compositeLayout.test.ts # co-located + +src/i18n/__tests__/tutorialHelpTranslations.test.ts # grouped +``` + +### Example + +```ts +import { describe, expect, it } from "vitest"; +import { computeCompositeLayout } from "./compositeLayout"; + +describe("computeCompositeLayout", () => { + it("anchors the overlay in the lower-right corner", () => { + const layout = computeCompositeLayout({ + canvasSize: { width: 1920, height: 1080 }, + screenSize: { width: 1920, height: 1080 }, + webcamSize: { width: 1280, height: 720 }, + }); + + expect(layout).not.toBeNull(); + expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2); + expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2); + }); +}); +``` + +### Path aliases + +The `@/` alias resolves to `src/`. Use it for imports that would otherwise need long relative paths. + +```ts +import { SUPPORTED_LOCALES } from "@/i18n/config"; +``` + +### Running locally + +```bash +npm run test # run once +npm run test:watch # watch mode +``` + +--- + +## Browser tests + +**Config:** `vitest.browser.config.ts` +**Runs in:** real Chromium via Playwright (headless) +**File pattern:** `src/**/*.browser.test.ts` +**CI commands:** `npm run test:browser:install` then `npm run test:browser` + +Use browser tests when the code under test depends on real browser APIs that jsdom doesn't implement: `VideoDecoder`, `VideoEncoder`, `MediaRecorder`, `OffscreenCanvas`, `WebGL`, etc. + +### File placement + +Name the file `.browser.test.ts` and place it next to the source file. + +``` +src/lib/exporter/videoExporter.ts +src/lib/exporter/videoExporter.browser.test.ts +``` + +### Loading fixture assets + +Static assets (video files, images) live in `tests/fixtures/`. Import them with Vite's `?url` suffix so Vite serves them through the dev server. + +```ts +import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; +``` + +### Example + +```ts +import { describe, expect, it } from "vitest"; +import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; +import { VideoExporter } from "./videoExporter"; + +describe("VideoExporter (real browser)", () => { + it("exports a valid MP4 blob from a real video", async () => { + const exporter = new VideoExporter({ + videoUrl: sampleVideoUrl, + width: 320, + height: 180, + frameRate: 15, + bitrate: 1_000_000, + wallpaper: "#1a1a2e", + 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).toBeInstanceOf(Blob); + }); +}); +``` + +### Timeouts + +Browser tests have a default timeout of 120 seconds per test and 30 seconds per hook (set in `vitest.browser.config.ts`). Export operations are slow — prefer small fixture dimensions (320×180) and low bitrates to keep tests fast. + +### Running locally + +First install the browser (one-time): + +```bash +npm run test:browser:install +``` + +Then run the tests: + +```bash +npm run test:browser +``` + +--- + +## Choosing the right type + +| Situation | Use | +|---|---| +| Pure function / data transformation | Unit test | +| i18n key coverage | Unit test | +| React hook logic (no real browser APIs) | Unit test | +| `VideoDecoder` / `VideoEncoder` / `MediaRecorder` | Browser test | +| `OffscreenCanvas` / WebGL / Pixi.js rendering | Browser test | +| File export producing a real `Blob` | Browser test | diff --git a/package-lock.json b/package-lock.json index e823ad1..afe2091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "openscreen", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.3.0", + "version": "1.4.0", + "hasInstallScript": true, "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", @@ -187,6 +188,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -395,6 +397,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -718,6 +721,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -766,6 +770,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1197,7 +1202,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1219,7 +1223,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1236,7 +1239,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1251,7 +1253,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -1961,7 +1962,6 @@ "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz", "integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/colord": "^2.9.6" } @@ -1976,8 +1976,7 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz", "integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/core": { "version": "7.4.3", @@ -2004,8 +2003,7 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz", "integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/filter-drop-shadow": { "version": "5.2.0", @@ -2032,22 +2030,19 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz", "integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/runner": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz", "integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/settings": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz", "integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/constants": "7.4.3", "@types/css-font-loading-module": "^0.0.12", @@ -2059,7 +2054,6 @@ "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz", "integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/extensions": "7.4.3", "@pixi/settings": "7.4.3", @@ -2071,7 +2065,6 @@ "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz", "integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/color": "7.4.3", "@pixi/constants": "7.4.3", @@ -3650,8 +3643,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3726,8 +3718,7 @@ "version": "0.0.12", "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/debug": { "version": "4.1.13", @@ -3765,8 +3756,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -3865,6 +3855,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3876,6 +3867,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4157,6 +4149,7 @@ "integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/browser": "4.1.5", "@vitest/mocker": "4.1.5", @@ -4349,6 +4342,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4874,6 +4868,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5055,7 +5050,6 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -5406,8 +5400,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -5697,6 +5690,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -5789,8 +5783,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dotenv": { "version": "16.6.1", @@ -5839,8 +5832,7 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/ejs": { "version": "3.1.10", @@ -6023,7 +6015,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6044,7 +6035,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6287,8 +6277,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/expect-type": { "version": "1.3.0", @@ -7700,7 +7689,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7920,7 +7908,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -8234,7 +8221,6 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -8443,6 +8429,7 @@ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.18.1.tgz", "integrity": "sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==", "license": "MIT", + "peer": true, "workspaces": [ "examples", "playground" @@ -8488,6 +8475,7 @@ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.59.1" }, @@ -8558,6 +8546,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8702,7 +8691,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -8720,7 +8708,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -8731,7 +8718,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -8747,7 +8733,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -8861,7 +8846,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -8920,6 +8904,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8932,6 +8917,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8968,8 +8954,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -9290,7 +9275,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -9497,7 +9481,6 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -9517,7 +9500,6 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" @@ -9534,7 +9516,6 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -9553,7 +9534,6 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -9894,6 +9874,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -9977,7 +9958,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -10378,7 +10358,6 @@ "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", "license": "MIT", - "peer": true, "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" @@ -10391,8 +10370,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/use-callback-ref": { "version": "1.3.3", @@ -10485,6 +10463,7 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10574,7 +10553,8 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", @@ -10597,6 +10577,7 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", diff --git a/src/i18n/__tests__/tutorialHelpTranslations.test.ts b/src/i18n/__tests__/tutorialHelpTranslations.test.ts index fcfa9d3..b391a12 100644 --- a/src/i18n/__tests__/tutorialHelpTranslations.test.ts +++ b/src/i18n/__tests__/tutorialHelpTranslations.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from "vitest"; import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; +import arDialogs from "@/i18n/locales/ar/dialogs.json"; import enDialogs from "@/i18n/locales/en/dialogs.json"; import esDialogs from "@/i18n/locales/es/dialogs.json"; import frDialogs from "@/i18n/locales/fr/dialogs.json"; +import jaJPDialogs from "@/i18n/locales/ja-JP/dialogs.json"; import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json"; import trDialogs from "@/i18n/locales/tr/dialogs.json"; import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json"; +import zhTWDialogs from "@/i18n/locales/zh-TW/dialogs.json"; const tutorialHelpKeys = [ "triggerLabel", @@ -35,10 +38,13 @@ const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1Des const dialogsByLocale = { en: enDialogs, "zh-CN": zhCNDialogs, + "zh-TW": zhTWDialogs, es: esDialogs, fr: frDialogs, tr: trDialogs, "ko-KR": koKRDialogs, + "ja-JP": jaJPDialogs, + ar: arDialogs, } satisfies Record }>; describe("TutorialHelp translations", () => { diff --git a/src/lib/blurEffects.test.ts b/src/lib/blurEffects.test.ts index 4797e69..1a6a9c9 100644 --- a/src/lib/blurEffects.test.ts +++ b/src/lib/blurEffects.test.ts @@ -75,6 +75,6 @@ describe("blur color helpers", () => { intensity: 12, blockSize: 12, }), - ).toBe("rgba(0, 0, 0, 0.18)"); + ).toBe("rgba(0, 0, 0, 0.56)"); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index ea60216..9108f69 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ globals: true, environment: "jsdom", include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + exclude: ["src/**/*.browser.test.{ts,tsx}"], }, resolve: { alias: { From a0c423de677f46a2d04fbf64b7fc5c226b355c29 Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Fri, 8 May 2026 00:00:30 -0400 Subject: [PATCH 11/12] add diagnostics report --- electron/electron-env.d.ts | 6 +++ electron/ipc/handlers.ts | 42 +++++++++++++++++++ electron/preload.ts | 8 ++++ src/components/video-editor/SettingsPanel.tsx | 13 ++++++ src/components/video-editor/VideoEditor.tsx | 14 +++++++ 5 files changed, 83 insertions(+) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index d9ebab2..6d3d9d5 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -150,6 +150,12 @@ interface Window { setHasUnsavedChanges: (hasChanges: boolean) => void; onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void; setLocale: (locale: string) => Promise; + saveDiagnostic: (payload: { + error: string; + stack?: string; + projectState: unknown; + logs: string[]; + }) => Promise<{ success: boolean; path?: string; canceled?: boolean; error?: string }>; }; } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 7361b26..20154bc 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,5 +1,6 @@ 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"; @@ -1317,4 +1318,45 @@ export function registerIpcHandlers( return { success: false, error: String(error) }; } }); + + ipcMain.handle( + "save-diagnostic", + async ( + _, + payload: { error: string; stack?: string; projectState: unknown; logs: string[] }, + ) => { + const { filePath, canceled } = await dialog.showSaveDialog({ + title: "Save Diagnostic File", + defaultPath: `openscreen-diagnostic-${Date.now()}.json`, + filters: [{ name: "JSON", extensions: ["json"] }], + }); + + if (canceled || !filePath) return { success: false, canceled: true }; + + const diagnostic = { + timestamp: new Date().toISOString(), + appVersion: app.getVersion(), + platform: process.platform, + arch: process.arch, + osRelease: os.release(), + osVersion: os.version(), + totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024), + nodeVersion: process.versions.node, + electronVersion: process.versions.electron, + chromeVersion: process.versions.chrome, + error: payload.error, + stack: payload.stack, + projectState: payload.projectState, + recentLogs: payload.logs, + }; + + try { + await fs.writeFile(filePath, JSON.stringify(diagnostic, null, 2), "utf-8"); + return { success: true, path: filePath }; + } catch (error) { + console.error("Failed to write diagnostic file:", error); + return { success: false, error: String(error) }; + } + }, + ); } diff --git a/electron/preload.ts b/electron/preload.ts index 6c705d7..9149756 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -134,6 +134,14 @@ contextBridge.exposeInMainWorld("electronAPI", { setLocale: (locale: string) => { return ipcRenderer.invoke("set-locale", locale); }, + saveDiagnostic: (payload: { + error: string; + stack?: string; + projectState: unknown; + logs: string[]; + }) => { + return ipcRenderer.invoke("save-diagnostic", payload); + }, setMicrophoneExpanded: (expanded: boolean) => { ipcRenderer.send("hud:setMicrophoneExpanded", expanded); }, diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 76ff762..377cbbe 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -3,6 +3,7 @@ import { ChevronDown, Crop, Download, + FileDown, Film, Image, Lock, @@ -240,6 +241,7 @@ interface SettingsPanelProps { webcamSizePreset?: WebcamSizePreset; onWebcamSizePresetChange?: (size: WebcamSizePreset) => void; onWebcamSizePresetCommit?: () => void; + onSaveDiagnostic?: () => Promise; } export default SettingsPanel; @@ -327,6 +329,7 @@ export function SettingsPanel({ webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET, onWebcamSizePresetChange, onWebcamSizePresetCommit, + onSaveDiagnostic, }: SettingsPanelProps) { const t = useScopedT("settings"); // Resolved URLs are for DOM rendering only (backgroundImage). The canonical @@ -1682,6 +1685,16 @@ export function SettingsPanel({ {t("links.reportBug")} + {onSaveDiagnostic && ( + + )}
From f47fa6bdca465de34d67bf2105e3b7e918e5a5f7 Mon Sep 17 00:00:00 2001 From: Trivenzaa-Admin Date: Fri, 8 May 2026 01:48:52 -0700 Subject: [PATCH 12/12] fix(macos): add NSScreenCaptureUsageDescription and screen-capture entitlement Without NSScreenCaptureUsageDescription in Info.plist, macOS silently blocks desktopCapturer.getSources(), breaking window detection on macOS 10.15+. Also adds the com.apple.security.device.screen-capture entitlement to macos.entitlements alongside the existing camera and audio-input entries. Fixes #548 --- electron-builder.json5 | 1 + macos.entitlements | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/electron-builder.json5 b/electron-builder.json5 index ad6cd18..d9fee6b 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -51,6 +51,7 @@ "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", + "NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.", "NSCameraUseContinuityCameraDeviceType": true, "com.apple.security.device.audio-input": true } diff --git a/macos.entitlements b/macos.entitlements index 5c6ddcf..38d8b29 100644 --- a/macos.entitlements +++ b/macos.entitlements @@ -21,5 +21,9 @@ com.apple.security.device.camera + + + com.apple.security.device.screen-capture +