From be60576475cada66a115747db835acbcb05b0038 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:28:25 +1000 Subject: [PATCH] fix: preserve media safely during imports and deduplicate sign-in callbacks --- docs/authentication.md | 4 +- docs/ui-redundancy-audit.md | 2 +- electron/authCallback.ts | 7 +- electron/electron-env.d.ts | 6 +- electron/ipc/recording/importRecording.ts | 22 ++-- electron/ipc/recording/library.test.ts | 25 +++- electron/ipc/recording/library.ts | 113 +++++++++--------- electron/ipc/register/assets.ts | 15 ++- electron/ipc/register/cloudShare.ts | 1 + electron/ipc/register/project.ts | 37 ++++-- electron/preload.ts | 13 +- src/components/auth/useRecordlyAuth.ts | 10 +- .../library/useRecordingLibrary.ts | 11 +- src/lib/auth/recordlyAuth.test.ts | 31 +++++ src/lib/auth/recordlyAuth.ts | 19 ++- 15 files changed, 218 insertions(+), 98 deletions(-) create mode 100644 src/lib/auth/recordlyAuth.test.ts diff --git a/docs/authentication.md b/docs/authentication.md index 6d9d2d49..96e7b30b 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,6 +1,6 @@ # Recordly authentication setup -Recordly uses Supabase Auth for one shared session across email/password and Google. The desktop application uses PKCE and returns from the system browser through a loopback callback in development and `recordly://auth/callback` in production. X and SAML are intentionally hidden until those providers are configured. +Recordly uses Supabase Auth for one shared session across email/password and Google. The desktop application uses PKCE and returns from the system browser through a loopback callback in development and `recordly://auth/callback` in production. X is shown in the sign-in dialog and requires its provider to be enabled in Supabase. SAML is intentionally hidden until configured. ## 1. Create the project @@ -18,7 +18,7 @@ Email/password works once email authentication is enabled and a user has been cr 3. Enable Google under **Supabase → Authentication → Sign In / Providers** and paste the Google client ID and secret. 4. Keep the requested scopes to `openid`, email, and profile unless Recordly genuinely needs more. -## 3. X (currently hidden) +## 3. X 1. Create an OAuth 2.0 app in the X Developer Dashboard and enable requesting the user's email. 2. Set its callback URL to `https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback`. diff --git a/docs/ui-redundancy-audit.md b/docs/ui-redundancy-audit.md index 0cdd8fca..2cd0fcfd 100644 --- a/docs/ui-redundancy-audit.md +++ b/docs/ui-redundancy-audit.md @@ -45,7 +45,7 @@ Regression coverage includes keyboard deletion for each block type, inspector de - HUD source/device rows and source/region triggers use ghost buttons. Project cards fit the 300px HUD popup, scroll vertically with thin scrollbars, and no longer truncate the library at 24 entries. - Caption generation skips session re-registration when the source is unchanged, preserving active companion-audio approvals. Local media URLs resolve the current server port; preview retries a failed local load once through the existing approved-media API. -- Videos uses a secondary header button at the far left and replaces the existing left inspector with lightweight file cards (no embedded video players). It lists files in the configured recordings directory. Removal sends the selected recordings and their capture companions to system Trash. Session undo restores from a temporary recovery copy without overwriting newer files. This replaces the earlier hide-only behavior. Automatic recording age/count pruning is removed. +- Videos uses a secondary header button at the far left and replaces the existing left inspector with lightweight file cards (no embedded video players). It lists files in the configured recordings directory. Removal stages the selected recordings and their capture companions using filesystem moves. Undo restores the latest removal without copying media or overwriting newer files; the next removal or app exit sends staged files to system Trash. This replaces the earlier hide-only behavior. Automatic recording age/count pruning is removed. - Timeline drops insert at a clip boundary. Since preview/export share a single source, imports prepare an immutable combined source in `.recordly-media`. New footage fits the current source canvas/frame rate; subsequent imports copy existing prepared video, with lossless system/mic companions and shifted cursor telemetry. Preparation takes time for large recordings. Clip source bounds prevent trimming into the next recording. Timeline history can undo/redo insertions because existing source offsets are retained. - The existing FFmpeg metadata parser is now shared by import and export, rather than duplicating it or depending on the incompatible FFprobe binary found in this development install. diff --git a/electron/authCallback.ts b/electron/authCallback.ts index bfbbbea9..439aeb0e 100644 --- a/electron/authCallback.ts +++ b/electron/authCallback.ts @@ -111,10 +111,9 @@ export function createAuthCallbackController({ isDev, focusApp }: AuthCallbackOp dispatch(url); }); - ipcMain.handle("auth:get-pending-callback", () => { - const callback = pendingUrl; - pendingUrl = null; - return callback; + ipcMain.handle("auth:get-pending-callback", () => pendingUrl); + ipcMain.handle("auth:ack-callback", (_, url: string) => { + if (pendingUrl === url) pendingUrl = null; }); return { close, dispatch, find, protocol, startDevServer }; diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 6ee89b0e..d7a1a9eb 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -752,7 +752,10 @@ interface Window { }>; getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; clearCurrentVideoPath: () => Promise<{ success: boolean }>; - getRecordingThumbnail: (filePath: string) => Promise>; + getRecordingThumbnail: ( + filePath: string, + ) => Promise>; + finishRecordingImport: (keepPath: string) => Promise<{ success: boolean; error?: string }>; cancelRecordingImport: () => Promise<{ success: boolean }>; listRecordings: () => Promise< import("../src/types/recordingLibrary").LibraryResult< @@ -892,6 +895,7 @@ interface Window { isWindowFullscreen: () => Promise; onWindowFullscreenChanged: (callback: (isFullscreen: boolean) => void) => () => void; getLinuxWindowSystem: () => Promise<"wayland" | "x11" | null>; + ackAuthCallbackUrl: (url: string) => Promise; getPendingAuthCallbackUrl: () => Promise; onAuthCallbackUrl: (callback: (url: string) => void) => () => void; revealInFolder: ( diff --git a/electron/ipc/recording/importRecording.ts b/electron/ipc/recording/importRecording.ts index ce4ab45e..bab9b17f 100644 --- a/electron/ipc/recording/importRecording.ts +++ b/electron/ipc/recording/importRecording.ts @@ -130,13 +130,7 @@ export async function importRecording( const work = await fs.mkdtemp(path.join(root, "import-")); const output = path.join(root, `${id}.mp4`); const stem = output.slice(0, -4); - const outputs = [ - ...sequenceWebcamOutputs(output), - output, - `${stem}.system.wav`, - `${stem}.mic.wav`, - `${output}.cursor.json`, - ]; + const outputs = recordingImportOutputs(output); try { const normalizedBase = path.join(work, "base.mkv"); const normalizedNew = path.join(work, "new.mkv"); @@ -253,3 +247,17 @@ export async function importRecording( await fs.rm(work, { recursive: true, force: true }); } } + +function recordingImportOutputs(output: string) { + const stem = output.slice(0, -4); + return [ + ...sequenceWebcamOutputs(output), + output, + `${stem}.system.wav`, + `${stem}.mic.wav`, + `${output}.cursor.json`, + ]; +} +export async function discardRecordingImport(output: string) { + await Promise.all(recordingImportOutputs(output).map((file) => fs.rm(file, { force: true }))); +} diff --git a/electron/ipc/recording/library.test.ts b/electron/ipc/recording/library.test.ts index 3e240023..5c6560d2 100644 --- a/electron/ipc/recording/library.test.ts +++ b/electron/ipc/recording/library.test.ts @@ -46,8 +46,8 @@ vi.mock("../ffmpeg/binary", async () => { getFfprobeBinaryPath: () => require("ffprobe-static").path, }; }); -import { listRecordings, setRecordingsRemoved } from "./library"; -import { importRecording } from "./importRecording"; +import { listRecordings, setRecordingsRemoved, clearRecordingTrashUndo } from "./library"; +import { importRecording, discardRecordingImport } from "./importRecording"; import { getRecordingThumbnail } from "./thumbnail"; import { getCompanionAudioFallbackInfo } from "./diagnostics"; const require = createRequire(import.meta.url); @@ -58,6 +58,7 @@ beforeEach(async () => { state.approved.clear(); }); afterEach(async () => { + await clearRecordingTrashUndo(); await fs.rm(state.root, { recursive: true, force: true }); }); @@ -79,7 +80,9 @@ it("lists recordings, moves recordings and their companions to Trash with revers expect(await listRecordings()).toEqual([]); await expect(fs.access(first)).rejects.toThrow(); await expect(fs.access(path.join(state.root, "recording-new.mic.wav"))).rejects.toThrow(); - expect(await fs.readdir(path.join(state.root, ".test-trash"))).toHaveLength(1); + expect( + (await fs.readdir(state.root)).filter((name) => name.startsWith(".recordly-trash-")), + ).toHaveLength(1); await setRecordingsRemoved([first, second], false); expect(await listRecordings()).toHaveLength(2); expect(await fs.readFile(first, "utf8")).toBe("fixture"); @@ -238,6 +241,16 @@ it("imports different-sized recordings with playable video, separate audio, stab { startMs: 1200, endMs: 2000 }, { startMs: 2200, endMs: 3000 }, ]); + await discardRecordingImport(result.path); + await expect(fs.access(result.path)).rejects.toThrow(); + await expect(fs.access(result.path.replace(/\.mp4$/, ".mic.wav"))).rejects.toThrow(); + await expect(fs.access(result.path.replace(/\.mp4$/, "-webcam.mp4"))).rejects.toThrow(); + await expect( + fs.access(result.path.replace(/\.mp4$/, ".recordly-session.json")), + ).rejects.toThrow(); + await expect(fs.access(`${result.path}.webcam-ranges.json`)).rejects.toThrow(); + await expect(fs.access(`${result.path}.cursor.json`)).rejects.toThrow(); + await expect(fs.access(second.path)).resolves.toBeUndefined(); expect(await fs.readFile(base)).toEqual(original); expect((await listRecordings()).map((entry) => entry.path).sort()).toEqual( [base, added].sort(), @@ -247,12 +260,14 @@ it("imports different-sized recordings with playable video, separate audio, stab ); }, 60000); -it("restores every original when the OS refuses to trash the bundle", async () => { +it("retains staged originals for undo when the OS refuses Trash", async () => { const { shell } = await import("electron"); const file = path.join(state.root, "recording-failure.mp4"); await fs.writeFile(file, "original"); + await setRecordingsRemoved([file], true); vi.mocked(shell.trashItem).mockRejectedValueOnce(new Error("Trash unavailable")); - await expect(setRecordingsRemoved([file], true)).rejects.toThrow("Trash unavailable"); + await expect(clearRecordingTrashUndo()).rejects.toThrow("Trash unavailable"); + await setRecordingsRemoved([file], false); expect(await fs.readFile(file, "utf8")).toBe("original"); }); diff --git a/electron/ipc/recording/library.ts b/electron/ipc/recording/library.ts index 81322be8..0d3e94c1 100644 --- a/electron/ipc/recording/library.ts +++ b/electron/ipc/recording/library.ts @@ -1,39 +1,55 @@ import fs from "node:fs/promises"; -import { constants } from "node:fs"; import path from "node:path"; -import { app, shell } from "electron"; +import { shell } from "electron"; import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer"; import { rememberApprovedLocalReadPath } from "../project/manager"; import { getRecordingsDir } from "../utils"; import type { RecordingLibraryEntry } from "../../../src/types/recordingLibrary"; let mutation = Promise.resolve(); -const undoBatches = new Map(); +const undoBatches = new Map(); const isRecording = (name: string) => /\.(mp4|mov|webm|mkv|m4v)$/i.test(name) && !/[.-]webcam[.-]/i.test(name); const batchKey = (paths: string[]) => JSON.stringify([...new Set(paths)].sort()); -export async function listRecordings(): Promise { - const root = await fs.realpath(await getRecordingsDir()); - const server = getMediaServerBaseUrl(); - if (!server) throw new Error("Media server is not ready. Try again."); - const entries = await fs.readdir(root, { withFileTypes: true }); - const result: RecordingLibraryEntry[] = []; - for (const entry of entries) { - if (!entry.isFile() || !isRecording(entry.name)) continue; - const filePath = path.join(root, entry.name); - const stat = await fs.stat(filePath); - if (!stat.size) continue; - await rememberApprovedLocalReadPath(filePath); - result.push({ - path: filePath, - name: entry.name, - bytes: stat.size, - createdAt: stat.mtimeMs, - url: buildMediaUrl(server, filePath), - }); - } - return result.sort((a, b) => b.createdAt - a.createdAt); +export function listRecordings(): Promise { + const task = mutation.then(async () => { + const root = await fs.realpath(await getRecordingsDir()); + const server = getMediaServerBaseUrl(); + if (!server) throw new Error("Media server is not ready. Try again."); + const entries = await fs.readdir(root, { withFileTypes: true }); + for (const entry of entries) { + const staged = path.join(root, entry.name); + if ( + entry.isDirectory() && + /^\.recordly-trash-[A-Za-z0-9]{6}$/.test(entry.name) && + ![...undoBatches.values()].some((batch) => batch.bundle === staged) + ) { + await shell.trashItem(staged); + } + } + const result: RecordingLibraryEntry[] = []; + for (const entry of entries) { + if (!entry.isFile() || !isRecording(entry.name)) continue; + const filePath = path.join(root, entry.name); + const stat = await fs.stat(filePath); + if (!stat.size) continue; + await rememberApprovedLocalReadPath(filePath); + result.push({ + path: filePath, + name: entry.name, + bytes: stat.size, + createdAt: stat.mtimeMs, + url: buildMediaUrl(server, filePath), + }); + } + return result.sort((a, b) => b.createdAt - a.createdAt); + }); + mutation = task.then( + () => undefined, + () => undefined, + ); + return task; } // Only capture sidecars belonging to this exact recording, never adjacent recordings or projects. @@ -52,7 +68,7 @@ function belongsToRecording(name: string, video: string) { ); } -/** Move the recording bundle into the system Trash. Session backups allow cross-platform Undo. */ +/** Stage a bundle by rename for Undo; the next removal or app exit sends it to Trash. */ export function setRecordingsRemoved(paths: string[], removed: boolean): Promise { const task = mutation.then(async () => { if ( @@ -93,19 +109,15 @@ export function setRecordingsRemoved(paths: string[], removed: boolean): Promise const restored: string[] = []; try { for (const file of batch.files) { - await fs.copyFile( - path.join(batch.backup, path.basename(file)), - file, - constants.COPYFILE_EXCL, - ); + await fs.link(path.join(batch.bundle, path.basename(file)), file); restored.push(file); } } catch (error) { - await Promise.all(restored.map((file) => fs.rm(file, { force: true }))); + await Promise.all(restored.map((file) => fs.unlink(file))); throw error; } undoBatches.delete(key); - await fs.rm(batch.backup, { recursive: true, force: true }); + await fs.rm(batch.bundle, { recursive: true, force: true }); return; } const available = await fs.readdir(root, { withFileTypes: true }); @@ -120,42 +132,33 @@ export function setRecordingsRemoved(paths: string[], removed: boolean): Promise selected.some((file) => belongsToRecording(entry.name, path.basename(file))), ) .map((entry) => path.join(root, entry.name)); - const backup = await fs.mkdtemp(path.join(app.getPath("temp"), "recordly-trash-undo-")); - const bundle = await fs.mkdtemp(path.join(root, "Recordly videos ")); + await finishPendingTrash(); + const bundle = await fs.mkdtemp(path.join(root, ".recordly-trash-")); const moved: string[] = []; try { - for (const file of files) - await fs.copyFile( - file, - path.join(backup, path.basename(file)), - constants.COPYFILE_FICLONE, - ); for (const file of files) { await fs.rename(file, path.join(bundle, path.basename(file))); moved.push(file); } - await shell.trashItem(bundle); - undoBatches.set(key, { backup, files }); + undoBatches.set(key, { bundle, files }); } catch (error) { for (const file of moved) await fs.rename(path.join(bundle, path.basename(file)), file); - await fs.rm(backup, { recursive: true, force: true }); + await fs.rmdir(bundle); throw error; - } finally { - await fs.rmdir(bundle).catch((error) => { - if (error.code !== "ENOENT") throw error; - }); } }); mutation = task.catch(() => undefined); return task; } -export async function clearRecordingTrashUndo() { - await mutation; - await Promise.all( - [...undoBatches.values()].map(({ backup }) => - fs.rm(backup, { recursive: true, force: true }), - ), - ); - undoBatches.clear(); +async function finishPendingTrash() { + for (const [key, batch] of undoBatches) { + await shell.trashItem(batch.bundle); + undoBatches.delete(key); + } +} +export function clearRecordingTrashUndo() { + const task = mutation.then(finishPendingTrash); + mutation = task.catch(() => undefined); + return task; } diff --git a/electron/ipc/register/assets.ts b/electron/ipc/register/assets.ts index 11b244cb..e871cc17 100644 --- a/electron/ipc/register/assets.ts +++ b/electron/ipc/register/assets.ts @@ -29,13 +29,20 @@ export function registerAssetHandlers() { ipcMain.handle("generate-wallpaper-thumbnail", async (_, filePath: string) => { try { const bundled = filePath.startsWith("/wallpapers/"); + const wallpaperRoot = path.resolve(getAssetRootPath(), "wallpapers"); const candidate = bundled - ? path.join( - getAssetRootPath(), - "wallpapers", - path.basename(decodeURIComponent(filePath)), + ? path.resolve( + wallpaperRoot, + decodeURIComponent(filePath.slice("/wallpapers/".length)), ) : filePath; + if ( + bundled && + (path.relative(wallpaperRoot, candidate).startsWith("..") || + path.isAbsolute(path.relative(wallpaperRoot, candidate))) + ) { + throw new Error("Wallpaper path is outside the bundled wallpapers"); + } const resolved = await resolveReadableLocalFilePath(candidate); // Deterministic cache key from file path + mtime diff --git a/electron/ipc/register/cloudShare.ts b/electron/ipc/register/cloudShare.ts index e23fab3b..da40a517 100644 --- a/electron/ipc/register/cloudShare.ts +++ b/electron/ipc/register/cloudShare.ts @@ -326,6 +326,7 @@ export function registerCloudShareHandlers() { } const contentType = contentTypeFor(resolvedPath); const sendProgress = (uploadedBytes: number) => { + if (event.sender.isDestroyed()) return; event.sender.send("cloud-share-progress", { uploadId, uploadedBytes, diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 52b85b64..f4864c0c 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -1,6 +1,6 @@ import { getRecordingThumbnail } from "../recording/thumbnail"; import { listRecordings, setRecordingsRemoved } from "../recording/library"; -import { importRecording } from "../recording/importRecording"; +import { importRecording, discardRecordingImport } from "../recording/importRecording"; import { randomUUID } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; @@ -215,6 +215,22 @@ async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject( export function registerProjectHandlers() { const imports = new Map(); + const pendingImports = new Map>(); + ipcMain.handle("finish-recording-import", async (event, keepPath: string) => { + if (imports.has(event.sender.id)) + return { success: false, error: "Import is still running" }; + const outputs = pendingImports.get(event.sender.id); + try { + for (const output of outputs ?? []) { + if (output !== keepPath) await discardRecordingImport(output); + outputs?.delete(output); + } + pendingImports.delete(event.sender.id); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); ipcMain.handle("cancel-recording-import", (event) => { imports.get(event.sender.id)?.abort(); return { success: true }; @@ -255,15 +271,16 @@ export function registerProjectHandlers() { const controller = new AbortController(); imports.set(owner, controller); try { - return { - success: true, - value: await importRecording( - currentPath, - recordingPath, - webcam, - controller.signal, - ), - }; + const value = await importRecording( + currentPath, + recordingPath, + webcam, + controller.signal, + ); + const outputs = pendingImports.get(owner) ?? new Set(); + outputs.add(value.path); + pendingImports.set(owner, outputs); + return { success: true, value }; } catch (error) { return { success: false, error: String(error) }; } finally { diff --git a/electron/preload.ts b/electron/preload.ts index 1cf6c5dc..ee15d748 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -787,13 +787,19 @@ contextBridge.exposeInMainWorld("electronAPI", { clearCurrentVideoPath: () => { return ipcRenderer.invoke("clear-current-video-path"); }, - getRecordingThumbnail: (filePath: string) => ipcRenderer.invoke("get-recording-thumbnail", filePath), + getRecordingThumbnail: (filePath: string) => + ipcRenderer.invoke("get-recording-thumbnail", filePath), + finishRecordingImport: (keepPath: string) => + ipcRenderer.invoke("finish-recording-import", keepPath), cancelRecordingImport: () => ipcRenderer.invoke("cancel-recording-import"), listRecordings: () => ipcRenderer.invoke("list-recordings"), setRecordingsRemoved: (paths: string[], removed: boolean) => ipcRenderer.invoke("set-recordings-removed", paths, removed), - importRecording: (currentPath: string, recordingPath: string, webcam?: import("../src/types/recordingLibrary").RecordingWebcamSource) => - ipcRenderer.invoke("import-recording", currentPath, recordingPath, webcam), + importRecording: ( + currentPath: string, + recordingPath: string, + webcam?: import("../src/types/recordingLibrary").RecordingWebcamSource, + ) => ipcRenderer.invoke("import-recording", currentPath, recordingPath, webcam), deleteRecordingFile: (filePath: string) => { return ipcRenderer.invoke("delete-recording-file", filePath); }, @@ -971,6 +977,7 @@ contextBridge.exposeInMainWorld("electronAPI", { getLinuxWindowSystem: () => { return ipcRenderer.invoke("get-linux-window-system"); }, + ackAuthCallbackUrl: (url: string) => ipcRenderer.invoke("auth:ack-callback", url), getPendingAuthCallbackUrl: () => ipcRenderer.invoke("auth:get-pending-callback"), onAuthCallbackUrl: (callback: (url: string) => void) => { const listener = (_event: Electron.IpcRendererEvent, url: string) => callback(url); diff --git a/src/components/auth/useRecordlyAuth.ts b/src/components/auth/useRecordlyAuth.ts index 8a87ebea..ee847063 100644 --- a/src/components/auth/useRecordlyAuth.ts +++ b/src/components/auth/useRecordlyAuth.ts @@ -1,5 +1,5 @@ import type { User } from "@supabase/supabase-js"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { completeAuthCallback, recordlyAuth, @@ -11,6 +11,7 @@ export function useRecordlyAuth() { const [accessToken, setAccessToken] = useState(); const [loading, setLoading] = useState(recordlyAuthConfigured); const [callbackError, setCallbackError] = useState(); + const callbackUrl = useRef(undefined); useEffect(() => { if (!recordlyAuth) { @@ -42,11 +43,16 @@ export function useRecordlyAuth() { }); const handleCallback = async (url: string) => { + if (!mounted || callbackUrl.current === url) return; + callbackUrl.current = url; try { setCallbackError(undefined); await completeAuthCallback(url); } catch (error) { - setCallbackError(error instanceof Error ? error.message : String(error)); + if (mounted) + setCallbackError(error instanceof Error ? error.message : String(error)); + } finally { + await window.electronAPI.ackAuthCallbackUrl(url).catch(() => undefined); } }; const unsubscribe = window.electronAPI.onAuthCallbackUrl((url) => void handleCallback(url)); diff --git a/src/components/video-editor/library/useRecordingLibrary.ts b/src/components/video-editor/library/useRecordingLibrary.ts index e454c39c..7b439ab6 100644 --- a/src/components/video-editor/library/useRecordingLibrary.ts +++ b/src/components/video-editor/library/useRecordingLibrary.ts @@ -62,7 +62,7 @@ export function useRecordingLibrary( try { const result = await window.electronAPI.setRecordingsRemoved(paths, true); if (!result.success) throw new Error(result.error); - setRemoved((previous) => [...previous, paths]); + setRemoved([paths]); setEntries((previous) => previous.filter((entry) => !paths.includes(entry.path))); setSelected(new Set()); } catch (error) { @@ -103,6 +103,7 @@ export function useRecordingLibrary( }; const addToTimeline = async (paths: string | string[], index?: number) => { const source = current.current.project.videoSourcePath; + let retainedSource = source; if (lock.current || !source) return; lock.current = true; cancelled.current = false; @@ -203,6 +204,7 @@ export function useRecordingLibrary( enabled: true, })); project.setVideoSourcePath(media.path); + retainedSource = media.path; project.setVideoPath(media.url); ui.setIsPreviewReady(false); ui.setPreviewVersion((version) => version + 1); @@ -214,6 +216,13 @@ export function useRecordingLibrary( } catch (error) { if (!cancelled.current) toast.error(`Could not add video: ${String(error)}`); } finally { + try { + const cleanup = await window.electronAPI.finishRecordingImport(retainedSource!); + if (!cleanup.success) + console.warn("Could not clean up temporary imports", cleanup.error); + } catch (error) { + console.warn("Could not clean up temporary imports", error); + } lock.current = false; setImporting(false); setCancelling(false); diff --git a/src/lib/auth/recordlyAuth.test.ts b/src/lib/auth/recordlyAuth.test.ts new file mode 100644 index 00000000..fd09a18e --- /dev/null +++ b/src/lib/auth/recordlyAuth.test.ts @@ -0,0 +1,31 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const exchange = vi.hoisted(() => vi.fn(async (_code: string) => ({ error: null }))); +vi.mock("@supabase/supabase-js", () => ({ + createClient: () => ({ auth: { exchangeCodeForSession: exchange } }), +})); +beforeEach(() => { + vi.resetModules(); + exchange.mockClear(); + vi.stubEnv("VITE_SUPABASE_URL", "https://auth.example.test"); + vi.stubEnv("VITE_SUPABASE_PUBLISHABLE_KEY", "test-key"); +}); +afterEach(() => vi.unstubAllEnvs()); + +it("exchanges a callback once when live and pending delivery overlap", async () => { + const { completeAuthCallback } = await import("./recordlyAuth"); + const url = "recordly://auth/callback?code=one-time-code"; + await Promise.all([completeAuthCallback(url), completeAuthCallback(url)]); + await completeAuthCallback(url); + expect(exchange).toHaveBeenCalledExactlyOnceWith("one-time-code"); +}); + +it("shows provider errors without attempting a code exchange", async () => { + const { completeAuthCallback } = await import("./recordlyAuth"); + await expect( + completeAuthCallback( + "recordly://auth/callback?error=denied&error_description=Sign-in+cancelled", + ), + ).rejects.toThrow("Sign-in cancelled"); + expect(exchange).not.toHaveBeenCalled(); +}); diff --git a/src/lib/auth/recordlyAuth.ts b/src/lib/auth/recordlyAuth.ts index 41d5c153..38195fbe 100644 --- a/src/lib/auth/recordlyAuth.ts +++ b/src/lib/auth/recordlyAuth.ts @@ -21,7 +21,9 @@ export const recordlyAuth = recordlyAuthConfigured function requireAuth() { if (!recordlyAuth) { - throw new Error("Recordly Auth is not configured. Add the Supabase URL and publishable key."); + throw new Error( + "Recordly Auth is not configured. Add the Supabase URL and publishable key.", + ); } return recordlyAuth; } @@ -68,8 +70,11 @@ export async function signInWithSaml(email: string): Promise { await openAuthUrl(data.url); } -export async function completeAuthCallback(url: string): Promise { - const code = new URL(url).searchParams.get("code"); +async function exchangeAuthCallback(url: string): Promise { + const params = new URL(url).searchParams; + const providerError = params.get("error_description") || params.get("error"); + if (providerError) throw new Error(providerError); + const code = params.get("code"); if (!code) throw new Error("The sign-in callback did not include an authorization code."); const client = requireAuth(); const { error } = await client.auth.exchangeCodeForSession(code); @@ -81,3 +86,11 @@ export async function signOutRecordly(): Promise { const { error } = await client.auth.signOut(); if (error) throw error; } + +let lastCallback: { url: string; completion: Promise } | undefined; +export function completeAuthCallback(url: string): Promise { + if (lastCallback?.url === url) return lastCallback.completion; + const completion = exchangeAuthCallback(url); + lastCallback = { url, completion }; + return completion; +}