From 3509ecf21c00ad84b4b8c55b2dd985feed49ee2e Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Mon, 20 Apr 2026 02:03:45 +0700 Subject: [PATCH] fix(recordings): fail closed during prune project scans --- electron/ipc/recording/prune.test.ts | 52 +++++++++++++++++++++--- electron/ipc/recording/prune.ts | 61 +++++++++++++++------------- 2 files changed, 79 insertions(+), 34 deletions(-) diff --git a/electron/ipc/recording/prune.test.ts b/electron/ipc/recording/prune.test.ts index d546be62..8c1c6683 100644 --- a/electron/ipc/recording/prune.test.ts +++ b/electron/ipc/recording/prune.test.ts @@ -57,7 +57,7 @@ describe("pruneAutoRecordings", () => { await fs.mkdir(projectsDir, { recursive: true }); const recordingPaths: string[] = []; - for (let index = 0; index < 22; index += 1) { + for (let index = 0; index < 23; index += 1) { const recordingPath = path.join(recordingsDir, `recording-${index}.mp4`); recordingPaths.push(recordingPath); await fs.writeFile(recordingPath, `video-${index}`); @@ -65,14 +65,31 @@ describe("pruneAutoRecordings", () => { await fs.utimes(recordingPath, timestamp, timestamp); } - const protectedRecordingPath = recordingPaths.at(-2); + const protectedVideoRecordingPath = recordingPaths.at(-3); + const protectedWebcamRecordingPath = recordingPaths.at(-2); const prunableRecordingPath = recordingPaths.at(-1); await fs.writeFile( - path.join(projectsDir, `saved-project.${PROJECT_FILE_EXTENSION}`), + path.join(projectsDir, `saved-project-video.${PROJECT_FILE_EXTENSION}`), JSON.stringify( { - videoPath: protectedRecordingPath, + videoPath: protectedVideoRecordingPath, + }, + null, + 2, + ), + "utf-8", + ); + + await fs.writeFile( + path.join(projectsDir, `saved-project-webcam.${PROJECT_FILE_EXTENSION}`), + JSON.stringify( + { + editor: { + webcam: { + sourcePath: protectedWebcamRecordingPath, + }, + }, }, null, 2, @@ -82,7 +99,32 @@ describe("pruneAutoRecordings", () => { await pruneAutoRecordings(); - await expect(fs.access(protectedRecordingPath!)).resolves.toBeUndefined(); + await expect(fs.access(protectedVideoRecordingPath!)).resolves.toBeUndefined(); + await expect(fs.access(protectedWebcamRecordingPath!)).resolves.toBeUndefined(); await expect(fs.access(prunableRecordingPath!)).rejects.toThrow(); }); + + it("aborts pruning when a saved project cannot be parsed", async () => { + const { getRecordingsDir } = await import("../utils"); + const { PROJECTS_DIRECTORY_NAME, PROJECT_FILE_EXTENSION } = await import("../constants"); + const { pruneAutoRecordings } = await import("./prune"); + + const recordingsDir = await getRecordingsDir(); + const projectsDir = path.join(recordingsDir, PROJECTS_DIRECTORY_NAME); + await fs.mkdir(projectsDir, { recursive: true }); + + const recordingPath = path.join(recordingsDir, "recording-stale.mp4"); + await fs.writeFile(recordingPath, "video"); + const staleTimestamp = new Date(Date.now() - 48 * 60 * 60 * 1_000); + await fs.utimes(recordingPath, staleTimestamp, staleTimestamp); + + await fs.writeFile( + path.join(projectsDir, `broken-project.${PROJECT_FILE_EXTENSION}`), + "{ invalid json", + "utf-8", + ); + + await expect(pruneAutoRecordings()).rejects.toThrow(); + await expect(fs.access(recordingPath)).resolves.toBeUndefined(); + }); }); diff --git a/electron/ipc/recording/prune.ts b/electron/ipc/recording/prune.ts index 29023dff..2ade345d 100644 --- a/electron/ipc/recording/prune.ts +++ b/electron/ipc/recording/prune.ts @@ -46,7 +46,18 @@ async function loadSavedProjectMediaPaths() { ...LEGACY_PROJECT_FILE_EXTENSIONS, ]); - const projectEntries = await fs.readdir(projectsDir, { withFileTypes: true }).catch(() => []); + let projectEntries: Array<{ isFile(): boolean; name: string }>; + try { + projectEntries = await fs.readdir(projectsDir, { withFileTypes: true }); + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error ? error.code : undefined; + if (code === "ENOENT") { + return protectedPaths; + } + + throw error; + } await Promise.all( projectEntries @@ -60,37 +71,29 @@ async function loadSavedProjectMediaPaths() { }) .map(async (entry) => { const projectPath = path.join(projectsDir, entry.name); + const rawProject = JSON.parse(await fs.readFile(projectPath, "utf-8")) as { + videoPath?: unknown; + editor?: { webcam?: { sourcePath?: unknown } }; + }; + const candidatePaths = [ + rawProject.videoPath, + rawProject.editor?.webcam?.sourcePath, + ]; - try { - const rawProject = JSON.parse(await fs.readFile(projectPath, "utf-8")) as { - videoPath?: unknown; - editor?: { webcam?: { sourcePath?: unknown } }; - }; - const candidatePaths = [ - rawProject.videoPath, - rawProject.editor?.webcam?.sourcePath, - ]; + for (const candidatePath of candidatePaths) { + if (typeof candidatePath !== "string" || candidatePath.trim().length === 0) { + continue; + } - for (const candidatePath of candidatePaths) { - if ( - typeof candidatePath !== "string" || - candidatePath.trim().length === 0 - ) { - continue; - } - - const normalizedCandidatePath = normalizePath( - normalizeVideoSourcePath(candidatePath) ?? candidatePath, - ); - protectedPaths.add(normalizedCandidatePath); - try { - protectedPaths.add(await fs.realpath(normalizedCandidatePath)); - } catch { - // Ignore missing project media; project loading already surfaces that error. - } + const normalizedCandidatePath = normalizePath( + normalizeVideoSourcePath(candidatePath) ?? candidatePath, + ); + protectedPaths.add(normalizedCandidatePath); + try { + protectedPaths.add(await fs.realpath(normalizedCandidatePath)); + } catch { + // Ignore missing project media; project loading already surfaces that error. } - } catch { - // Ignore malformed project files during retention pruning. } }), );