mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 15:55:35 +00:00
fix: preserve media safely during imports and deduplicate sign-in callbacks
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Vendored
+5
-1
@@ -752,7 +752,10 @@ interface Window {
|
||||
}>;
|
||||
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
|
||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
|
||||
getRecordingThumbnail: (filePath: string) => Promise<import("../src/types/recordingLibrary").LibraryResult<string>>;
|
||||
getRecordingThumbnail: (
|
||||
filePath: string,
|
||||
) => Promise<import("../src/types/recordingLibrary").LibraryResult<string>>;
|
||||
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<boolean>;
|
||||
onWindowFullscreenChanged: (callback: (isFullscreen: boolean) => void) => () => void;
|
||||
getLinuxWindowSystem: () => Promise<"wayland" | "x11" | null>;
|
||||
ackAuthCallbackUrl: (url: string) => Promise<void>;
|
||||
getPendingAuthCallbackUrl: () => Promise<string | null>;
|
||||
onAuthCallbackUrl: (callback: (url: string) => void) => () => void;
|
||||
revealInFolder: (
|
||||
|
||||
@@ -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 })));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, { backup: string; files: string[] }>();
|
||||
const undoBatches = new Map<string, { bundle: string; files: string[] }>();
|
||||
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<RecordingLibraryEntry[]> {
|
||||
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<RecordingLibraryEntry[]> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number, AbortController>();
|
||||
const pendingImports = new Map<number, Set<string>>();
|
||||
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<string>();
|
||||
outputs.add(value.path);
|
||||
pendingImports.set(owner, outputs);
|
||||
return { success: true, value };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
} finally {
|
||||
|
||||
+10
-3
@@ -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);
|
||||
|
||||
@@ -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<string>();
|
||||
const [loading, setLoading] = useState(recordlyAuthConfigured);
|
||||
const [callbackError, setCallbackError] = useState<string>();
|
||||
const callbackUrl = useRef<string | undefined>(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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<void> {
|
||||
await openAuthUrl(data.url);
|
||||
}
|
||||
|
||||
export async function completeAuthCallback(url: string): Promise<void> {
|
||||
const code = new URL(url).searchParams.get("code");
|
||||
async function exchangeAuthCallback(url: string): Promise<void> {
|
||||
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<void> {
|
||||
const { error } = await client.auth.signOut();
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
let lastCallback: { url: string; completion: Promise<void> } | undefined;
|
||||
export function completeAuthCallback(url: string): Promise<void> {
|
||||
if (lastCallback?.url === url) return lastCallback.completion;
|
||||
const completion = exchangeAuthCallback(url);
|
||||
lastCallback = { url, completion };
|
||||
return completion;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user