diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 55b4594f..5409fcbe 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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; startCountdown: (seconds: number) => Promise<{ success: boolean; cancelled?: boolean }>; cancelCountdown: () => Promise<{ success: boolean }>; getActiveCountdown: () => Promise<{ success: boolean; seconds: number | null }>; diff --git a/electron/hudCaptureProtection.test.ts b/electron/hudCaptureProtection.test.ts index 30b0e2b9..28f80722 100644 --- a/electron/hudCaptureProtection.test.ts +++ b/electron/hudCaptureProtection.test.ts @@ -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); +}); diff --git a/electron/ipc/project/recentMetadata.test.ts b/electron/ipc/project/recentMetadata.test.ts new file mode 100644 index 00000000..c20f49de --- /dev/null +++ b/electron/ipc/project/recentMetadata.test.ts @@ -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(); +}); diff --git a/electron/ipc/project/recentMetadata.ts b/electron/ipc/project/recentMetadata.ts new file mode 100644 index 00000000..174bf365 --- /dev/null +++ b/electron/ipc/project/recentMetadata.ts @@ -0,0 +1,13 @@ +/** Library history is secondary to a filesystem operation that already committed. */ +export async function persistRecentMetadata( + action: () => Promise, +): Promise { + 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; + } +} diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 2f974c6b..27767ba6 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -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)] }; } diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 2262da37..797f8bad 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -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; diff --git a/electron/main.ts b/electron/main.ts index 915873c2..417d4a05 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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 diff --git a/electron/preload.ts b/electron/preload.ts index cbba00ff..ddfadbfa 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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"), diff --git a/electron/windows.ts b/electron/windows.ts index 719e39aa..ddcc1c2d 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -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; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 1e92d63b..634fec26 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -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} > diff --git a/src/components/launch/RecordingControls.tsx b/src/components/launch/RecordingControls.tsx index 2e0f9b57..2636b123 100644 --- a/src/components/launch/RecordingControls.tsx +++ b/src/components/launch/RecordingControls.tsx @@ -41,7 +41,7 @@ export function RecordingControls({ isIconOnly variant="ghost" className={actionClass} - aria-label="Home" + aria-label={t("recording.home")} onPress={onHome} > diff --git a/src/components/video-editor/dashboard/DashboardAnnouncements.test.tsx b/src/components/video-editor/dashboard/DashboardAnnouncements.test.tsx index ca0e1c08..7401c34f 100644 --- a/src/components/video-editor/dashboard/DashboardAnnouncements.test.tsx +++ b/src/components/video-editor/dashboard/DashboardAnnouncements.test.tsx @@ -30,7 +30,10 @@ describe("announcement configuration", () => { , ); diff --git a/src/components/video-editor/dashboard/DashboardAnnouncements.tsx b/src/components/video-editor/dashboard/DashboardAnnouncements.tsx index 81450cc0..7bbd3b1b 100644 --- a/src/components/video-editor/dashboard/DashboardAnnouncements.tsx +++ b/src/components/video-editor/dashboard/DashboardAnnouncements.tsx @@ -36,6 +36,7 @@ export function DashboardAnnouncements({ {href ? ( event.preventDefault()} className="block w-full" onClick={(event) => { event.preventDefault(); @@ -67,7 +68,7 @@ export function DashboardAnnouncements({ > {banners.map((banner, slide) => (