Address validated CodeRabbit findings for project saves and HUD lifecycle

This commit is contained in:
webadderall committed 2026-09-23 11:23:49 +10:00
1 parent 18c3824bb5
commit b7997e902b
39 files changed
+168 -66

No files matched your search

+1
View File
@@ -1014,6 +1014,7 @@ interface Window {
/** Countdown timer before recording */
getCountdownDelay: () => Promise<{ success: boolean; delay: number }>;
setCountdownDelay: (delay: number) => Promise<{ success: boolean; error?: string }>;
finishRecordingStartup: () => Promise<void>;
startCountdown: (seconds: number) => Promise<{ success: boolean; cancelled?: boolean }>;
cancelCountdown: () => Promise<{ success: boolean }>;
getActiveCountdown: () => Promise<{ success: boolean; seconds: number | null }>;
+9
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
getHudCaptureExcludedProcessIds,
shouldProtectHudCapture,
supportsHudCaptureProtection,
} from "../src/lib/hudCaptureProtection";
@@ -19,3 +20,11 @@ describe("HUD capture protection lifecycle", () => {
expect(getHudCaptureExcludedProcessIds("linux", true, 734)).toEqual([]);
});
});
it("protects initial capture frames while keeping idle and failed starts visible", () => {
expect(shouldProtectHudCapture(true, false, false)).toBe(false);
expect(shouldProtectHudCapture(true, false, true)).toBe(true);
expect(shouldProtectHudCapture(true, true, false)).toBe(true);
expect(shouldProtectHudCapture(true, false, false)).toBe(false);
expect(shouldProtectHudCapture(false, true, true)).toBe(false);
});
@@ -0,0 +1,17 @@
import { expect, it, vi } from "vitest";
import { persistRecentMetadata } from "./recentMetadata";
it("reports a metadata warning without rejecting an already committed operation", async () => {
const log = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const result = await persistRecentMetadata(async () => {
throw Error("disk full");
});
expect(result).toContain("operation completed");
expect(result).toContain("disk full");
expect(log).toHaveBeenCalledOnce();
log.mockRestore();
});
it("has no warning after a successful metadata update", async () => {
const update = vi.fn().mockResolvedValue(undefined);
await expect(persistRecentMetadata(update)).resolves.toBeUndefined();
expect(update).toHaveBeenCalledOnce();
});
+13
View File
@@ -0,0 +1,13 @@
/** Library history is secondary to a filesystem operation that already committed. */
export async function persistRecentMetadata(
action: () => Promise<void>,
): Promise<string | undefined> {
try {
await action();
return undefined;
} catch (error) {
const warning = `Project operation completed, but recent-project history could not be updated: ${String(error)}`;
console.warn(warning);
return warning;
}
}
+14 -9
View File
@@ -1,3 +1,4 @@
import { persistRecentMetadata } from "../project/recentMetadata";
import { renameLibraryProject } from "../project/renameLibraryProject";
import { createUntitledProject } from "../project/createUntitledProject";
import { trashLibraryProjects } from "../project/trashProjects";
@@ -45,6 +46,7 @@ import {
getTelemetryPathForVideo,
isAutoRecordingPath,
normalizeVideoSourcePath,
normalizePath,
parseJsonWithByteOrderMark,
} from "../utils";
@@ -227,9 +229,10 @@ export function registerProjectHandlers() {
entries.entries.map((entry) => entry.path),
(value) => [getProjectThumbnailPath(value), getProjectBackupPath(value)],
);
if (currentProjectPath === source) setCurrentProjectPath(target);
await rememberRecentProject(target);
return { success: true, path: target };
if (currentProjectPath && normalizePath(currentProjectPath) === normalizePath(source))
setCurrentProjectPath(target);
const warning = await persistRecentMetadata(() => rememberRecentProject(target));
return { success: true, path: target, warning };
} catch (error) {
return { success: false, error: String(error) };
}
@@ -711,13 +714,13 @@ export function registerProjectHandlers() {
JSON.stringify(prepared.projectData, null, 2),
);
setCurrentProjectPath(target);
await rememberRecentProject(target);
const warning = await persistRecentMetadata(() => rememberRecentProject(target));
try {
await saveProjectThumbnail(target, thumbnailDataUrl);
} catch (error) {
console.warn("Could not save project thumbnail", error);
}
return { success: true, path: target, projectId: prepared.projectId };
return { success: true, path: target, projectId: prepared.projectId, warning };
} catch (error) {
return { success: false, message: String(error) };
}
@@ -732,12 +735,14 @@ export function registerProjectHandlers() {
});
if (currentProjectPath && result.deleted.includes(path.resolve(currentProjectPath)))
setCurrentProjectPath(null);
await saveRecentProjectPaths(
(await loadRecentProjectPaths()).filter(
(p) => !result.deleted.includes(path.resolve(p)),
const warning = await persistRecentMetadata(async () =>
saveRecentProjectPaths(
(await loadRecentProjectPaths()).filter(
(p) => !result.deleted.includes(path.resolve(p)),
),
),
);
return { success: result.errors.length === 0, ...result };
return { success: result.errors.length === 0, ...result, warning };
} catch (error) {
return { success: false, deleted: [], errors: [String(error)] };
}
+3 -6
View File
@@ -14,10 +14,7 @@ import {
} from "electron";
import { getHudCaptureExcludedProcessIds } from "../../../src/lib/hudCaptureProtection";
import { showCursor } from "../../cursorHider";
import {
getHudOverlayCaptureProtectionEnabled,
reassertHudOverlayCaptureProtection,
} from "../../windows";
import { getHudOverlayCaptureProtectionEnabled, beginHudCaptureProtection } from "../../windows";
import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants";
import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bounds";
import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction";
@@ -404,8 +401,8 @@ export function registerRecordingHandlers(
ipcMain.handle(
"start-native-screen-recording",
async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => {
// Refresh window protection; it applies only while recording is active.
reassertHudOverlayCaptureProtection();
// Protect the HUD at the capture boundary, before the renderer publishes recording state.
beginHudCaptureProtection();
const visibleWindowBounds = source.id?.startsWith("window:")
? await bringSelectedWindowForward(source)
: null;
+8 -4
View File
@@ -55,7 +55,7 @@ import {
getUpdateToastWindow,
hideUpdateToastWindow,
isHudOverlayMousePassthroughSupported,
reassertHudOverlayCaptureProtection,
beginHudCaptureProtection,
reassertHudOverlayMousePassthrough as reassertHudOverlayMouseState,
setHudOverlayRecordingActive,
showUpdateToastWindow,
@@ -873,7 +873,9 @@ function createSourceSelectorWindowWrapper() {
app.on("before-quit", () => {
isAppQuitting = true;
authCallbacks.close();
void clearRecordingTrashUndo().catch((error) => console.warn("Could not clear recording undo cache", error));
void clearRecordingTrashUndo().catch((error) =>
console.warn("Could not clear recording undo cache", error),
);
killWindowsCaptureProcess();
showCursor();
cleanupNativeVideoExportSessions();
@@ -902,7 +904,9 @@ app.on("second-instance", (_event, commandLine) => {
app.whenReady().then(async () => {
authCallbacks.startDevServer();
if (process.defaultApp && process.argv[1]) {
app.setAsDefaultProtocolClient(authCallbacks.protocol, process.execPath, [path.resolve(process.argv[1])]);
app.setAsDefaultProtocolClient(authCallbacks.protocol, process.execPath, [
path.resolve(process.argv[1]),
]);
} else {
app.setAsDefaultProtocolClient(authCallbacks.protocol);
}
@@ -1105,7 +1109,7 @@ app.whenReady().then(async () => {
// Browser and Linux portal capture starts as soon as this callback
// resolves, before recording-state-changed is emitted.
reassertHudOverlayCaptureProtection();
beginHudCaptureProtection();
const sourceId = getSelectedSourceId();
// On Linux/Wayland, calling desktopCapturer.getSources() itself
+1
View File
@@ -1089,6 +1089,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
}) => ipcRenderer.invoke("set-recording-preferences", prefs),
getCountdownDelay: () => ipcRenderer.invoke("get-countdown-delay"),
setCountdownDelay: (delay: number) => ipcRenderer.invoke("set-countdown-delay", delay),
finishRecordingStartup: () => ipcRenderer.invoke("finish-recording-startup"),
startCountdown: (seconds: number) => ipcRenderer.invoke("start-countdown", seconds),
cancelCountdown: () => ipcRenderer.invoke("cancel-countdown"),
getActiveCountdown: () => ipcRenderer.invoke("get-active-countdown"),
+21 -2
View File
@@ -4,7 +4,10 @@ import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { app, BrowserWindow, ipcMain } from "electron";
import { supportsHudCaptureProtection } from "../src/lib/hudCaptureProtection";
import {
supportsHudCaptureProtection,
shouldProtectHudCapture,
} from "../src/lib/hudCaptureProtection";
import { USER_DATA_PATH } from "./appPaths";
import {
getHudOverlayWindowBounds,
@@ -36,6 +39,7 @@ let hudOverlayIgnoringMouse = true;
let hudOverlaySourceSelectionActive = false;
let hudOverlayMouseReassertTimer: NodeJS.Timeout | null = null;
let hudOverlayRecordingActive = false;
let hudCaptureStarting = false;
let hudOverlayWebcamPreviewVisible = false;
let countdownWindow: BrowserWindow | null = null;
let updateToastWindow: BrowserWindow | null = null;
@@ -155,12 +159,23 @@ function applyHudOverlayCaptureProtectionToWindow(hud: BrowserWindow, enabled: b
try {
// Keep the idle HUD visible to screenshots and other capture applications.
hud.setContentProtection(enabled && hudOverlayRecordingActive);
hud.setContentProtection(
shouldProtectHudCapture(enabled, hudOverlayRecordingActive, hudCaptureStarting),
);
} catch (error) {
console.warn("Failed to apply HUD capture protection:", error);
}
}
export function beginHudCaptureProtection(): void {
hudCaptureStarting = true;
reassertHudOverlayCaptureProtection();
}
ipcMain.handle("finish-recording-startup", () => {
hudCaptureStarting = false;
reassertHudOverlayCaptureProtection();
});
export function reassertHudOverlayCaptureProtection(): boolean {
const enabled = loadHudOverlayCaptureProtectionSetting();
const hud = getHudOverlayWindow();
@@ -650,6 +665,9 @@ export function createHudOverlayWindow(): BrowserWindow {
screen.removeListener("display-metrics-changed", handleDisplayMetricsChanged);
if (hudOverlayWindow === win) {
hudOverlayWindow = null;
recordingPreparationActive = false;
hudCaptureStarting = false;
hudOverlayRecordingActive = false;
}
});
@@ -703,6 +721,7 @@ export function reassertHudOverlayMousePassthrough(): void {
}
export function setHudOverlayRecordingActive(recording: boolean): void {
hudCaptureStarting = false;
hudOverlayRecordingActive = Boolean(recording);
notifyEditorMode();
hudOverlayFallbackExpanded = false;
+2 -2
View File
@@ -195,8 +195,8 @@ function LaunchWindowContent() {
variant="ghost"
size="icon"
iconSize="lg"
aria-label="Home"
title="Home"
aria-label={t("recording.home")}
title={t("recording.home")}
onClick={openHome}
>
<House weight="fill" className="size-5" />
+1 -1
View File
@@ -41,7 +41,7 @@ export function RecordingControls({
isIconOnly
variant="ghost"
className={actionClass}
aria-label="Home"
aria-label={t("recording.home")}
onPress={onHome}
>
<House weight="fill" className="size-4" />
@@ -30,7 +30,10 @@ describe("announcement configuration", () => {
<DashboardAnnouncements
config={{
enabled: true,
banners: [{ ...banner, href: "javascript:alert(1)" }, banner],
banners: [
{ ...banner, src: "/banner-1.svg", href: "javascript:alert(1)" },
{ ...banner, src: "/banner-2.svg" },
],
}}
/>,
);
@@ -36,6 +36,7 @@ export function DashboardAnnouncements({
{href ? (
<Link
href={href}
onAuxClick={(event) => event.preventDefault()}
className="block w-full"
onClick={(event) => {
event.preventDefault();
@@ -67,7 +68,7 @@ export function DashboardAnnouncements({
>
{banners.map((banner, slide) => (
<Button
key={banner.src}
key={`${slide}:${banner.src}`}
isIconOnly
variant="ghost"
aria-label={`Announcement ${slide + 1}`}
@@ -4,7 +4,7 @@ import VideoPlayback, { type VideoPlaybackRef } from "../VideoPlayback";
import { normalizeProjectEditor } from "../projectPersistence";
import type { CursorTelemetryPoint } from "../types";
const ignore = () => {};
const ignore = () => undefined;
/** One muted five-second pass through the saved timeline using the editor renderer. */
export function ProjectHoverPreview({
data,
@@ -32,7 +32,7 @@ export function ProjectThumbnail({
.then((result) => {
if (active && !document.hidden && result.success) setPreview(result.value);
})
.catch(() => {});
.catch(() => undefined);
}, 300);
const observer = new IntersectionObserver((entries) => {
if (entries.every((entry) => !entry.isIntersecting)) {
@@ -15,7 +15,7 @@ export function RawThumbnail({ entry, active }: { entry: RecordingLibraryEntry;
.then((result) => {
if (active && result.success) setPoster(result.value);
})
.catch(() => {});
.catch(() => undefined);
return () => {
active = false;
};
@@ -30,7 +30,7 @@ export function RawThumbnail({ entry, active }: { entry: RecordingLibraryEntry;
}
const timer = window.setTimeout(() => {
element.currentTime = 0;
void element.play().catch(() => {});
void element.play().catch(() => undefined);
}, 300);
const stop = () => {
if (document.hidden) element.pause();
@@ -21,6 +21,7 @@ export function useDashboardModel({
setSelected((prev) =>
prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path],
);
const { folders, save } = useProjectFolders();
const assignFolder = (path: string, id: string) =>
save(
folders.map((f) => ({
@@ -52,7 +53,6 @@ export function useDashboardModel({
};
const libraryEntries = isRaw ? raw.entries : entries;
const [busy, setBusy] = useState(false);
const { folders, save } = useProjectFolders();
const visible = useMemo(
() =>
libraryEntries
@@ -42,6 +42,7 @@ export function useProjectFolders() {
try {
localStorage.setItem(KEY, JSON.stringify(next));
setFolders(next);
window.dispatchEvent(new CustomEvent("recordly-folders-changed", { detail: next }));
} catch {
toast.error("Could not save folders");
}
@@ -7,7 +7,7 @@ import { useRecordingLibrary } from "../library/useRecordingLibrary";
import { RecordingLibraryPanel } from "../library/RecordingLibraryPanel";
import { RECORDING_DRAG_TYPE } from "@/types/recordingLibrary";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react";
import { useCallback, useMemo, useEffect, useRef, useState, type ComponentProps } from "react";
import { EditorAnnouncementBanner } from "@/components/announcements/EditorAnnouncementBanner";
import { Toaster } from "@/components/ui/toast";
import type { useI18n } from "@/contexts/I18nContext";
@@ -133,19 +133,21 @@ export function EditorShell(props: Props) {
handleAutoSuggestZoomsConsumed,
} = editing;
const { dialogActions, status: exportStatus, exportMessage } = exportController;
const dashboardSettingsContent = useMemo(
() => (
<SettingsPanel
{...settingsPanelProps}
activeEffectSection="settings"
selectedAnnotationId={null}
selectedClipId={null}
advanced
/>
),
[settingsPanelProps],
);
const editorDialogs = (
<AccountProfileContext.Provider value={auth.user}>
<DashboardSettingsContext.Provider
value={
<SettingsPanel
{...settingsPanelProps}
activeEffectSection="settings"
selectedAnnotationId={null}
selectedClipId={null}
advanced
/>
}
>
<DashboardSettingsContext.Provider value={dashboardSettingsContent}>
<EditorDialogs
t={t}
projectSaveDialogOpen={project.projectSaveDialogOpen}
@@ -112,8 +112,8 @@ export function useProjectLibraryController({
}
const canvas = document.createElement("canvas");
const targetWidth = 1600;
const targetHeight = 1200;
const targetWidth = 640;
const targetHeight = 480;
canvas.width = targetWidth;
canvas.height = targetHeight;
@@ -180,12 +180,9 @@ export function useProjectOpenActions({
}
videoPlaybackRef.current?.pause();
setIsPlaying(false);
if (
project.videoPath &&
!project.error &&
!(await saveProject(false, { remountPreviewAfterSave: false }))
)
return;
if (project.videoPath && !project.error) {
await saveProject(false, { remountPreviewAfterSave: false });
}
project.setProjectBrowserOpen(true);
void refreshProjectLibrary();
}, [
@@ -236,14 +233,17 @@ export function useProjectOpenActions({
},
[project, refreshProjectLibrary],
);
const handleRenameLibraryProject = async (path: string, name: string) => {
const result = await window.electronAPI.renameLibraryProject(path, name);
if (!result.success || !result.path)
throw new Error(result.error || "Could not rename project");
if (project.currentProjectPath === path) project.setCurrentProjectPath(result.path);
await refreshProjectLibrary();
return result.path;
};
const handleRenameLibraryProject = useCallback(
async (path: string, name: string) => {
const result = await window.electronAPI.renameLibraryProject(path, name);
if (!result.success || !result.path)
throw new Error(result.error || "Could not rename project");
if (project.currentProjectPath === path) project.setCurrentProjectPath(result.path);
await refreshProjectLibrary();
return result.path;
},
[project, refreshProjectLibrary],
);
return {
handleRenameLibraryProject,
@@ -1,6 +1,6 @@
import { moveProjectFolderReferences } from "../dashboard/useProjectFolders";
import { moveProjectShareLink } from "../cloud/projectShareLinks";
import { type RefObject, useCallback, useEffect, useRef } from "react";
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { toast } from "@/components/ui/toast";
import { createProjectData, type EditorProjectData } from "../projectPersistence";
import type { useProjectState } from "../state/useProjectState";
@@ -61,9 +61,13 @@ export function useProjectSaveActions({
} = project;
const savingNameRef = useRef(false);
const activePathRef = useRef(currentProjectPath);
activePathRef.current = currentProjectPath;
const activeSourceRef = useRef(currentSourcePath);
activeSourceRef.current = currentSourcePath;
useLayoutEffect(() => {
activePathRef.current = currentProjectPath;
}, [currentProjectPath]);
useLayoutEffect(() => {
activeSourceRef.current = currentSourcePath;
}, [currentSourcePath]);
const autosaveTimeoutRef = useRef<number | null>(null);
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const clearPendingAutosave = useCallback(() => {
@@ -41,13 +41,12 @@ export function useProjectSnapshotModel({
[project.videoPath, project.videoSourcePath],
);
const projectDisplayName = useMemo(() => {
const fileName =
project.currentProjectPath?.split(/[\\/]/).pop() ?? "Untitled Project";
const fileName = project.currentProjectPath?.split(/[\\/]/).pop() ?? "Untitled Project";
return (
fileName.replace(/\.recordly$/i, "").replace(/\.[^.]+$/, "") ||
t("editor.project.untitled", "Untitled Project")
);
}, [project.currentProjectPath, currentSourcePath, t]);
}, [project.currentProjectPath, t]);
useEffect(() => {
if (!project.isEditingProjectName) project.setProjectNameDraft(projectDisplayName);
+5
View File
@@ -2273,6 +2273,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
await stopWebcamRecorder();
}
} finally {
try {
await window.electronAPI.finishRecordingStartup();
} catch (error) {
console.warn("Failed to release recording startup protection:", error);
}
setHudSourceSelectionActive(false);
startInFlight.current = false;
setStarting(false);
+1
View File
@@ -1,5 +1,6 @@
{
"recording": {
"home": "Startseite",
"disableSystemAudio": "System-Audio deaktivieren",
"enableSystemAudio": "System-Audio aktivieren",
"disableMicrophone": "Mikrofon deaktivieren",
+1
View File
@@ -1,5 +1,6 @@
{
"recording": {
"home": "Home",
"disableSystemAudio": "Disable system audio",
"enableSystemAudio": "Enable system audio",
"disableMicrophone": "Disable microphone",
+1
View File
@@ -1,5 +1,6 @@
{
"recording": {
"home": "Inicio",
"disableSystemAudio": "Desactivar audio del sistema",
"enableSystemAudio": "Activar audio del sistema",
"disableMicrophone": "Desactivar micrófono",
+1
View File
@@ -1,5 +1,6 @@
{
"recording": {
"home": "Accueil",
"disableSystemAudio": "Désactiver l’audio système",
"enableSystemAudio": "Activer l’audio système",
"disableMicrophone": "Désactiver le microphone",
+1
View File
@@ -1,5 +1,6 @@
{
"recording": {
"home": "Home",
"disableSystemAudio": "Disabilita audio di sistema",
"enableSystemAudio": "Abilita audio di sistema",
"disableMicrophone": "Disabilita microfono",
+1
View File
@@ -1,5 +1,6 @@
{
"recording": {
"home": "홈",
"disableSystemAudio": "시스템 오디오 끄기",
"enableSystemAudio": "시스템 오디오 켜기",
"disableMicrophone": "마이크 끄기",
+1
View File
@@ -1,5 +1,6 @@
{
"recording": {
"home": "Start",
"disableSystemAudio": "Systeemaudio uitschakelen",
"enableSystemAudio": "Systeemaudio inschakelen",
"disableMicrophone": "Microfoon uitschakelen",
+1
View File
@@ -1,5 +1,6 @@
{
"recording": {
"home": "Início",
"disableSystemAudio": "Desativar áudio do sistema",
"enableSystemAudio": "Ativar áudio do sistema",
"disableMicrophone": "Desativar microfone",
+1
View File
@@ -1,5 +1,6 @@
{
"recording": {
"home": "Главная",
"disableSystemAudio": "Отключить системные звуки",
"enableSystemAudio": "Включить системные звуки",
"disableMicrophone": "Отключить микрофон",
+1
View File
@@ -1,5 +1,6 @@
{
"recording": {
"home": "主页",
"disableSystemAudio": "禁用系统音频",
"enableSystemAudio": "启用系统音频",
"disableMicrophone": "禁用麦克风",
+1
View File
@@ -1,5 +1,6 @@
{
"recording": {
"home": "首頁",
"disableSystemAudio": "停用系統音訊",
"enableSystemAudio": "啟用系統音訊",
"disableMicrophone": "停用麥克風",
+1
View File
@@ -188,6 +188,7 @@
color: inherit;
}
.inline-project-name:focus { outline: none; box-shadow: none; }
.inline-project-name:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.dashboard-shell {
background-color: color-mix(in srgb, var(--background) 92%, var(--foreground) 8%);
+8
View File
@@ -13,3 +13,11 @@ export function getHudCaptureExcludedProcessIds(
return [processId];
}
export function shouldProtectHudCapture(
enabled: boolean,
recording: boolean,
starting: boolean,
): boolean {
return enabled && (recording || starting);
}
+1
View File
@@ -12,6 +12,7 @@ export async function installDesktopBridge(page: Page, videoFixture = "preview.m
Object.assign(window, {
electronAPI: {
getAppSetting: () => null,
finishRecordingStartup: async () => undefined,
showProjectDashboard: async () => {
document.documentElement.dataset.dashboardOpened = "true";
},
+1 -1
View File
@@ -33,7 +33,7 @@ test("recording HUD uses uniform controls and a readable timer", async ({ page }
await installDesktopBridge(page);
await page.addInitScript(() => {
window.electronAPI.onRecordingStateChanged = (callback) => {
const listener = () => callback({ recording: true });
const listener = () => callback({ recording: true, sourceName: "Built-in Display" });
window.addEventListener("test-recording-started", listener);
return () => window.removeEventListener("test-recording-started", listener);
};