From 9469b30a4f405490fbdb8b42ccf6f7a326cd66a1 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:10:31 +1000 Subject: [PATCH] fix(project): add named save IPC and stabilize recorder lifecycle --- electron/electron-api-projects.d.ts | 11 ++++ electron/ipc/register/project.ts | 65 ++++++++++++++++++++++-- electron/preload.ts | 12 +++++ src/hooks/useScreenRecorder/lifecycle.ts | 57 +++++++++++---------- 4 files changed, 115 insertions(+), 30 deletions(-) diff --git a/electron/electron-api-projects.d.ts b/electron/electron-api-projects.d.ts index 70aefb87..5a52e972 100644 --- a/electron/electron-api-projects.d.ts +++ b/electron/electron-api-projects.d.ts @@ -25,6 +25,17 @@ interface ElectronAPIProjects { canceled?: boolean; error?: string; }>; + saveProjectFileNamed: ( + projectData: unknown, + projectName: string, + thumbnailDataUrl?: string | null, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + error?: string; + }>; loadProjectFile: () => Promise<{ success: boolean; path?: string; diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index dee0af16..10c999e3 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -41,6 +41,29 @@ function normalizeRecordingTimeOffsetMs(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0; } +function normalizeProjectSaveName(projectName?: string | null) { + if (typeof projectName !== "string") { + return null; + } + + const trimmedName = projectName.trim(); + if (!trimmedName) { + return null; + } + + const withoutExtension = trimmedName.replace( + new RegExp(`\\.${PROJECT_FILE_EXTENSION}$`, "i"), + "", + ); + const sanitizedName = withoutExtension + .replace(/[<>:"/\\|?*\u0000-\u001F]/g, "") + .replace(/\s+/g, " ") + .replace(/[. ]+$/g, "") + .trim(); + + return sanitizedName || null; +} + export function registerProjectHandlers() { ipcMain.handle('reveal-in-folder', async (_, filePath: string) => { try { @@ -142,10 +165,8 @@ export function registerProjectHandlers() { } } - const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, '_') - const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`) - ? safeName - : `${safeName}.${PROJECT_FILE_EXTENSION}` + const safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}` + const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}` const result = await dialog.showSaveDialog({ title: 'Save Recordly Project', @@ -185,6 +206,42 @@ export function registerProjectHandlers() { } }) + ipcMain.handle('save-project-file-named', async (_, projectData: unknown, projectName: string, thumbnailDataUrl?: string | null) => { + try { + const normalizedProjectName = normalizeProjectSaveName(projectName) + if (!normalizedProjectName) { + return { + success: false, + message: 'Project name is required', + } + } + + const projectsDir = await getProjectsDir() + const targetProjectPath = path.join( + projectsDir, + `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`, + ) + + await fs.writeFile(targetProjectPath, JSON.stringify(projectData, null, 2), 'utf-8') + setCurrentProjectPath(targetProjectPath) + await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl) + await rememberRecentProject(targetProjectPath) + + return { + success: true, + path: targetProjectPath, + message: 'Project saved successfully' + } + } catch (error) { + console.error('Failed to save named project file:', error) + return { + success: false, + message: 'Failed to save project file', + error: String(error) + } + } + }) + ipcMain.handle('load-project-file', async () => { try { const projectsDir = await getProjectsDir() diff --git a/electron/preload.ts b/electron/preload.ts index 2c495e7b..031a9583 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -409,6 +409,18 @@ contextBridge.exposeInMainWorld("electronAPI", { thumbnailDataUrl, ); }, + saveProjectFileNamed: ( + projectData: unknown, + projectName: string, + thumbnailDataUrl?: string | null, + ) => { + return ipcRenderer.invoke( + "save-project-file-named", + projectData, + projectName, + thumbnailDataUrl, + ); + }, loadProjectFile: () => { return ipcRenderer.invoke("load-project-file"); }, diff --git a/src/hooks/useScreenRecorder/lifecycle.ts b/src/hooks/useScreenRecorder/lifecycle.ts index dbafc66e..96f633db 100644 --- a/src/hooks/useScreenRecorder/lifecycle.ts +++ b/src/hooks/useScreenRecorder/lifecycle.ts @@ -1,4 +1,4 @@ -import { useEffect, type MutableRefObject } from "react"; +import { useEffect, useRef, type MutableRefObject } from "react"; import { toast } from "sonner"; import type { ScreenRecorderRefs } from "./shared"; @@ -16,67 +16,71 @@ type UseScreenRecorderLifecycleOptions = { }; export function useScreenRecorderLifecycle(options: UseScreenRecorderLifecycleOptions) { + const optionsRef = useRef(options); + optionsRef.current = options; + useEffect(() => { void (async () => { const platform = await window.electronAPI.getPlatform(); - options.setIsMacOS(platform === "darwin"); + optionsRef.current.setIsMacOS(platform === "darwin"); })(); - }, [options]); + }, []); useEffect(() => { - if (options.refs.countdownDelayLoaded.current) return; - options.refs.countdownDelayLoaded.current = true; + if (optionsRef.current.refs.countdownDelayLoaded.current) return; + optionsRef.current.refs.countdownDelayLoaded.current = true; void (async () => { const result = await window.electronAPI.getCountdownDelay(); if (result.success && typeof result.delay === "number") { - options.setCountdownDelayState(result.delay); + optionsRef.current.setCountdownDelayState(result.delay); } })(); - }, [options]); + }, []); useEffect(() => { - if (options.refs.recordingPrefsLoaded.current) return; - options.refs.recordingPrefsLoaded.current = true; + if (optionsRef.current.refs.recordingPrefsLoaded.current) return; + optionsRef.current.refs.recordingPrefsLoaded.current = true; void (async () => { const result = await window.electronAPI.getRecordingPreferences(); if (result.success) { - options.setMicrophoneEnabled(result.microphoneEnabled); + optionsRef.current.setMicrophoneEnabled(result.microphoneEnabled); if (result.microphoneDeviceId) { - options.setMicrophoneDeviceId(result.microphoneDeviceId); + optionsRef.current.setMicrophoneDeviceId(result.microphoneDeviceId); } - options.setSystemAudioEnabled(result.systemAudioEnabled); + optionsRef.current.setSystemAudioEnabled(result.systemAudioEnabled); } })(); - }, [options]); + }, []); useEffect(() => { let cleanup: (() => void) | undefined; if (window.electronAPI?.onStopRecordingFromTray) { cleanup = window.electronAPI.onStopRecordingFromTray(() => { - options.stopRecordingRef.current(); + optionsRef.current.stopRecordingRef.current(); }); } const removeRecordingStateListener = window.electronAPI?.onRecordingStateChanged?.( (state) => { - options.setRecording(state.recording); + optionsRef.current.setRecording(state.recording); }, ); const removeRecordingInterruptedListener = window.electronAPI?.onRecordingInterrupted?.( (state) => { void (async () => { - options.setRecording(false); - options.refs.nativeScreenRecording.current = false; - await options.cleanupCapturedMedia(); + const currentOptions = optionsRef.current; + currentOptions.setRecording(false); + currentOptions.refs.nativeScreenRecording.current = false; + await currentOptions.cleanupCapturedMedia(); await window.electronAPI.setRecordingState(false); if (state.reason !== "window-unavailable") { try { - const recoveredPath = await options.recoverNativeRecordingSession(); + const recoveredPath = await currentOptions.recoverNativeRecordingSession(); if (recoveredPath) { return; } @@ -90,9 +94,9 @@ export function useScreenRecorderLifecycle(options: UseScreenRecorderLifecycleOp if ( state.reason === "window-unavailable" && - !options.refs.hasPromptedForReselect.current + !currentOptions.refs.hasPromptedForReselect.current ) { - options.refs.hasPromptedForReselect.current = true; + currentOptions.refs.hasPromptedForReselect.current = true; alert(state.message); await window.electronAPI.openSourceSelector(); } else { @@ -104,22 +108,23 @@ export function useScreenRecorderLifecycle(options: UseScreenRecorderLifecycleOp ); return () => { + const currentOptions = optionsRef.current; cleanup?.(); removeRecordingStateListener?.(); removeRecordingInterruptedListener?.(); - if (options.refs.nativeScreenRecording.current) { - options.refs.nativeScreenRecording.current = false; + if (currentOptions.refs.nativeScreenRecording.current) { + currentOptions.refs.nativeScreenRecording.current = false; void window.electronAPI.stopNativeScreenRecording(); } - const recorder = options.refs.mediaRecorder.current; + const recorder = currentOptions.refs.mediaRecorder.current; const recorderState = recorder?.state; if (recorder && (recorderState === "recording" || recorderState === "paused")) { recorder.stop(); } - void options.cleanupCapturedMedia(); + void currentOptions.cleanupCapturedMedia(); }; - }, [options]); + }, []); } \ No newline at end of file