fix(project): add named save IPC and stabilize recorder lifecycle

This commit is contained in:
webadderall
2026-04-21 11:10:31 +10:00
parent 99f8ef0ae9
commit 9469b30a4f
4 changed files with 115 additions and 30 deletions
+11
View File
@@ -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;
+61 -4
View File
@@ -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()
+12
View File
@@ -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");
},
+31 -26
View File
@@ -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]);
}, []);
}