From 4f739a058e8b7e2a0c12fd585e004619a2786b65 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sun, 19 Apr 2026 21:41:57 +0700 Subject: [PATCH 1/2] fix(export): reopen saved videos and default to source quality --- electron/ipc/project/manager.test.ts | 67 +++++++++++++++++++ electron/ipc/project/manager.ts | 10 +++ electron/ipc/register/export.ts | 5 +- electron/ipc/register/project.ts | 11 +-- .../video-editor/editorPreferences.test.ts | 4 ++ .../video-editor/projectPersistence.ts | 2 +- 6 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 electron/ipc/project/manager.test.ts diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts new file mode 100644 index 000000000..3f360ee4d --- /dev/null +++ b/electron/ipc/project/manager.test.ts @@ -0,0 +1,67 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("local media path policy", () => { + let tempRoot: string; + let appDataPath: string; + let userDataPath: string; + let tempPath: string; + let appPath: string; + + beforeEach(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-media-policy-")); + appDataPath = path.join(tempRoot, "AppData"); + userDataPath = path.join(tempRoot, "UserData"); + tempPath = path.join(tempRoot, "Temp"); + appPath = path.join(tempRoot, "App"); + + await Promise.all( + [appDataPath, userDataPath, tempPath, appPath].map((dirPath) => + fs.mkdir(dirPath, { recursive: true }), + ), + ); + + vi.resetModules(); + vi.doMock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: () => appPath, + getPath: (name: string) => { + if (name === "appData") return appDataPath; + if (name === "userData") return userDataPath; + if (name === "temp") return tempPath; + return tempRoot; + }, + setPath: () => undefined, + }, + })); + }); + + afterEach(async () => { + vi.resetModules(); + vi.doUnmock("electron"); + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("allows existing exported media files outside the session directories", async () => { + const downloadsPath = path.join(tempRoot, "Downloads"); + const exportPath = path.join(downloadsPath, "export-test.mp4"); + await fs.mkdir(downloadsPath, { recursive: true }); + await fs.writeFile(exportPath, "test-video"); + + const { isAllowedLocalMediaPath } = await import("./manager"); + + await expect(isAllowedLocalMediaPath(exportPath)).resolves.toBe(true); + }); + + it("rejects missing media files outside the allowed directories", async () => { + const missingPath = path.join(tempRoot, "Downloads", "missing.mp4"); + const { isAllowedLocalMediaPath } = await import("./manager"); + + await expect(isAllowedLocalMediaPath(missingPath)).resolves.toBe(false); + }); +}); diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index 48541e8ed..862af43e8 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -60,6 +60,16 @@ export function isAllowedLocalReadPath(candidatePath: string) { ); } +// Keep media-server access rules aligned with read-local-file so exported videos +// saved outside the active recording session can still be reopened in the editor. +export async function isAllowedLocalMediaPath(candidatePath: string) { + const normalizedCandidatePath = normalizePath(candidatePath); + const realPath = await fs.realpath(normalizedCandidatePath).catch(() => normalizedCandidatePath); + return ( + isAllowedLocalReadPath(normalizedCandidatePath) || isAllowedLocalReadPath(realPath) + ); +} + export async function rememberApprovedLocalReadPath(filePath?: string | null) { const normalizedPath = normalizeVideoSourcePath(filePath); if (!normalizedPath) { diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index cbd2b00fd..da7387f75 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -29,6 +29,7 @@ import { muxExportedVideoAudioBuffer, type NativeVideoExportSession, } from "../export/native-video"; +import { approveUserPath } from "../utils"; export function registerExportHandlers() { ipcMain.handle( @@ -341,6 +342,7 @@ export function registerExportHandlers() { } await fs.writeFile(result.filePath, Buffer.from(videoData)); + approveUserPath(result.filePath); return { success: true, @@ -362,10 +364,11 @@ export function registerExportHandlers() { const resolvedPath = path.resolve(outputPath) await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); await fs.writeFile(resolvedPath, Buffer.from(videoData)); + approveUserPath(resolvedPath); return { success: true, - path: outputPath, + path: resolvedPath, message: 'Video exported successfully', canceled: false, }; diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 32846a760..70b0fe0ba 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -15,10 +15,13 @@ import { setCurrentVideoPath, currentRecordingSession, setCurrentRecordingSession, - approvedLocalReadPaths, } from "../state"; import { normalizeVideoSourcePath } from "../utils"; -import { isPathInsideDirectory, replaceApprovedSessionLocalReadPaths } from "../project/manager"; +import { + isAllowedLocalMediaPath, + isPathInsideDirectory, + replaceApprovedSessionLocalReadPaths, +} from "../project/manager"; import { getTelemetryPathForVideo, isAutoRecordingPath, @@ -391,8 +394,8 @@ export function registerProjectHandlers() { } catch { return { success: false as const }; } - if (!approvedLocalReadPaths.has(resolved) && !approvedLocalReadPaths.has(normalized)) { - console.warn(`[get-local-media-url] Blocked unapproved path: ${resolved}`); + if (!(await isAllowedLocalMediaPath(resolved))) { + console.warn(`[get-local-media-url] Blocked disallowed path: ${resolved}`); return { success: false as const }; } return { success: true as const, url: buildMediaUrl(baseUrl, resolved) }; diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index b74ee1593..edd0c0120 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -52,6 +52,10 @@ describe("editorPreferences", () => { ).toEqual(DEFAULT_EDITOR_PREFERENCES); }); + it("defaults MP4 exports to source quality", () => { + expect(DEFAULT_EDITOR_PREFERENCES.exportQuality).toBe("source"); + }); + it("loads stored editor control preferences", () => { vi.stubGlobal( "localStorage", diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 81c59744d..71823988a 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -838,7 +838,7 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.exportQuality === "high" || editor.exportQuality === "source" ? editor.exportQuality - : "good", + : "source", mp4FrameRate: normalizeExportMp4FrameRate(editor.mp4FrameRate), exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4", gifFrameRate: From cac33005e326fbcd98cfd91b05bb98fa9555884f Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sun, 19 Apr 2026 23:44:10 +0700 Subject: [PATCH 2/2] test(export): tighten media path regression coverage --- electron/ipc/project/manager.test.ts | 9 +++++++++ electron/ipc/project/manager.ts | 5 +---- electron/ipc/register/project.ts | 18 ++++++++---------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts index 3f360ee4d..375d0f9d0 100644 --- a/electron/ipc/project/manager.test.ts +++ b/electron/ipc/project/manager.test.ts @@ -64,4 +64,13 @@ describe("local media path policy", () => { await expect(isAllowedLocalMediaPath(missingPath)).resolves.toBe(false); }); + + it("allows approved media paths before the file exists", async () => { + const pendingExportPath = path.join(tempRoot, "Downloads", "pending-export.mp4"); + const { isAllowedLocalMediaPath, rememberApprovedLocalReadPath } = await import("./manager"); + + await rememberApprovedLocalReadPath(pendingExportPath); + + await expect(isAllowedLocalMediaPath(pendingExportPath)).resolves.toBe(true); + }); }); diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index 862af43e8..e13127f63 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -64,10 +64,7 @@ export function isAllowedLocalReadPath(candidatePath: string) { // saved outside the active recording session can still be reopened in the editor. export async function isAllowedLocalMediaPath(candidatePath: string) { const normalizedCandidatePath = normalizePath(candidatePath); - const realPath = await fs.realpath(normalizedCandidatePath).catch(() => normalizedCandidatePath); - return ( - isAllowedLocalReadPath(normalizedCandidatePath) || isAllowedLocalReadPath(realPath) - ); + return isAllowedLocalReadPath(normalizedCandidatePath); } export async function rememberApprovedLocalReadPath(filePath?: string | null) { diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 70b0fe0ba..dee0af163 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -16,27 +16,25 @@ import { currentRecordingSession, setCurrentRecordingSession, } from "../state"; -import { normalizeVideoSourcePath } from "../utils"; import { + getProjectsDir, isAllowedLocalMediaPath, isPathInsideDirectory, + isTrustedProjectPath, + listProjectLibraryEntries, + loadProjectFromPath, + persistRecordingsDirectorySetting, replaceApprovedSessionLocalReadPaths, + rememberRecentProject, + saveProjectThumbnail, } from "../project/manager"; import { getTelemetryPathForVideo, isAutoRecordingPath, getRecordingsDir, approveUserPath, + normalizeVideoSourcePath, } from "../utils"; -import { - getProjectsDir, - persistRecordingsDirectorySetting, - saveProjectThumbnail, - rememberRecentProject, - listProjectLibraryEntries, - loadProjectFromPath, - isTrustedProjectPath, -} from "../project/manager"; import { persistRecordingSessionManifest, resolveRecordingSession } from "../project/session"; function normalizeRecordingTimeOffsetMs(value: unknown): number {