From fb447035142d9c625b1aa6145c8e63ef6bb2f12a Mon Sep 17 00:00:00 2001 From: young Date: Fri, 28 Aug 2026 09:54:28 +1000 Subject: [PATCH] Apply consistent project formatting --- electron/ipc/captions/whisper.ts | 10 +- electron/ipc/cursor/bounds.ts | 16 +- electron/ipc/cursor/interaction.test.ts | 15 +- electron/ipc/cursor/telemetry.ts | 21 +- electron/ipc/ffmpeg/filters.ts | 9 +- electron/ipc/monitorResolver.ts | 14 +- electron/ipc/paths/binaries.ts | 11 +- electron/ipc/project/session.ts | 9 +- electron/ipc/recording/diagnostics.ts | 8 +- electron/ipc/recording/ffmpeg.ts | 9 +- electron/ipc/recording/prune.ts | 11 +- electron/ipc/recording/windows.ts | 12 +- electron/ipc/register/assets.ts | 206 +-- electron/ipc/register/export.test.ts | 4 +- electron/ipc/register/export.ts | 11 +- .../register/exportCaptionSidecars.test.ts | 6 +- .../ipc/register/exportCaptionSidecars.ts | 2 +- electron/ipc/register/permissions.ts | 144 +- electron/ipc/register/project.ts | 1169 ++++++++-------- electron/ipc/register/recording.ts | 636 ++++----- electron/ipc/register/sourceMapping.test.ts | 7 +- electron/ipc/register/sourceMapping.ts | 2 +- electron/ipc/register/sources.ts | 96 +- electron/ipc/utils.ts | 1 - .../bin/win32-x64/helpers-manifest.json | 66 +- .../render-tahoe-cursor-atlas.cjs | 94 +- electron/navigationPolicy.test.ts | 8 +- electron/permissionPolicy.test.ts | 23 +- scripts/benchmark-export-queues.mjs | 11 +- scripts/build-windows-capture.mjs | 13 +- scripts/build-windows-gpu-export.mjs | 17 +- scripts/create-release.mjs | 9 +- scripts/normalize-electron-main-cjs.mjs | 14 +- src/components/launch/hooks/useHudBarDrag.ts | 218 +-- .../launch/hooks/useWebcamPreviewOverlay.ts | 49 +- .../popovers/LaunchPopoverCoordinator.tsx | 14 +- src/components/launch/popovers/MicPopover.tsx | 15 +- .../launch/popovers/PopoverScaffold.tsx | 4 +- .../launch/popovers/WebcamPopover.tsx | 29 +- src/components/ui/button.tsx | 1 - src/components/ui/separator.tsx | 5 +- .../video-editor/AnnotationOverlay.tsx | 4 +- .../video-editor/AnnotationSettingsPanel.tsx | 1242 +++++++++-------- .../video-editor/ExportSettingsMenu.tsx | 41 +- src/components/video-editor/ExtensionIcon.tsx | 4 +- .../video-editor/GifOptionsPanel.tsx | 4 +- .../video-editor/KeyboardShortcutsHelp.tsx | 7 +- src/components/video-editor/TutorialHelp.tsx | 126 +- .../audio/audioResourceVersion.test.ts | 10 +- .../video-editor/audio/audioTypes.ts | 1 - .../video-editor/audio/clipAudio.ts | 18 +- .../video-editor/audio/useAudioPreviewSync.ts | 859 ++++++------ .../audio/useSourceAudioFallback.ts | 131 +- .../video-editor/audio/useVideoEditorAudio.ts | 6 +- .../audio/waveform/WaveformGenerator.ts | 42 +- .../audio/waveform/waveform.worker.ts | 4 +- .../video-editor/clipSpeedChange.test.ts | 64 +- .../video-editor/clipSpeedChange.ts | 4 +- src/components/video-editor/editorHistory.ts | 4 +- .../video-editor/mp4ExportRouting.ts | 6 +- .../video-editor/mp4ExportSettings.ts | 5 +- .../video-editor/smokeExportConfig.test.ts | 4 +- .../video-editor/smokeExportConfig.ts | 12 +- .../timeline/components/axis/TimelineAxis.tsx | 4 +- .../components/playhead/PlaybackCursor.tsx | 39 +- .../components/toolbar/TimelineToolbar.tsx | 117 +- .../hooks/actions/useTimelineAudioActions.ts | 6 +- .../hooks/actions/useTimelineZoomActions.ts | 4 +- .../timeline/hooks/useTimelineSelection.ts | 5 +- .../hooks/utils/timelineAudioPlacement.ts | 5 +- .../hooks/utils/timelineNotifications.ts | 3 +- .../timeline/model/timelineModel.test.ts | 63 +- src/components/video-editor/types.test.ts | 12 +- .../video-editor/useNvidiaCudaExportOptIn.ts | 18 +- .../videoPlayback/cursorFollowCamera.test.ts | 7 +- .../videoPlayback/motionSmoothing.ts | 90 +- .../videoPlayback/uploadedCursorAssets.ts | 3 +- .../videoPlayback/webcamSync.test.ts | 32 +- src/contexts/ThemeContext.test.ts | 5 +- src/contexts/ThemeContext.tsx | 12 +- src/i18n/config.ts | 13 +- src/i18n/locales/de/common.json | 42 +- src/i18n/locales/de/dialogs.json | 96 +- src/i18n/locales/de/editor.json | 234 ++-- src/i18n/locales/de/extensions.json | 102 +- src/i18n/locales/de/launch.json | 110 +- src/i18n/locales/de/settings.json | 338 ++--- src/i18n/locales/de/shortcuts.json | 26 +- src/i18n/locales/de/timeline.json | 68 +- src/i18n/locales/en/extensions.json | 2 +- src/i18n/locales/pt-BR/extensions.json | 2 +- src/i18n/locales/ru/common.json | 2 +- src/i18n/locales/ru/dialogs.json | 2 +- src/i18n/locales/ru/editor.json | 2 +- src/i18n/locales/ru/extensions.json | 2 +- src/i18n/locales/ru/launch.json | 2 +- src/i18n/locales/ru/shortcuts.json | 2 +- src/i18n/locales/ru/timeline.json | 2 +- src/i18n/locales/zh-TW/common.json | 2 +- src/i18n/locales/zh-TW/dialogs.json | 2 +- src/i18n/locales/zh-TW/extensions.json | 2 +- src/i18n/locales/zh-TW/launch.json | 18 +- src/i18n/locales/zh-TW/shortcuts.json | 2 +- src/i18n/locales/zh-TW/timeline.json | 2 +- src/lib/exporter/annotationRenderer.ts | 7 +- src/lib/exporter/audioRoutingEngine.ts | 4 +- src/lib/exporter/mediaResource.test.ts | 2 +- src/lib/exporter/mediaResource.ts | 2 +- src/lib/exporter/modernFrameRenderer.test.ts | 14 +- src/lib/exporter/sourceTrackRoutingPolicy.ts | 5 +- src/lib/exporter/streamingDecoder.test.ts | 4 +- src/lib/exporter/temporalMotionBlur.test.ts | 1 - src/lib/pixiApplicationLifecycle.ts | 4 +- src/lib/wallpapers.ts | 5 +- 114 files changed, 3803 insertions(+), 3362 deletions(-) diff --git a/electron/ipc/captions/whisper.ts b/electron/ipc/captions/whisper.ts index c8e774c6..67b04cd5 100644 --- a/electron/ipc/captions/whisper.ts +++ b/electron/ipc/captions/whisper.ts @@ -3,7 +3,11 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import { get as httpsGet } from "node:https"; import type Electron from "electron"; -import { WHISPER_MODEL_DIR, WHISPER_MODEL_DOWNLOAD_URL, WHISPER_SMALL_MODEL_PATH } from "../constants"; +import { + WHISPER_MODEL_DIR, + WHISPER_MODEL_DOWNLOAD_URL, + WHISPER_SMALL_MODEL_PATH, +} from "../constants"; export function sendWhisperModelDownloadProgress( webContents: Electron.WebContents, @@ -106,7 +110,9 @@ export function downloadFileWithProgress( return request(url); } -export async function downloadWhisperSmallModel(webContents: Electron.WebContents): Promise { +export async function downloadWhisperSmallModel( + webContents: Electron.WebContents, +): Promise { await fs.mkdir(WHISPER_MODEL_DIR, { recursive: true }); const tempPath = `${WHISPER_SMALL_MODEL_PATH}.download`; diff --git a/electron/ipc/cursor/bounds.ts b/electron/ipc/cursor/bounds.ts index 02fb3c75..fbc7d2d5 100644 --- a/electron/ipc/cursor/bounds.ts +++ b/electron/ipc/cursor/bounds.ts @@ -119,7 +119,9 @@ export function parseXwininfoBounds(stdout: string): WindowBounds | null { }; } -export async function resolveLinuxWindowBounds(source: SelectedSource): Promise { +export async function resolveLinuxWindowBounds( + source: SelectedSource, +): Promise { const windowId = parseWindowId(source?.id); if (windowId) { @@ -153,7 +155,9 @@ export async function resolveLinuxWindowBounds(source: SelectedSource): Promise< } } -export async function resolveWindowsWindowBounds(source: SelectedSource): Promise { +export async function resolveWindowsWindowBounds( + source: SelectedSource, +): Promise { const windowId = parseWindowId(source?.id); const windowTitle = typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim(); @@ -259,7 +263,9 @@ export function startWindowBoundsCapture() { } void refreshSelectedWindowBounds(); - setWindowBoundsCaptureInterval(setInterval(() => { - void refreshSelectedWindowBounds(); - }, 250)); + setWindowBoundsCaptureInterval( + setInterval(() => { + void refreshSelectedWindowBounds(); + }, 250), + ); } diff --git a/electron/ipc/cursor/interaction.test.ts b/electron/ipc/cursor/interaction.test.ts index f126f6e3..4ea66241 100644 --- a/electron/ipc/cursor/interaction.test.ts +++ b/electron/ipc/cursor/interaction.test.ts @@ -32,7 +32,9 @@ describe("repairBundledUiohookBinaryForCurrentArch", () => { afterEach(async () => { await Promise.all( - tempRoots.splice(0).map((tempRoot) => fs.rm(tempRoot, { recursive: true, force: true })), + tempRoots + .splice(0) + .map((tempRoot) => fs.rm(tempRoot, { recursive: true, force: true })), ); }); @@ -50,9 +52,14 @@ describe("repairBundledUiohookBinaryForCurrentArch", () => { const log = vi.fn(); const repaired = repairBundledUiohookBinaryForCurrentArch( - Object.assign(new Error("mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64')"), { - code: "ERR_DLOPEN_FAILED", - }), + Object.assign( + new Error( + "mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64')", + ), + { + code: "ERR_DLOPEN_FAILED", + }, + ), { packageRoot, platform: "darwin", arch: "arm64", log }, ); diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index ebedfe72..fd598b10 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -91,11 +91,7 @@ export async function writeCursorTelemetry(videoPath: string, samples: unknown) await fs.writeFile( telemetryPath, - JSON.stringify( - { version: CURSOR_TELEMETRY_VERSION, samples: normalizedSamples }, - null, - 2, - ), + JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: normalizedSamples }, null, 2), "utf-8", ); @@ -144,9 +140,7 @@ export function resumeCursorCapture(resumedAtMs: number) { } const pauseDurationMs = Math.max(0, resumedAtMs - cursorCapturePauseStartedAtMs); - setCursorCaptureAccumulatedPausedMs( - cursorCaptureAccumulatedPausedMs + pauseDurationMs, - ); + setCursorCaptureAccumulatedPausedMs(cursorCaptureAccumulatedPausedMs + pauseDurationMs); setCursorCapturePauseStartedAtMs(null); } @@ -217,7 +211,16 @@ export function getNormalizedCursorPoint() { } export function getHookCursorScreenPoint( - event: { x?: number; y?: number; data?: { x?: number; y?: number; screenX?: number; screenY?: number }; screenX?: number; screenY?: number } | null | undefined, + event: + | { + x?: number; + y?: number; + data?: { x?: number; y?: number; screenX?: number; screenY?: number }; + screenX?: number; + screenY?: number; + } + | null + | undefined, ): { x: number; y: number } | null { const rawX = event?.x ?? event?.data?.x ?? event?.screenX ?? event?.data?.screenX; const rawY = event?.y ?? event?.data?.y ?? event?.screenY ?? event?.data?.screenY; diff --git a/electron/ipc/ffmpeg/filters.ts b/electron/ipc/ffmpeg/filters.ts index 7e437525..2d3370c7 100644 --- a/electron/ipc/ffmpeg/filters.ts +++ b/electron/ipc/ffmpeg/filters.ts @@ -114,11 +114,10 @@ export function appendSyncedAudioFilter( filters.push(`adelay=${adjustment.delayMs}|${adjustment.delayMs}`); } - if ( - adjustment.mode === "delay" && - adjustment.durationDeltaMs > adjustment.delayMs + 20 - ) { - filters.push(`apad=pad_dur=${formatFfmpegSeconds(adjustment.durationDeltaMs - adjustment.delayMs)}`); + if (adjustment.mode === "delay" && adjustment.durationDeltaMs > adjustment.delayMs + 20) { + filters.push( + `apad=pad_dur=${formatFfmpegSeconds(adjustment.durationDeltaMs - adjustment.delayMs)}`, + ); } if (adjustment.mode === "tempo") { diff --git a/electron/ipc/monitorResolver.ts b/electron/ipc/monitorResolver.ts index e71f36c1..f14bbd38 100644 --- a/electron/ipc/monitorResolver.ts +++ b/electron/ipc/monitorResolver.ts @@ -13,7 +13,7 @@ export interface WinMonitorHandle { /** * Retrieves raw HMONITOR handles from the Windows OS using a PowerShell bridge. - * This is necessary because Electron's display IDs are often internal hashes that + * This is necessary because Electron's display IDs are often internal hashes that * cannot be used directly with native Windows APIs like Graphics Capture (WGC). */ export function getMonitorHandles(): WinMonitorHandle[] { @@ -53,10 +53,14 @@ public class MonitorHelper { [MonitorHelper]::GetMonitors() `.trim(); - const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", psScript], { - encoding: "utf-8", - timeout: 5000, - }); + const result = spawnSync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", psScript], + { + encoding: "utf-8", + timeout: 5000, + }, + ); if (result.error || result.status !== 0) { // Silent failure is preferred; the caller will fall back to coordinate-based matching. diff --git a/electron/ipc/paths/binaries.ts b/electron/ipc/paths/binaries.ts index 3e15f332..04bc577c 100644 --- a/electron/ipc/paths/binaries.ts +++ b/electron/ipc/paths/binaries.ts @@ -4,10 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { app } from "electron"; -import { - nativeHelperMigrationPromise, - setNativeHelperMigrationPromise, -} from "../state"; +import { nativeHelperMigrationPromise, setNativeHelperMigrationPromise } from "../state"; const execFileAsync = promisify(execFile); @@ -131,7 +128,11 @@ export function getCursorMonitorExePath(): string { async function migrateLegacyNativeHelperBinaries(): Promise { const legacyToCurrentPaths: Array<[string, string]> = [ [ - path.join(app.getPath("userData"), "native-tools", "openscreen-screencapturekit-helper"), + path.join( + app.getPath("userData"), + "native-tools", + "openscreen-screencapturekit-helper", + ), getNativeCaptureHelperBinaryPath(), ], [ diff --git a/electron/ipc/project/session.ts b/electron/ipc/project/session.ts index 3c126e6d..d5f83a83 100644 --- a/electron/ipc/project/session.ts +++ b/electron/ipc/project/session.ts @@ -15,7 +15,9 @@ export function getRecordingSessionManifestPath(videoPath: string) { return path.join(path.dirname(videoPath), `${baseName}${RECORDING_SESSION_MANIFEST_SUFFIX}`); } -export async function persistRecordingSessionManifest(session: RecordingSessionData): Promise { +export async function persistRecordingSessionManifest( + session: RecordingSessionData, +): Promise { const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath); if (!normalizedVideoPath) { return; @@ -51,8 +53,7 @@ export async function resolveRecordingSessionManifest( try { const content = await fs.readFile(manifestPath, "utf-8"); - const parsed = - parseJsonWithByteOrderMark>(content); + const parsed = parseJsonWithByteOrderMark>(content); if (parsed.version !== 1 && parsed.version !== 2) { return null; } @@ -138,5 +139,3 @@ export async function resolveRecordingSession( webcamPath: linkedWebcamPath, }; } - - diff --git a/electron/ipc/recording/diagnostics.ts b/electron/ipc/recording/diagnostics.ts index edf5f638..985209f2 100644 --- a/electron/ipc/recording/diagnostics.ts +++ b/electron/ipc/recording/diagnostics.ts @@ -201,9 +201,7 @@ export async function probeMediaDurationSeconds(filePath: string): Promise((resolve, reject) => { const onClose = async (code: number | null) => { cleanup(); diff --git a/electron/ipc/recording/prune.ts b/electron/ipc/recording/prune.ts index d8004bd1..29787e8e 100644 --- a/electron/ipc/recording/prune.ts +++ b/electron/ipc/recording/prune.ts @@ -82,10 +82,13 @@ async function loadSavedProjectMediaPaths() { editor?: { webcam?: { sourcePath?: unknown } }; }>(await fs.readFile(projectPath, "utf-8")); } catch (error) { - console.warn("[prune] Aborting recording prune because a saved project is unreadable", { - projectPath, - error, - }); + console.warn( + "[prune] Aborting recording prune because a saved project is unreadable", + { + projectPath, + error, + }, + ); throw error; } const candidatePaths = [ diff --git a/electron/ipc/recording/windows.ts b/electron/ipc/recording/windows.ts index 262d26da..650cdca9 100644 --- a/electron/ipc/recording/windows.ts +++ b/electron/ipc/recording/windows.ts @@ -13,9 +13,7 @@ import { windowsCaptureTargetPath, windowsNativeCaptureActive, } from "../state"; -import { - AudioSyncAdjustment, -} from "../types"; +import { AudioSyncAdjustment } from "../types"; import { moveFileWithOverwrite } from "../utils"; import { emitRecordingInterrupted } from "./events"; @@ -135,7 +133,9 @@ export function waitForWindowsCaptureStop( const onClose = (code: number | null) => { finish(() => { - const match = windowsCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/); + const match = windowsCaptureOutputBuffer.match( + /Recording stopped\. Output path: (.+)/, + ); if (match?.[1]) { resolve(match[1].trim()); return; @@ -254,9 +254,7 @@ export async function muxNativeWindowsVideoWithAudio( } } - console.log( - `[PERF:MAIN] muxNativeWindowsVideoWithAudio: COMPLETED in ${Date.now() - start}ms`, - ); + console.log(`[PERF:MAIN] muxNativeWindowsVideoWithAudio: COMPLETED in ${Date.now() - start}ms`); return { muxed: false, diff --git a/electron/ipc/register/assets.ts b/electron/ipc/register/assets.ts index f18bf7b6..fae8d708 100644 --- a/electron/ipc/register/assets.ts +++ b/electron/ipc/register/assets.ts @@ -8,120 +8,122 @@ import { normalizePath } from "../utils"; import { getAssetRootPath } from "../project/manager"; export function registerAssetHandlers() { - async function resolveReadableLocalFilePath(filePath: string) { - const normalizedPath = normalizePath(filePath) - const resolvedPath = await fs.realpath(normalizedPath).catch(() => normalizedPath) - const stats = await fs.stat(resolvedPath) - if (!stats.isFile()) { - throw new Error('Path is not a readable file') - } - return normalizePath(resolvedPath) - } + async function resolveReadableLocalFilePath(filePath: string) { + const normalizedPath = normalizePath(filePath); + const resolvedPath = await fs.realpath(normalizedPath).catch(() => normalizedPath); + const stats = await fs.stat(resolvedPath); + if (!stats.isFile()) { + throw new Error("Path is not a readable file"); + } + return normalizePath(resolvedPath); + } - // Generate a tiny thumbnail for a wallpaper image and cache it in userData. - // Returns the cached thumbnail as raw JPEG bytes for fast grid rendering. - // Serialized to prevent concurrent nativeImage operations from eating memory. - const THUMB_SIZE = 96 - const thumbCacheDir = path.join(USER_DATA_PATH, 'wallpaper-thumbs') - let thumbGenerationQueue: Promise = Promise.resolve() + // Generate a tiny thumbnail for a wallpaper image and cache it in userData. + // Returns the cached thumbnail as raw JPEG bytes for fast grid rendering. + // Serialized to prevent concurrent nativeImage operations from eating memory. + const THUMB_SIZE = 96; + const thumbCacheDir = path.join(USER_DATA_PATH, "wallpaper-thumbs"); + let thumbGenerationQueue: Promise = Promise.resolve(); - ipcMain.handle('generate-wallpaper-thumbnail', async (_, filePath: string) => { - try { - const resolved = await resolveReadableLocalFilePath(filePath) + ipcMain.handle("generate-wallpaper-thumbnail", async (_, filePath: string) => { + try { + const resolved = await resolveReadableLocalFilePath(filePath); - // Deterministic cache key from file path + mtime - const stat = await fs.stat(resolved) - const cacheKey = Buffer.from(`${resolved}:${stat.mtimeMs}`).toString('base64url') - const thumbPath = path.join(thumbCacheDir, `${cacheKey}.jpg`) + // Deterministic cache key from file path + mtime + const stat = await fs.stat(resolved); + const cacheKey = Buffer.from(`${resolved}:${stat.mtimeMs}`).toString("base64url"); + const thumbPath = path.join(thumbCacheDir, `${cacheKey}.jpg`); - // Return cached thumbnail if it exists (no queue needed) - if (existsSync(thumbPath)) { - const data = await fs.readFile(thumbPath) - return { success: true, data } - } + // Return cached thumbnail if it exists (no queue needed) + if (existsSync(thumbPath)) { + const data = await fs.readFile(thumbPath); + return { success: true, data }; + } - // Serialize nativeImage operations to avoid OOM from concurrent full-res decodes - let jpegData: Buffer - const generation = thumbGenerationQueue.then(async () => { - const { nativeImage } = await import('electron') - const img = nativeImage.createFromPath(resolved) - if (img.isEmpty()) { - throw new Error('Failed to load image') - } - const { width, height } = img.getSize() - const scale = THUMB_SIZE / Math.min(width, height) - const resized = img.resize({ - width: Math.round(width * scale), - height: Math.round(height * scale), - quality: 'good', - }) - jpegData = resized.toJPEG(70) + // Serialize nativeImage operations to avoid OOM from concurrent full-res decodes + let jpegData: Buffer; + const generation = thumbGenerationQueue.then(async () => { + const { nativeImage } = await import("electron"); + const img = nativeImage.createFromPath(resolved); + if (img.isEmpty()) { + throw new Error("Failed to load image"); + } + const { width, height } = img.getSize(); + const scale = THUMB_SIZE / Math.min(width, height); + const resized = img.resize({ + width: Math.round(width * scale), + height: Math.round(height * scale), + quality: "good", + }); + jpegData = resized.toJPEG(70); - // Cache to disk - await fs.mkdir(thumbCacheDir, { recursive: true }) - await fs.writeFile(thumbPath, jpegData) - }) - // Keep the queue moving even if one fails - thumbGenerationQueue = generation.catch(() => undefined) - await generation + // Cache to disk + await fs.mkdir(thumbCacheDir, { recursive: true }); + await fs.writeFile(thumbPath, jpegData); + }); + // Keep the queue moving even if one fails + thumbGenerationQueue = generation.catch(() => undefined); + await generation; - return { success: true, data: jpegData! } - } catch (error) { - return { success: false, error: String(error) } - } - }) + return { success: true, data: jpegData! }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); - // Return base path for assets so renderer can resolve file:// paths in production - ipcMain.handle('get-asset-base-path', () => { - try { - const assetPath = getAssetRootPath() - return pathToFileURL(`${assetPath}${path.sep}`).toString() - } catch (err) { - console.error('Failed to resolve asset base path:', err) - return null - } - }) + // Return base path for assets so renderer can resolve file:// paths in production + ipcMain.handle("get-asset-base-path", () => { + try { + const assetPath = getAssetRootPath(); + return pathToFileURL(`${assetPath}${path.sep}`).toString(); + } catch (err) { + console.error("Failed to resolve asset base path:", err); + return null; + } + }); - ipcMain.handle('list-asset-directory', async (_, relativeDir: string) => { - try { - const normalizedRelativeDir = String(relativeDir ?? '') - .replace(/\\/g, '/') - .replace(/^\/+/, '') + ipcMain.handle("list-asset-directory", async (_, relativeDir: string) => { + try { + const normalizedRelativeDir = String(relativeDir ?? "") + .replace(/\\/g, "/") + .replace(/^\/+/, ""); - const assetRootPath = path.resolve(getAssetRootPath()) - const targetDirPath = path.resolve(assetRootPath, normalizedRelativeDir) - if (targetDirPath !== assetRootPath && !targetDirPath.startsWith(`${assetRootPath}${path.sep}`)) { - return { success: false, error: 'Invalid asset directory' } - } + const assetRootPath = path.resolve(getAssetRootPath()); + const targetDirPath = path.resolve(assetRootPath, normalizedRelativeDir); + if ( + targetDirPath !== assetRootPath && + !targetDirPath.startsWith(`${assetRootPath}${path.sep}`) + ) { + return { success: false, error: "Invalid asset directory" }; + } - const entries = await fs.readdir(targetDirPath, { withFileTypes: true }) - const files = entries - .filter((entry) => entry.isFile()) - .map((entry) => entry.name) - .sort(new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare) + const entries = await fs.readdir(targetDirPath, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort(new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare); - return { success: true, files } - } catch (error) { - console.error('Failed to list asset directory:', error) - return { success: false, error: String(error) } - } - }) + return { success: true, files }; + } catch (error) { + console.error("Failed to list asset directory:", error); + return { success: false, error: String(error) }; + } + }); - ipcMain.handle('read-local-file', async (_, filePath: string) => { - try { - // Intentionally more permissive than the media-server allowlist: this IPC - // is used for direct renderer-side local file reads after the app has - // already accepted a path, while URL-based media serving must stay scoped - // to approved/app-managed locations. We still canonicalize the path and - // require a real on-disk file so this cannot be used to read directories. - const resolved = await resolveReadableLocalFilePath(filePath) - - const data = await fs.readFile(resolved) - return { success: true, data } - } catch (error) { - console.error('Failed to read local file:', error) - return { success: false, error: String(error) } - } - }) + ipcMain.handle("read-local-file", async (_, filePath: string) => { + try { + // Intentionally more permissive than the media-server allowlist: this IPC + // is used for direct renderer-side local file reads after the app has + // already accepted a path, while URL-based media serving must stay scoped + // to approved/app-managed locations. We still canonicalize the path and + // require a real on-disk file so this cannot be used to read directories. + const resolved = await resolveReadableLocalFilePath(filePath); + const data = await fs.readFile(resolved); + return { success: true, data }; + } catch (error) { + console.error("Failed to read local file:", error); + return { success: false, error: String(error) }; + } + }); } diff --git a/electron/ipc/register/export.test.ts b/electron/ipc/register/export.test.ts index 33941eb1..df1e9d85 100644 --- a/electron/ipc/register/export.test.ts +++ b/electron/ipc/register/export.test.ts @@ -55,9 +55,7 @@ describe("moveExportedTempFile", () => { await moveExportedTempFile(tempPath, destinationPath); - await expect(fs.readFile(destinationPath, "utf8")).resolves.toBe( - "recordly-export", - ); + await expect(fs.readFile(destinationPath, "utf8")).resolves.toBe("recordly-export"); await expect(fs.access(tempPath)).rejects.toThrow(); }); diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index c4410a27..2eabb978 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -75,12 +75,7 @@ export async function moveExportedTempFile(tempPath: string, destinationPath: st return; } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if ( - code !== "EXDEV" && - code !== "EPERM" && - code !== "ENOTEMPTY" && - code !== "EEXIST" - ) { + if (code !== "EXDEV" && code !== "EPERM" && code !== "ENOTEMPTY" && code !== "EEXIST") { throw error; } // Cross-device or Windows permission quirks — fall back to copy + unlink so @@ -113,9 +108,7 @@ export async function moveExportedTempFile(tempPath: string, destinationPath: st await fs.rename(partialDestinationPath, destinationPath); } catch (replaceError) { if (movedExistingDestination) { - await fs - .rename(backupDestinationPath, destinationPath) - .catch(() => undefined); + await fs.rename(backupDestinationPath, destinationPath).catch(() => undefined); } throw replaceError; } diff --git a/electron/ipc/register/exportCaptionSidecars.test.ts b/electron/ipc/register/exportCaptionSidecars.test.ts index 6839e959..2fc571e1 100644 --- a/electron/ipc/register/exportCaptionSidecars.test.ts +++ b/electron/ipc/register/exportCaptionSidecars.test.ts @@ -56,7 +56,9 @@ describe("exportCaptionSidecars", () => { }); it("returns a warning result instead of throwing when sidecar writes fail", async () => { - const writeFileSpy = vi.spyOn(fs, "writeFile").mockRejectedValueOnce(new Error("disk full")); + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockRejectedValueOnce(new Error("disk full")); await expect( writeCaptionSidecarsBestEffort("/tmp/export.mp4", { @@ -109,4 +111,4 @@ describe("exportCaptionSidecars", () => { }), ).toBe("Video exported successfully"); }); -}); \ No newline at end of file +}); diff --git a/electron/ipc/register/exportCaptionSidecars.ts b/electron/ipc/register/exportCaptionSidecars.ts index 6711d3bb..0560a9a9 100644 --- a/electron/ipc/register/exportCaptionSidecars.ts +++ b/electron/ipc/register/exportCaptionSidecars.ts @@ -154,4 +154,4 @@ export function withCaptionSidecarMessage( } return `${baseMessage} Captions could not be saved alongside the video.`; -} \ No newline at end of file +} diff --git a/electron/ipc/register/permissions.ts b/electron/ipc/register/permissions.ts index 07057c96..f3b8b86f 100644 --- a/electron/ipc/register/permissions.ts +++ b/electron/ipc/register/permissions.ts @@ -2,86 +2,86 @@ import { ipcMain, shell, systemPreferences } from "electron"; import { getMacPrivacySettingsUrl } from "../utils"; export function registerPermissionHandlers() { - ipcMain.handle('open-external-url', async (_, url: string) => { - try { - // Security: only allow http/https URLs to prevent file:// or custom protocol abuse - const parsed = new URL(url) - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { - return { success: false, error: `Blocked non-HTTP URL: ${parsed.protocol}` } - } - await shell.openExternal(url) - return { success: true } - } catch (error) { - console.error('Failed to open URL:', error) - return { success: false, error: String(error) } - } - }) + ipcMain.handle("open-external-url", async (_, url: string) => { + try { + // Security: only allow http/https URLs to prevent file:// or custom protocol abuse + const parsed = new URL(url); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return { success: false, error: `Blocked non-HTTP URL: ${parsed.protocol}` }; + } + await shell.openExternal(url); + return { success: true }; + } catch (error) { + console.error("Failed to open URL:", error); + return { success: false, error: String(error) }; + } + }); - ipcMain.handle('get-accessibility-permission-status', () => { - if (process.platform !== 'darwin') { - return { success: true, trusted: true, prompted: false } - } + ipcMain.handle("get-accessibility-permission-status", () => { + if (process.platform !== "darwin") { + return { success: true, trusted: true, prompted: false }; + } - return { - success: true, - trusted: systemPreferences.isTrustedAccessibilityClient(false), - prompted: false, - } - }) + return { + success: true, + trusted: systemPreferences.isTrustedAccessibilityClient(false), + prompted: false, + }; + }); - ipcMain.handle('request-accessibility-permission', () => { - if (process.platform !== 'darwin') { - return { success: true, trusted: true, prompted: false } - } + ipcMain.handle("request-accessibility-permission", () => { + if (process.platform !== "darwin") { + return { success: true, trusted: true, prompted: false }; + } - return { - success: true, - trusted: systemPreferences.isTrustedAccessibilityClient(true), - prompted: true, - } - }) + return { + success: true, + trusted: systemPreferences.isTrustedAccessibilityClient(true), + prompted: true, + }; + }); - ipcMain.handle('get-screen-recording-permission-status', () => { - if (process.platform !== 'darwin') { - return { success: true, status: 'granted' } - } + ipcMain.handle("get-screen-recording-permission-status", () => { + if (process.platform !== "darwin") { + return { success: true, status: "granted" }; + } - try { - return { - success: true, - status: systemPreferences.getMediaAccessStatus('screen'), - } - } catch (error) { - console.error('Failed to get screen recording permission status:', error) - return { success: false, status: 'unknown', error: String(error) } - } - }) + try { + return { + success: true, + status: systemPreferences.getMediaAccessStatus("screen"), + }; + } catch (error) { + console.error("Failed to get screen recording permission status:", error); + return { success: false, status: "unknown", error: String(error) }; + } + }); - ipcMain.handle('open-screen-recording-preferences', async () => { - if (process.platform !== 'darwin') { - return { success: true } - } + ipcMain.handle("open-screen-recording-preferences", async () => { + if (process.platform !== "darwin") { + return { success: true }; + } - try { - await shell.openExternal(getMacPrivacySettingsUrl('screen')) - return { success: true } - } catch (error) { - console.error('Failed to open Screen Recording preferences:', error) - return { success: false, error: String(error) } - } - }) + try { + await shell.openExternal(getMacPrivacySettingsUrl("screen")); + return { success: true }; + } catch (error) { + console.error("Failed to open Screen Recording preferences:", error); + return { success: false, error: String(error) }; + } + }); - ipcMain.handle('open-accessibility-preferences', async () => { - if (process.platform !== 'darwin') { - return { success: true } - } + ipcMain.handle("open-accessibility-preferences", async () => { + if (process.platform !== "darwin") { + return { success: true }; + } - try { - await shell.openExternal(getMacPrivacySettingsUrl('accessibility')) - return { success: true } - } catch (error) { - console.error('Failed to open Accessibility preferences:', error) - return { success: false, error: String(error) } - } - }) + try { + await shell.openExternal(getMacPrivacySettingsUrl("accessibility")); + return { success: true }; + } catch (error) { + console.error("Failed to open Accessibility preferences:", error); + return { success: false, error: String(error) }; + } + }); } diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index f1fa43e2..a3a0cd07 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -5,26 +5,23 @@ import path from "node:path"; import { BrowserWindow, dialog, ipcMain, shell } from "electron"; import { RECORDINGS_DIR } from "../../appPaths"; import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer"; -import { - LEGACY_PROJECT_FILE_EXTENSIONS, - PROJECT_FILE_EXTENSION, -} from "../constants"; +import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION } from "../constants"; import { getProjectBackupPath, writeProjectFileAtomically } from "../project/atomicSave"; import { getProjectsDir, - getProjectThumbnailPath, + getProjectThumbnailPath, isPathInsideDirectory, isTrustedProjectPath, listProjectLibraryEntries, loadProjectFromPath, - loadRecentProjectPaths, + loadRecentProjectPaths, persistRecordingsDirectorySetting, rememberRecentProject, replaceApprovedSessionLocalReadPaths, rememberApprovedLocalReadPath, resolveApprovedLocalMediaPath, saveProjectThumbnail, - saveRecentProjectPaths, + saveRecentProjectPaths, } from "../project/manager"; import { persistRecordingSessionManifest, resolveRecordingSession } from "../project/session"; import { @@ -49,36 +46,36 @@ function normalizeRecordingTimeOffsetMs(value: unknown): number { } function normalizeBoolean(value: unknown, fallback = false): boolean { - return typeof value === "boolean" ? value : fallback; + return typeof value === "boolean" ? value : fallback; } /** * Produces a filesystem-safe project base name without the project extension. */ function normalizeProjectSaveName(projectName?: string | null) { - if (typeof projectName !== "string") { - return null; - } + if (typeof projectName !== "string") { + return null; + } - const trimmedName = projectName.trim(); - if (!trimmedName) { - return null; - } + const trimmedName = projectName.trim(); + if (!trimmedName) { + return null; + } - const withoutExtension = trimmedName.replace( - new RegExp(`\\.${PROJECT_FILE_EXTENSION}$`, "i"), - "", - ); - const withoutInvalidFilesystemChars = withoutExtension.replace(/[<>:"/\\|?*]/g, ""); - const withoutControlChars = Array.from(withoutInvalidFilesystemChars) - .filter((character) => character.charCodeAt(0) > 31) - .join(""); - const sanitizedName = withoutControlChars - .replace(/\s+/g, " ") - .replace(/[. ]+$/g, "") - .trim(); + const withoutExtension = trimmedName.replace( + new RegExp(`\\.${PROJECT_FILE_EXTENSION}$`, "i"), + "", + ); + const withoutInvalidFilesystemChars = withoutExtension.replace(/[<>:"/\\|?*]/g, ""); + const withoutControlChars = Array.from(withoutInvalidFilesystemChars) + .filter((character) => character.charCodeAt(0) > 31) + .join(""); + const sanitizedName = withoutControlChars + .replace(/\s+/g, " ") + .replace(/[. ]+$/g, "") + .trim(); - return sanitizedName || null; + return sanitizedName || null; } type NamedProjectSaveMode = "rename" | "copy"; @@ -91,609 +88,689 @@ function normalizeNamedProjectSaveMode(value: unknown): NamedProjectSaveMode { * Extracts the persisted source video path from a saved project payload. */ function getProjectVideoPath(projectData: unknown) { - if (!projectData || typeof projectData !== "object") { - return null; - } + if (!projectData || typeof projectData !== "object") { + return null; + } - const candidate = projectData as { videoPath?: unknown }; - return typeof candidate.videoPath === "string" ? candidate.videoPath : null; + const candidate = projectData as { videoPath?: unknown }; + return typeof candidate.videoPath === "string" ? candidate.videoPath : null; } function getProjectId(projectData: unknown) { - if (!projectData || typeof projectData !== "object") { - return null; - } + if (!projectData || typeof projectData !== "object") { + return null; + } - const candidate = projectData as { projectId?: unknown }; - return typeof candidate.projectId === "string" && candidate.projectId.trim().length > 0 - ? candidate.projectId - : null; + const candidate = projectData as { projectId?: unknown }; + return typeof candidate.projectId === "string" && candidate.projectId.trim().length > 0 + ? candidate.projectId + : null; } function withProjectId(projectData: unknown, projectId: string) { - if (!projectData || typeof projectData !== "object" || Array.isArray(projectData)) { - return projectData; - } + if (!projectData || typeof projectData !== "object" || Array.isArray(projectData)) { + return projectData; + } - return { - ...projectData, - projectId, - }; + return { + ...projectData, + projectId, + }; } function ensureProjectDataHasProjectId(projectData: unknown) { - const existingProjectId = getProjectId(projectData); - if (existingProjectId) { - return { - projectId: existingProjectId, - projectData, - }; - } + const existingProjectId = getProjectId(projectData); + if (existingProjectId) { + return { + projectId: existingProjectId, + projectData, + }; + } - const projectId = randomUUID(); - return { - projectId, - projectData: withProjectId(projectData, projectId), - }; + const projectId = randomUUID(); + return { + projectId, + projectData: withProjectId(projectData, projectId), + }; } async function resolveComparablePath(filePath: string) { - return fs.realpath(filePath).catch(() => path.resolve(filePath)); + return fs.realpath(filePath).catch(() => path.resolve(filePath)); } /** * Prevents a named save from silently overwriting a different project file. */ async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject( - targetProjectPath: string, - projectData: unknown, - activeProjectPath?: string | null, + targetProjectPath: string, + projectData: unknown, + activeProjectPath?: string | null, ) { - try { - await fs.stat(targetProjectPath); - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return { success: true }; - } - throw error; - } + try { + await fs.stat(targetProjectPath); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return { success: true }; + } + throw error; + } - const targetResolvedPath = await resolveComparablePath(targetProjectPath); - if (activeProjectPath) { - const activeResolvedPath = await resolveComparablePath(activeProjectPath); - if (activeResolvedPath === targetResolvedPath) { - return { success: true }; - } - } + const targetResolvedPath = await resolveComparablePath(targetProjectPath); + if (activeProjectPath) { + const activeResolvedPath = await resolveComparablePath(activeProjectPath); + if (activeResolvedPath === targetResolvedPath) { + return { success: true }; + } + } - const incomingProjectId = getProjectId(projectData); - const incomingVideoPath = getProjectVideoPath(projectData); + const incomingProjectId = getProjectId(projectData); + const incomingVideoPath = getProjectVideoPath(projectData); - try { - const existingProjectRaw = await fs.readFile(targetProjectPath, "utf-8"); - const existingProjectData = parseJsonWithByteOrderMark(existingProjectRaw); - const existingProjectId = getProjectId(existingProjectData); - const existingVideoPath = getProjectVideoPath(existingProjectData); + try { + const existingProjectRaw = await fs.readFile(targetProjectPath, "utf-8"); + const existingProjectData = parseJsonWithByteOrderMark(existingProjectRaw); + const existingProjectId = getProjectId(existingProjectData); + const existingVideoPath = getProjectVideoPath(existingProjectData); - if (existingProjectId && incomingProjectId) { - if (existingProjectId === incomingProjectId) { - return { success: true }; - } + if (existingProjectId && incomingProjectId) { + if (existingProjectId === incomingProjectId) { + return { success: true }; + } - return { - success: false, - message: "A different project already uses this name", - }; - } + return { + success: false, + message: "A different project already uses this name", + }; + } - if (existingVideoPath && incomingVideoPath && existingVideoPath !== incomingVideoPath) { - return { - success: false, - message: "A different project already uses this name", - }; - } + if (existingVideoPath && incomingVideoPath && existingVideoPath !== incomingVideoPath) { + return { + success: false, + message: "A different project already uses this name", + }; + } - if (!existingProjectId && !incomingProjectId && existingVideoPath && incomingVideoPath) { - return { - success: false, - message: "Unable to verify project identity for the chosen name", - }; - } + if (!existingProjectId && !incomingProjectId && existingVideoPath && incomingVideoPath) { + return { + success: false, + message: "Unable to verify project identity for the chosen name", + }; + } - return { - success: false, - message: "Unable to verify project identity for the chosen name", - }; - } catch (error) { - console.error("Failed to verify existing named project before overwrite:", error); - return { - success: false, - message: "Unable to verify project identity for the chosen name", - }; - } + return { + success: false, + message: "Unable to verify project identity for the chosen name", + }; + } catch (error) { + console.error("Failed to verify existing named project before overwrite:", error); + return { + success: false, + message: "Unable to verify project identity for the chosen name", + }; + } } export function registerProjectHandlers() { - ipcMain.handle('reveal-in-folder', async (_, filePath: string) => { - try { - // shell.showItemInFolder doesn't return a value, it throws on error - shell.showItemInFolder(filePath); - return { success: true }; - } catch (error) { - console.error(`Error revealing item in folder: ${filePath}`, error); - // Fallback to open the directory if revealing the item fails - // This might happen if the file was moved or deleted after export, - // or if the path is somehow invalid for showItemInFolder - try { - const openPathResult = await shell.openPath(path.dirname(filePath)); - if (openPathResult) { - // openPath returned an error message - return { success: false, error: openPathResult }; - } - return { success: true, message: 'Could not reveal item, but opened directory.' }; - } catch (openError) { - console.error(`Error opening directory: ${path.dirname(filePath)}`, openError); - return { success: false, error: String(error) }; - } - } - }); + ipcMain.handle("reveal-in-folder", async (_, filePath: string) => { + try { + // shell.showItemInFolder doesn't return a value, it throws on error + shell.showItemInFolder(filePath); + return { success: true }; + } catch (error) { + console.error(`Error revealing item in folder: ${filePath}`, error); + // Fallback to open the directory if revealing the item fails + // This might happen if the file was moved or deleted after export, + // or if the path is somehow invalid for showItemInFolder + try { + const openPathResult = await shell.openPath(path.dirname(filePath)); + if (openPathResult) { + // openPath returned an error message + return { success: false, error: openPathResult }; + } + return { success: true, message: "Could not reveal item, but opened directory." }; + } catch (openError) { + console.error(`Error opening directory: ${path.dirname(filePath)}`, openError); + return { success: false, error: String(error) }; + } + } + }); - ipcMain.handle('open-recordings-folder', async () => { - try { - const recordingsDir = await getRecordingsDir(); - const openPathResult = await shell.openPath(recordingsDir); - if (openPathResult) { - return { success: false, error: openPathResult, message: 'Failed to open recordings folder.' }; - } + ipcMain.handle("open-recordings-folder", async () => { + try { + const recordingsDir = await getRecordingsDir(); + const openPathResult = await shell.openPath(recordingsDir); + if (openPathResult) { + return { + success: false, + error: openPathResult, + message: "Failed to open recordings folder.", + }; + } - return { success: true }; - } catch (error) { - console.error('Failed to open recordings folder:', error); - return { success: false, error: String(error), message: 'Failed to open recordings folder.' }; - } - }); + return { success: true }; + } catch (error) { + console.error("Failed to open recordings folder:", error); + return { + success: false, + error: String(error), + message: "Failed to open recordings folder.", + }; + } + }); - ipcMain.handle('get-recordings-directory', async () => { - try { - const recordingsDir = await getRecordingsDir() - return { - success: true, - path: recordingsDir, - isDefault: recordingsDir === RECORDINGS_DIR, - } - } catch (error) { - return { - success: false, - path: RECORDINGS_DIR, - isDefault: true, - error: String(error), - } - } - }) + ipcMain.handle("get-recordings-directory", async () => { + try { + const recordingsDir = await getRecordingsDir(); + return { + success: true, + path: recordingsDir, + isDefault: recordingsDir === RECORDINGS_DIR, + }; + } catch (error) { + return { + success: false, + path: RECORDINGS_DIR, + isDefault: true, + error: String(error), + }; + } + }); - ipcMain.handle('choose-recordings-directory', async () => { - try { - const current = await getRecordingsDir() - const result = await dialog.showOpenDialog({ - title: 'Choose recordings folder', - defaultPath: current, - properties: ['openDirectory', 'createDirectory', 'promptToCreate'], - }) + ipcMain.handle("choose-recordings-directory", async () => { + try { + const current = await getRecordingsDir(); + const result = await dialog.showOpenDialog({ + title: "Choose recordings folder", + defaultPath: current, + properties: ["openDirectory", "createDirectory", "promptToCreate"], + }); - if (result.canceled || result.filePaths.length === 0) { - return { success: false, canceled: true, path: current } - } + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true, path: current }; + } - const selectedPath = path.resolve(result.filePaths[0]) - await fs.mkdir(selectedPath, { recursive: true }) - await fs.access(selectedPath, fsConstants.W_OK) - await persistRecordingsDirectorySetting(selectedPath) + const selectedPath = path.resolve(result.filePaths[0]); + await fs.mkdir(selectedPath, { recursive: true }); + await fs.access(selectedPath, fsConstants.W_OK); + await persistRecordingsDirectorySetting(selectedPath); - return { success: true, path: selectedPath, isDefault: selectedPath === RECORDINGS_DIR } - } catch (error) { - return { success: false, error: String(error), message: 'Failed to set recordings folder' } - } - }) + return { + success: true, + path: selectedPath, + isDefault: selectedPath === RECORDINGS_DIR, + }; + } catch (error) { + return { + success: false, + error: String(error), + message: "Failed to set recordings folder", + }; + } + }); - ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string, thumbnailDataUrl?: string | null) => { - try { - const projectsDir = await getProjectsDir() - const preparedProject = ensureProjectDataHasProjectId(projectData) - const trustedExistingProjectPath = existingProjectPath && - path.extname(existingProjectPath).toLowerCase() === `.${PROJECT_FILE_EXTENSION}` && - (isTrustedProjectPath(existingProjectPath) || isPathInsideDirectory(existingProjectPath, projectsDir)) - ? path.resolve(existingProjectPath) - : null + ipcMain.handle( + "save-project-file", + async ( + _, + projectData: unknown, + suggestedName?: string, + existingProjectPath?: string, + thumbnailDataUrl?: string | null, + ) => { + try { + const projectsDir = await getProjectsDir(); + const preparedProject = ensureProjectDataHasProjectId(projectData); + const trustedExistingProjectPath = + existingProjectPath && + path.extname(existingProjectPath).toLowerCase() === + `.${PROJECT_FILE_EXTENSION}` && + (isTrustedProjectPath(existingProjectPath) || + isPathInsideDirectory(existingProjectPath, projectsDir)) + ? path.resolve(existingProjectPath) + : null; - if (trustedExistingProjectPath) { - await writeProjectFileAtomically( - trustedExistingProjectPath, - JSON.stringify(preparedProject.projectData, null, 2), - ) - setCurrentProjectPath(trustedExistingProjectPath) - await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl) - await rememberRecentProject(trustedExistingProjectPath) - return { - success: true, - path: trustedExistingProjectPath, - projectId: preparedProject.projectId, - message: 'Project saved successfully' - } - } + if (trustedExistingProjectPath) { + await writeProjectFileAtomically( + trustedExistingProjectPath, + JSON.stringify(preparedProject.projectData, null, 2), + ); + setCurrentProjectPath(trustedExistingProjectPath); + await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl); + await rememberRecentProject(trustedExistingProjectPath); + return { + success: true, + path: trustedExistingProjectPath, + projectId: preparedProject.projectId, + message: "Project saved successfully", + }; + } - if (existingProjectPath) { - return { - success: false, - message: 'Project path is no longer trusted. Use Save As to choose a project file.', - } - } + if (existingProjectPath) { + return { + success: false, + message: + "Project path is no longer trusted. Use Save As to choose a project file.", + }; + } - const safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}` - const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}` + const safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}`; + const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}`; - const result = await dialog.showSaveDialog({ - title: 'Save Recordly Project', - defaultPath: path.join(projectsDir, defaultName), - filters: [ - { name: 'Recordly Project', extensions: [PROJECT_FILE_EXTENSION] }, - { name: 'JSON', extensions: ['json'] } - ], - properties: ['createDirectory', 'showOverwriteConfirmation'] - }) + const result = await dialog.showSaveDialog({ + title: "Save Recordly Project", + defaultPath: path.join(projectsDir, defaultName), + filters: [ + { name: "Recordly Project", extensions: [PROJECT_FILE_EXTENSION] }, + { name: "JSON", extensions: ["json"] }, + ], + properties: ["createDirectory", "showOverwriteConfirmation"], + }); - if (result.canceled || !result.filePath) { - return { - success: false, - canceled: true, - message: 'Save project canceled' - } - } + if (result.canceled || !result.filePath) { + return { + success: false, + canceled: true, + message: "Save project canceled", + }; + } - await writeProjectFileAtomically( - result.filePath, - JSON.stringify(preparedProject.projectData, null, 2), - ) - setCurrentProjectPath(result.filePath) - await saveProjectThumbnail(result.filePath, thumbnailDataUrl) - await rememberRecentProject(result.filePath) + await writeProjectFileAtomically( + result.filePath, + JSON.stringify(preparedProject.projectData, null, 2), + ); + setCurrentProjectPath(result.filePath); + await saveProjectThumbnail(result.filePath, thumbnailDataUrl); + await rememberRecentProject(result.filePath); - return { - success: true, - path: result.filePath, - projectId: preparedProject.projectId, - message: 'Project saved successfully' - } - } catch (error) { - console.error('Failed to save project file:', error) - return { - success: false, - message: 'Failed to save project file', - error: String(error) - } - } - }) + return { + success: true, + path: result.filePath, + projectId: preparedProject.projectId, + message: "Project saved successfully", + }; + } catch (error) { + console.error("Failed to save project file:", error); + return { + success: false, + message: "Failed to save project file", + error: String(error), + }; + } + }, + ); - ipcMain.handle('save-project-file-named', async (_, projectData: unknown, projectName: string, thumbnailDataUrl?: string | null, mode?: unknown) => { - try { - const normalizedProjectName = normalizeProjectSaveName(projectName) - if (!normalizedProjectName) { - return { - success: false, - message: 'Project name is required', - } - } + ipcMain.handle( + "save-project-file-named", + async ( + _, + projectData: unknown, + projectName: string, + thumbnailDataUrl?: string | null, + mode?: unknown, + ) => { + try { + const normalizedProjectName = normalizeProjectSaveName(projectName); + if (!normalizedProjectName) { + return { + success: false, + message: "Project name is required", + }; + } - const projectsDir = await getProjectsDir() - const namedSaveMode = normalizeNamedProjectSaveMode(mode) - const activeProjectPath = isTrustedProjectPath(currentProjectPath) - ? currentProjectPath - : null - const targetProjectPath = path.join( - projectsDir, - `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`, - ) - const [activeResolvedPath, targetResolvedPath] = await Promise.all([ - activeProjectPath ? resolveComparablePath(activeProjectPath) : Promise.resolve(null), - resolveComparablePath(targetProjectPath), - ]) - const isSavingToDifferentPath = - !activeResolvedPath || activeResolvedPath !== targetResolvedPath - const preparedProject = - namedSaveMode === "copy" && isSavingToDifferentPath - ? (() => { - const projectId = randomUUID() - return { - projectId, - projectData: withProjectId(projectData, projectId), - } - })() - : ensureProjectDataHasProjectId(projectData) + const projectsDir = await getProjectsDir(); + const namedSaveMode = normalizeNamedProjectSaveMode(mode); + const activeProjectPath = isTrustedProjectPath(currentProjectPath) + ? currentProjectPath + : null; + const targetProjectPath = path.join( + projectsDir, + `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`, + ); + const [activeResolvedPath, targetResolvedPath] = await Promise.all([ + activeProjectPath + ? resolveComparablePath(activeProjectPath) + : Promise.resolve(null), + resolveComparablePath(targetProjectPath), + ]); + const isSavingToDifferentPath = + !activeResolvedPath || activeResolvedPath !== targetResolvedPath; + const preparedProject = + namedSaveMode === "copy" && isSavingToDifferentPath + ? (() => { + const projectId = randomUUID(); + return { + projectId, + projectData: withProjectId(projectData, projectId), + }; + })() + : ensureProjectDataHasProjectId(projectData); - const overwriteCheck = await ensureNamedProjectSaveDoesNotOverwriteDifferentProject( - targetProjectPath, - preparedProject.projectData, - activeProjectPath, - ) - if (!overwriteCheck.success) { - return overwriteCheck - } + const overwriteCheck = await ensureNamedProjectSaveDoesNotOverwriteDifferentProject( + targetProjectPath, + preparedProject.projectData, + activeProjectPath, + ); + if (!overwriteCheck.success) { + return overwriteCheck; + } - await writeProjectFileAtomically( - targetProjectPath, - JSON.stringify(preparedProject.projectData, null, 2), - ) - await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl) - await rememberRecentProject(targetProjectPath) + await writeProjectFileAtomically( + targetProjectPath, + JSON.stringify(preparedProject.projectData, null, 2), + ); + await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl); + await rememberRecentProject(targetProjectPath); - if (namedSaveMode === "rename" && activeProjectPath && isSavingToDifferentPath) { - await fs.unlink(activeProjectPath).catch((unlinkError: NodeJS.ErrnoException) => { - if (unlinkError.code !== 'ENOENT') { - throw unlinkError - } - }) - await fs.rm(getProjectThumbnailPath(activeProjectPath), { force: true }).catch(() => undefined) - await fs.rm(getProjectBackupPath(activeProjectPath), { force: true }).catch(() => undefined) + if (namedSaveMode === "rename" && activeProjectPath && isSavingToDifferentPath) { + await fs + .unlink(activeProjectPath) + .catch((unlinkError: NodeJS.ErrnoException) => { + if (unlinkError.code !== "ENOENT") { + throw unlinkError; + } + }); + await fs + .rm(getProjectThumbnailPath(activeProjectPath), { force: true }) + .catch(() => undefined); + await fs + .rm(getProjectBackupPath(activeProjectPath), { force: true }) + .catch(() => undefined); - const recentProjectPaths = await loadRecentProjectPaths() - const filteredRecentProjectPaths: string[] = [] - for (const recentProjectPath of recentProjectPaths) { - const recentResolvedPath = await resolveComparablePath(recentProjectPath) - if (recentResolvedPath !== activeResolvedPath) { - filteredRecentProjectPaths.push(recentProjectPath) - } - } - await saveRecentProjectPaths(filteredRecentProjectPaths) - } + const recentProjectPaths = await loadRecentProjectPaths(); + const filteredRecentProjectPaths: string[] = []; + for (const recentProjectPath of recentProjectPaths) { + const recentResolvedPath = await resolveComparablePath(recentProjectPath); + if (recentResolvedPath !== activeResolvedPath) { + filteredRecentProjectPaths.push(recentProjectPath); + } + } + await saveRecentProjectPaths(filteredRecentProjectPaths); + } - setCurrentProjectPath(targetProjectPath) + setCurrentProjectPath(targetProjectPath); - return { - success: true, - path: targetProjectPath, - projectId: preparedProject.projectId, - message: 'Project saved successfully' - } - } catch (error) { - console.error('Failed to save named project file:', error) - return { - success: false, - message: 'Failed to save project file', - error: String(error) - } - } - }) + return { + success: true, + path: targetProjectPath, + projectId: preparedProject.projectId, + message: "Project saved successfully", + }; + } catch (error) { + console.error("Failed to save named project file:", error); + return { + success: false, + message: "Failed to save project file", + error: String(error), + }; + } + }, + ); - ipcMain.handle('load-project-file', async () => { - try { - const projectsDir = await getProjectsDir() - const result = await dialog.showOpenDialog({ - title: 'Open Recordly Project', - defaultPath: projectsDir, - filters: [ - { name: 'Recordly Project', extensions: [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS] }, - { name: 'JSON', extensions: ['json'] }, - { name: 'All Files', extensions: ['*'] } - ], - properties: ['openFile'] - }) + ipcMain.handle("load-project-file", async () => { + try { + const projectsDir = await getProjectsDir(); + const result = await dialog.showOpenDialog({ + title: "Open Recordly Project", + defaultPath: projectsDir, + filters: [ + { + name: "Recordly Project", + extensions: [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS], + }, + { name: "JSON", extensions: ["json"] }, + { name: "All Files", extensions: ["*"] }, + ], + properties: ["openFile"], + }); - if (result.canceled || result.filePaths.length === 0) { - return { success: false, canceled: true, message: 'Open project canceled' } - } + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true, message: "Open project canceled" }; + } - return await loadProjectFromPath(result.filePaths[0]) - } catch (error) { - console.error('Failed to load project file:', error) - return { - success: false, - message: 'Failed to load project file', - error: String(error) - } - } - }) + return await loadProjectFromPath(result.filePaths[0]); + } catch (error) { + console.error("Failed to load project file:", error); + return { + success: false, + message: "Failed to load project file", + error: String(error), + }; + } + }); - ipcMain.handle('load-current-project-file', async () => { - try { - if (!currentProjectPath) { - return { success: false, message: 'No active project' } - } + ipcMain.handle("load-current-project-file", async () => { + try { + if (!currentProjectPath) { + return { success: false, message: "No active project" }; + } - return await loadProjectFromPath(currentProjectPath) - } catch (error) { - console.error('Failed to load current project file:', error) - return { - success: false, - message: 'Failed to load current project file', - error: String(error), - } - } - }) + return await loadProjectFromPath(currentProjectPath); + } catch (error) { + console.error("Failed to load current project file:", error); + return { + success: false, + message: "Failed to load current project file", + error: String(error), + }; + } + }); - ipcMain.handle('get-projects-directory', async () => { - try { - return { - success: true, - path: await getProjectsDir(), - } - } catch (error) { - return { - success: false, - error: String(error), - } - } - }) + ipcMain.handle("get-projects-directory", async () => { + try { + return { + success: true, + path: await getProjectsDir(), + }; + } catch (error) { + return { + success: false, + error: String(error), + }; + } + }); - ipcMain.handle('list-project-files', async () => { - try { - const library = await listProjectLibraryEntries() - return { - success: true, - projectsDir: library.projectsDir, - entries: library.entries, - } - } catch (error) { - return { - success: false, - projectsDir: null, - entries: [], - error: String(error), - } - } - }) + ipcMain.handle("list-project-files", async () => { + try { + const library = await listProjectLibraryEntries(); + return { + success: true, + projectsDir: library.projectsDir, + entries: library.entries, + }; + } catch (error) { + return { + success: false, + projectsDir: null, + entries: [], + error: String(error), + }; + } + }); - ipcMain.handle('open-project-file-at-path', async (_, filePath: string) => { - try { - return await loadProjectFromPath(filePath) - } catch (error) { - console.error('Failed to open project file at path:', error) - return { - success: false, - message: 'Failed to open project file', - error: String(error), - } - } - }) + ipcMain.handle("open-project-file-at-path", async (_, filePath: string) => { + try { + return await loadProjectFromPath(filePath); + } catch (error) { + console.error("Failed to open project file at path:", error); + return { + success: false, + message: "Failed to open project file", + error: String(error), + }; + } + }); - ipcMain.handle('open-projects-directory', async () => { - try { - const projectsDir = await getProjectsDir() - const openPathResult = await shell.openPath(projectsDir) - if (openPathResult) { - return { success: false, error: openPathResult, message: 'Failed to open projects folder.' } - } + ipcMain.handle("open-projects-directory", async () => { + try { + const projectsDir = await getProjectsDir(); + const openPathResult = await shell.openPath(projectsDir); + if (openPathResult) { + return { + success: false, + error: openPathResult, + message: "Failed to open projects folder.", + }; + } - return { success: true, path: projectsDir } - } catch (error) { - console.error('Failed to open projects folder:', error) - return { success: false, error: String(error), message: 'Failed to open projects folder.' } - } - }) - ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean }) => { - setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path) - approveUserPath(currentVideoPath) - const resolvedSession = await resolveRecordingSession(currentVideoPath) - ?? { - videoPath: currentVideoPath!, - webcamPath: null, - timeOffsetMs: 0, - } + return { success: true, path: projectsDir }; + } catch (error) { + console.error("Failed to open projects folder:", error); + return { + success: false, + error: String(error), + message: "Failed to open projects folder.", + }; + } + }); + ipcMain.handle( + "set-current-video-path", + async ( + _, + path: string, + options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean }, + ) => { + setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path); + approveUserPath(currentVideoPath); + const resolvedSession = (await resolveRecordingSession(currentVideoPath)) ?? { + videoPath: currentVideoPath!, + webcamPath: null, + timeOffsetMs: 0, + }; - const nextSession = { - ...resolvedSession, - hideOverlayCursorByDefault: - normalizeBoolean(options?.hideOverlayCursorByDefault) || - normalizeBoolean(resolvedSession.hideOverlayCursorByDefault), - } + const nextSession = { + ...resolvedSession, + hideOverlayCursorByDefault: + normalizeBoolean(options?.hideOverlayCursorByDefault) || + normalizeBoolean(resolvedSession.hideOverlayCursorByDefault), + }; - setCurrentRecordingSession(nextSession) - await replaceApprovedSessionLocalReadPaths([ - resolvedSession.videoPath, - resolvedSession.webcamPath, - ]) + setCurrentRecordingSession(nextSession); + await replaceApprovedSessionLocalReadPaths([ + resolvedSession.videoPath, + resolvedSession.webcamPath, + ]); - if (nextSession.webcamPath) { - await persistRecordingSessionManifest(nextSession) - } + if (nextSession.webcamPath) { + await persistRecordingSessionManifest(nextSession); + } - if (!options?.preserveProjectPath) { - setCurrentProjectPath(null) - } + if (!options?.preserveProjectPath) { + setCurrentProjectPath(null); + } - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send('recording-session-changed', nextSession); - } - } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("recording-session-changed", nextSession); + } + } - return { success: true, webcamPath: nextSession.webcamPath ?? null } - }) + return { success: true, webcamPath: nextSession.webcamPath ?? null }; + }, + ); - ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean }, options?: { preserveProjectPath?: boolean }) => { - const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath - setCurrentVideoPath(normalizedVideoPath) - setCurrentRecordingSession({ - videoPath: normalizedVideoPath, - webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null), - timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), - hideOverlayCursorByDefault: normalizeBoolean(session.hideOverlayCursorByDefault), - }); - await rememberApprovedLocalReadPath(currentRecordingSession!.videoPath) - await rememberApprovedLocalReadPath(currentRecordingSession!.webcamPath) - if (!options?.preserveProjectPath) { - setCurrentProjectPath(null) - } - await persistRecordingSessionManifest(currentRecordingSession!) + ipcMain.handle( + "set-current-recording-session", + async ( + _, + session: { + videoPath: string; + webcamPath?: string | null; + timeOffsetMs?: number; + hideOverlayCursorByDefault?: boolean; + }, + options?: { preserveProjectPath?: boolean }, + ) => { + const normalizedVideoPath = + normalizeVideoSourcePath(session.videoPath) ?? session.videoPath; + setCurrentVideoPath(normalizedVideoPath); + setCurrentRecordingSession({ + videoPath: normalizedVideoPath, + webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null), + timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), + hideOverlayCursorByDefault: normalizeBoolean(session.hideOverlayCursorByDefault), + }); + await rememberApprovedLocalReadPath(currentRecordingSession!.videoPath); + await rememberApprovedLocalReadPath(currentRecordingSession!.webcamPath); + if (!options?.preserveProjectPath) { + setCurrentProjectPath(null); + } + await persistRecordingSessionManifest(currentRecordingSession!); - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send('recording-session-changed', currentRecordingSession); - } - } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("recording-session-changed", currentRecordingSession); + } + } - return { success: true } - }) + return { success: true }; + }, + ); - ipcMain.handle('get-current-recording-session', () => { - if (!currentRecordingSession) { - return { success: false } - } + ipcMain.handle("get-current-recording-session", () => { + if (!currentRecordingSession) { + return { success: false }; + } - return { - success: true, - session: currentRecordingSession, - } - }) + return { + success: true, + session: currentRecordingSession, + }; + }); - ipcMain.handle('get-current-video-path', () => { - return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false }; - }); + ipcMain.handle("get-current-video-path", () => { + return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false }; + }); - ipcMain.handle('clear-current-video-path', () => { - setCurrentVideoPath(null); - setCurrentRecordingSession(null); - return { success: true }; - }); + ipcMain.handle("clear-current-video-path", () => { + setCurrentVideoPath(null); + setCurrentRecordingSession(null); + return { success: true }; + }); - ipcMain.handle('delete-recording-file', async (_, filePath: string) => { - try { - if (!filePath) { - return { success: false, error: 'Only auto-generated recordings can be deleted' }; - } - const resolvedPath = await fs.realpath(filePath).catch(() => path.resolve(filePath)); + ipcMain.handle("delete-recording-file", async (_, filePath: string) => { + try { + if (!filePath) { + return { success: false, error: "Only auto-generated recordings can be deleted" }; + } + const resolvedPath = await fs.realpath(filePath).catch(() => path.resolve(filePath)); const recordingsDirRaw = await getRecordingsDir(); - const recordingsDir = await fs.realpath(recordingsDirRaw).catch(() => path.resolve(recordingsDirRaw)); - if (!isPathInsideDirectory(resolvedPath, recordingsDir) || !isAutoRecordingPath(resolvedPath)) { - return { success: false, error: 'Only auto-generated recordings can be deleted' }; - } - await fs.unlink(resolvedPath); - // Also delete the cursor telemetry sidecar if it exists - const telemetryPath = getTelemetryPathForVideo(resolvedPath); - await fs.unlink(telemetryPath).catch(() => undefined); + const recordingsDir = await fs + .realpath(recordingsDirRaw) + .catch(() => path.resolve(recordingsDirRaw)); + if ( + !isPathInsideDirectory(resolvedPath, recordingsDir) || + !isAutoRecordingPath(resolvedPath) + ) { + return { success: false, error: "Only auto-generated recordings can be deleted" }; + } + await fs.unlink(resolvedPath); + // Also delete the cursor telemetry sidecar if it exists + const telemetryPath = getTelemetryPathForVideo(resolvedPath); + await fs.unlink(telemetryPath).catch(() => undefined); const currentResolved = currentVideoPath ? await fs.realpath(currentVideoPath).catch(() => currentVideoPath) : null; if (currentResolved === resolvedPath) { - setCurrentVideoPath(null); - setCurrentRecordingSession(null); - } - return { success: true }; - } catch (error) { - return { success: false, error: String(error) }; - } - }); - - ipcMain.handle('get-local-media-url', async (_, filePath: string) => { - const baseUrl = getMediaServerBaseUrl(); - if (!baseUrl || !filePath) { - return { success: false as const }; - } - const resolved = await resolveApprovedLocalMediaPath(filePath); - if (!resolved) { - const normalized = path.resolve(filePath); - console.warn(`[get-local-media-url] Blocked disallowed path: ${normalized}`); - return { success: false as const }; - } - return { success: true as const, url: buildMediaUrl(baseUrl, resolved) }; - }); + setCurrentVideoPath(null); + setCurrentRecordingSession(null); + } + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + ipcMain.handle("get-local-media-url", async (_, filePath: string) => { + const baseUrl = getMediaServerBaseUrl(); + if (!baseUrl || !filePath) { + return { success: false as const }; + } + const resolved = await resolveApprovedLocalMediaPath(filePath); + if (!resolved) { + const normalized = path.resolve(filePath); + console.warn(`[get-local-media-url] Blocked disallowed path: ${normalized}`); + return { success: false as const }; + } + return { success: true as const, url: buildMediaUrl(baseUrl, resolved) }; + }); } diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index fa9b32f3..3e03d970 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -435,13 +435,16 @@ export function registerRecordingHandlers( const recordingsDir = await getRecordingsDir(); const timestamp = Date.now(); const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`); - tempVideoPath = path.join(app.getPath("temp"), `recordly-native-${timestamp}.mp4`); - + tempVideoPath = path.join( + app.getPath("temp"), + `recordly-native-${timestamp}.mp4`, + ); + let captureOutput = ""; let systemAudioPath: string | null = null; let microphonePath: string | null = null; let orphanedMicAudioPath: string | null = null; - + const browserMicFallbackRequested = shouldStartWindowsBrowserMicrophoneFallback(options); const captureTarget = resolveWindowsCaptureTarget( @@ -484,7 +487,7 @@ export function registerRecordingHandlers( // Fallback to coordinate-based matching if handle resolution fails config.displayId = captureTarget.displayId; } - + config.displayX = Math.round(captureTarget.bounds.x); config.displayY = Math.round(captureTarget.bounds.y); config.displayW = Math.round(captureTarget.bounds.width); @@ -509,7 +512,10 @@ export function registerRecordingHandlers( if (options?.capturesMicrophone && !browserMicFallbackRequested) { microphonePath = path.join(recordingsDir, `recording-${timestamp}.mic.wav`); - tempMicPath = path.join(app.getPath("temp"), `recordly-native-${timestamp}.mic.wav`); + tempMicPath = path.join( + app.getPath("temp"), + `recordly-native-${timestamp}.mic.wav`, + ); config.captureMic = true; config.micOutputPath = tempMicPath; if (options.microphoneLabel) { @@ -907,333 +913,337 @@ export function registerRecordingHandlers( const start = Date.now(); console.log("[PERF:MAIN] Handler: stop-native-screen-recording: STARTED"); try { - // Windows native capture stop path - if (process.platform === "win32" && windowsNativeCaptureActive) { - let stagedTempVideoPath: string | null = null; - let stagedTempSystemAudioPath: string | null = null; - let stagedTempMicAudioPath: string | null = null; - try { - if (!windowsCaptureProcess) { - throw new Error("Native Windows capture process is not running"); + // Windows native capture stop path + if (process.platform === "win32" && windowsNativeCaptureActive) { + let stagedTempVideoPath: string | null = null; + let stagedTempSystemAudioPath: string | null = null; + let stagedTempMicAudioPath: string | null = null; + try { + if (!windowsCaptureProcess) { + throw new Error("Native Windows capture process is not running"); + } + + const proc = windowsCaptureProcess; + const preferredVideoPath = windowsCaptureTargetPath; + const preferredOrphanedMicAudioPath = windowsOrphanedMicAudioPath; + const diagnosticsSystemAudioPath = windowsSystemAudioPath; + const diagnosticsMicAudioPath = windowsMicAudioPath; + setWindowsCaptureStopRequested(true); + proc.stdin.write("stop\n"); + const tempVideoPath = await waitForWindowsCaptureStop(proc); + stagedTempVideoPath = tempVideoPath; + const finalVideoPath = preferredVideoPath ?? tempVideoPath; + + // Native Windows capture results are initially written to a safe temporary path + // (to avoid encoding failures with non-ASCII characters). We move them to the final + // destination now using Node.js, which handles Unicode paths correctly. + if (tempVideoPath !== finalVideoPath) { + await moveFileWithOverwrite(tempVideoPath, finalVideoPath); + } + + if (windowsSystemAudioPath && tempVideoPath.endsWith(".mp4")) { + const tempAudioPath = tempVideoPath.replace(".mp4", ".system.wav"); + stagedTempSystemAudioPath = tempAudioPath; + const finalAudioPath = windowsSystemAudioPath; + if (await pathExists(tempAudioPath)) { + await moveFileWithOverwrite(tempAudioPath, finalAudioPath); + const tempJson = tempAudioPath + ".json"; + if (await pathExists(tempJson)) { + await moveFileWithOverwrite(tempJson, finalAudioPath + ".json"); + } + } + } + + if (windowsMicAudioPath && tempVideoPath.endsWith(".mp4")) { + const tempMicPath = tempVideoPath.replace(".mp4", ".mic.wav"); + stagedTempMicAudioPath = tempMicPath; + const finalMicPath = windowsMicAudioPath; + if (await pathExists(tempMicPath)) { + await moveFileWithOverwrite(tempMicPath, finalMicPath); + const tempJson = tempMicPath + ".json"; + if (await pathExists(tempJson)) { + await moveFileWithOverwrite(tempJson, finalMicPath + ".json"); + } + } + } + const validation = await validateRecordedVideo(finalVideoPath); + + setWindowsCaptureProcess(null); + setWindowsNativeCaptureActive(false); + setNativeScreenRecordingActive(false); + setWindowsCaptureTargetPath(null); + setWindowsCaptureStopRequested(false); + setWindowsCapturePaused(false); + setWindowsOrphanedMicAudioPath(null); + await cleanupWindowsOrphanedMicAudioPath(preferredOrphanedMicAudioPath); + setWindowsPendingVideoPath(finalVideoPath); + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "stop", + outputPath: finalVideoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: validation.fileSizeBytes, + }); + await writeWindowsRecordingDiagnostics(finalVideoPath, { + phase: "stop", + outputPath: finalVideoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + details: { + fileSizeBytes: validation.fileSizeBytes, + durationSeconds: validation.durationSeconds, + }, + }); + + // Persist cursor telemetry before returning so the editor can find it immediately + snapshotCursorTelemetryForPersistence(); + try { + await persistPendingCursorTelemetry(finalVideoPath); + } catch (error) { + console.warn( + "Failed to persist cursor telemetry during native stop:", + error, + ); + } + + return { success: true, path: finalVideoPath }; + } catch (error) { + console.error("Failed to stop native Windows capture:", error); + const fallbackPath = await resolveExistingPath( + windowsCaptureTargetPath, + stagedTempVideoPath, + ); + const recoveredSystemAudioPath = await resolveExistingPath( + windowsSystemAudioPath, + stagedTempSystemAudioPath, + ); + const recoveredMicAudioPath = await resolveExistingPath( + windowsMicAudioPath, + stagedTempMicAudioPath, + ); + const fallbackOrphanedMicAudioPath = windowsOrphanedMicAudioPath; + const diagnosticsSystemAudioPath = + recoveredSystemAudioPath ?? windowsSystemAudioPath; + const diagnosticsMicAudioPath = recoveredMicAudioPath ?? windowsMicAudioPath; + setWindowsNativeCaptureActive(false); + setNativeScreenRecordingActive(false); + setWindowsCaptureProcess(null); + setWindowsCaptureTargetPath(null); + setWindowsCaptureStopRequested(false); + setWindowsCapturePaused(false); + setWindowsOrphanedMicAudioPath(null); + + if (fallbackPath) { + try { + const validation = await validateRecordedVideo(fallbackPath); + setWindowsPendingVideoPath(fallbackPath); + setWindowsSystemAudioPath(recoveredSystemAudioPath); + setWindowsMicAudioPath(recoveredMicAudioPath); + await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath); + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: validation.fileSizeBytes, + error: String(error), + }); + await writeWindowsRecordingDiagnostics(fallbackPath, { + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + error: String(error), + details: { + fileSizeBytes: validation.fileSizeBytes, + durationSeconds: validation.durationSeconds, + recoveredAfterStopFailure: true, + }, + }); + return { success: true, path: fallbackPath }; + } catch { + // File is absent or failed validation. + } + } + + setWindowsSystemAudioPath(null); + setWindowsMicAudioPath(null); + setWindowsPendingVideoPath(null); + await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath); + + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: await getFileSizeIfPresent(fallbackPath), + error: String(error), + }); + await writeWindowsRecordingDiagnostics(fallbackPath, { + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + error: String(error), + details: { + fileSizeBytes: await getFileSizeIfPresent(fallbackPath), + }, + }); + + return { + success: false, + message: "Failed to stop native Windows capture", + error: String(error), + }; + } + } + + if (process.platform !== "darwin") { + return { + success: false, + message: "Native screen recording is only available on macOS.", + }; + } + + if (!nativeScreenRecordingActive) { + const recovered = await recoverNativeMacCaptureOutput(); + if (recovered) { + return recovered; } - const proc = windowsCaptureProcess; - const preferredVideoPath = windowsCaptureTargetPath; - const preferredOrphanedMicAudioPath = windowsOrphanedMicAudioPath; - const diagnosticsSystemAudioPath = windowsSystemAudioPath; - const diagnosticsMicAudioPath = windowsMicAudioPath; - setWindowsCaptureStopRequested(true); - proc.stdin.write("stop\n"); - const tempVideoPath = await waitForWindowsCaptureStop(proc); - stagedTempVideoPath = tempVideoPath; - const finalVideoPath = preferredVideoPath ?? tempVideoPath; + return { success: false, message: "No native screen recording is active." }; + } - // Native Windows capture results are initially written to a safe temporary path - // (to avoid encoding failures with non-ASCII characters). We move them to the final - // destination now using Node.js, which handles Unicode paths correctly. + try { + if (!nativeCaptureProcess) { + throw new Error("Native capture helper process is not running"); + } + + const process = nativeCaptureProcess; + const preferredVideoPath = nativeCaptureTargetPath; + const preferredSystemAudioPath = nativeCaptureSystemAudioPath; + const preferredMicrophonePath = nativeCaptureMicrophonePath; + console.log( + "[stop-native] Audio paths — system:", + preferredSystemAudioPath, + "mic:", + preferredMicrophonePath, + ); + setNativeCaptureStopRequested(true); + process.stdin.write("stop\n"); + const tempVideoPath = await waitForNativeCaptureStop(process); + console.log("[stop-native] Helper stopped, tempVideoPath:", tempVideoPath); + setNativeCaptureProcess(null); + setNativeScreenRecordingActive(false); + setNativeCaptureTargetPath(null); + setNativeCaptureSystemAudioPath(null); + setNativeCaptureMicrophonePath(null); + setNativeCaptureStopRequested(false); + setNativeCapturePaused(false); + + const finalVideoPath = preferredVideoPath ?? tempVideoPath; if (tempVideoPath !== finalVideoPath) { await moveFileWithOverwrite(tempVideoPath, finalVideoPath); } - if (windowsSystemAudioPath && tempVideoPath.endsWith(".mp4")) { - const tempAudioPath = tempVideoPath.replace(".mp4", ".system.wav"); - stagedTempSystemAudioPath = tempAudioPath; - const finalAudioPath = windowsSystemAudioPath; - if (await pathExists(tempAudioPath)) { - await moveFileWithOverwrite(tempAudioPath, finalAudioPath); - const tempJson = tempAudioPath + ".json"; - if (await pathExists(tempJson)) { - await moveFileWithOverwrite(tempJson, finalAudioPath + ".json"); - } + if (preferredSystemAudioPath || preferredMicrophonePath) { + console.log( + "[stop-native] Attempting audio mux (merging separate tracks) into:", + finalVideoPath, + ); + try { + await muxNativeMacRecordingWithAudio( + finalVideoPath, + preferredSystemAudioPath, + preferredMicrophonePath, + ); + console.log("[stop-native] Audio mux completed successfully"); + } catch (error) { + console.warn( + "[stop-native] Audio mux failed (video still has inline audio):", + error, + ); } + } else { + console.log("[stop-native] No separate audio tracks to mux"); } - if (windowsMicAudioPath && tempVideoPath.endsWith(".mp4")) { - const tempMicPath = tempVideoPath.replace(".mp4", ".mic.wav"); - stagedTempMicAudioPath = tempMicPath; - const finalMicPath = windowsMicAudioPath; - if (await pathExists(tempMicPath)) { - await moveFileWithOverwrite(tempMicPath, finalMicPath); - const tempJson = tempMicPath + ".json"; - if (await pathExists(tempJson)) { - await moveFileWithOverwrite(tempJson, finalMicPath + ".json"); - } - } - } - const validation = await validateRecordedVideo(finalVideoPath); - - setWindowsCaptureProcess(null); - setWindowsNativeCaptureActive(false); - setNativeScreenRecordingActive(false); - setWindowsCaptureTargetPath(null); - setWindowsCaptureStopRequested(false); - setWindowsCapturePaused(false); - setWindowsOrphanedMicAudioPath(null); - await cleanupWindowsOrphanedMicAudioPath(preferredOrphanedMicAudioPath); - setWindowsPendingVideoPath(finalVideoPath); - recordNativeCaptureDiagnostics({ - backend: "windows-wgc", - phase: "stop", - outputPath: finalVideoPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - fileSizeBytes: validation.fileSizeBytes, - }); - await writeWindowsRecordingDiagnostics(finalVideoPath, { - phase: "stop", - outputPath: finalVideoPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - details: { - fileSizeBytes: validation.fileSizeBytes, - durationSeconds: validation.durationSeconds, - }, - }); - - // Persist cursor telemetry before returning so the editor can find it immediately - snapshotCursorTelemetryForPersistence(); - try { - await persistPendingCursorTelemetry(finalVideoPath); - } catch (error) { - console.warn("Failed to persist cursor telemetry during native stop:", error); - } - - return { success: true, path: finalVideoPath }; + return await finalizeStoredVideo(finalVideoPath); } catch (error) { - console.error("Failed to stop native Windows capture:", error); - const fallbackPath = await resolveExistingPath( - windowsCaptureTargetPath, - stagedTempVideoPath, - ); - const recoveredSystemAudioPath = await resolveExistingPath( - windowsSystemAudioPath, - stagedTempSystemAudioPath, - ); - const recoveredMicAudioPath = await resolveExistingPath( - windowsMicAudioPath, - stagedTempMicAudioPath, - ); - const fallbackOrphanedMicAudioPath = windowsOrphanedMicAudioPath; - const diagnosticsSystemAudioPath = recoveredSystemAudioPath ?? windowsSystemAudioPath; - const diagnosticsMicAudioPath = recoveredMicAudioPath ?? windowsMicAudioPath; - setWindowsNativeCaptureActive(false); + console.error("Failed to stop native ScreenCaptureKit recording:", error); + const fallbackPath = nativeCaptureTargetPath; + const fallbackSystemAudioPath = nativeCaptureSystemAudioPath; + const fallbackMicrophonePath = nativeCaptureMicrophonePath; + const fallbackFileSizeBytes = await getFileSizeIfPresent(fallbackPath); setNativeScreenRecordingActive(false); - setWindowsCaptureProcess(null); - setWindowsCaptureTargetPath(null); - setWindowsCaptureStopRequested(false); - setWindowsCapturePaused(false); - setWindowsOrphanedMicAudioPath(null); + setNativeCaptureProcess(null); + setNativeCaptureTargetPath(null); + setNativeCaptureSystemAudioPath(null); + setNativeCaptureMicrophonePath(null); + setNativeCaptureStopRequested(false); + setNativeCapturePaused(false); + recordNativeCaptureDiagnostics({ + backend: "mac-screencapturekit", + phase: "stop", + sourceId: lastNativeCaptureDiagnostics?.sourceId ?? null, + sourceType: lastNativeCaptureDiagnostics?.sourceType ?? "unknown", + displayId: lastNativeCaptureDiagnostics?.displayId ?? null, + displayBounds: lastNativeCaptureDiagnostics?.displayBounds ?? null, + windowHandle: lastNativeCaptureDiagnostics?.windowHandle ?? null, + helperPath: lastNativeCaptureDiagnostics?.helperPath ?? null, + outputPath: fallbackPath, + systemAudioPath: fallbackSystemAudioPath, + microphonePath: fallbackMicrophonePath, + osRelease: lastNativeCaptureDiagnostics?.osRelease, + supported: lastNativeCaptureDiagnostics?.supported, + helperExists: lastNativeCaptureDiagnostics?.helperExists, + processOutput: nativeCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: fallbackFileSizeBytes, + error: String(error), + }); + + // Try to recover: if the target file exists on disk, finalize with it if (fallbackPath) { try { - const validation = await validateRecordedVideo(fallbackPath); - setWindowsPendingVideoPath(fallbackPath); - setWindowsSystemAudioPath(recoveredSystemAudioPath); - setWindowsMicAudioPath(recoveredMicAudioPath); - await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath); - recordNativeCaptureDiagnostics({ - backend: "windows-wgc", - phase: "stop", - outputPath: fallbackPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - fileSizeBytes: validation.fileSizeBytes, - error: String(error), - }); - await writeWindowsRecordingDiagnostics(fallbackPath, { - phase: "stop", - outputPath: fallbackPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - error: String(error), - details: { - fileSizeBytes: validation.fileSizeBytes, - durationSeconds: validation.durationSeconds, - recoveredAfterStopFailure: true, - }, - }); - return { success: true, path: fallbackPath }; - } catch { - // File is absent or failed validation. - } - } - - setWindowsSystemAudioPath(null); - setWindowsMicAudioPath(null); - setWindowsPendingVideoPath(null); - await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath); - - recordNativeCaptureDiagnostics({ - backend: "windows-wgc", - phase: "stop", - outputPath: fallbackPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - fileSizeBytes: await getFileSizeIfPresent(fallbackPath), - error: String(error), - }); - await writeWindowsRecordingDiagnostics(fallbackPath, { - phase: "stop", - outputPath: fallbackPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - error: String(error), - details: { - fileSizeBytes: await getFileSizeIfPresent(fallbackPath), - }, - }); - - return { - success: false, - message: "Failed to stop native Windows capture", - error: String(error), - }; - } - } - - if (process.platform !== "darwin") { - return { - success: false, - message: "Native screen recording is only available on macOS.", - }; - } - - if (!nativeScreenRecordingActive) { - const recovered = await recoverNativeMacCaptureOutput(); - if (recovered) { - return recovered; - } - - return { success: false, message: "No native screen recording is active." }; - } - - try { - if (!nativeCaptureProcess) { - throw new Error("Native capture helper process is not running"); - } - - const process = nativeCaptureProcess; - const preferredVideoPath = nativeCaptureTargetPath; - const preferredSystemAudioPath = nativeCaptureSystemAudioPath; - const preferredMicrophonePath = nativeCaptureMicrophonePath; - console.log( - "[stop-native] Audio paths — system:", - preferredSystemAudioPath, - "mic:", - preferredMicrophonePath, - ); - setNativeCaptureStopRequested(true); - process.stdin.write("stop\n"); - const tempVideoPath = await waitForNativeCaptureStop(process); - console.log("[stop-native] Helper stopped, tempVideoPath:", tempVideoPath); - setNativeCaptureProcess(null); - setNativeScreenRecordingActive(false); - setNativeCaptureTargetPath(null); - setNativeCaptureSystemAudioPath(null); - setNativeCaptureMicrophonePath(null); - setNativeCaptureStopRequested(false); - setNativeCapturePaused(false); - - const finalVideoPath = preferredVideoPath ?? tempVideoPath; - if (tempVideoPath !== finalVideoPath) { - await moveFileWithOverwrite(tempVideoPath, finalVideoPath); - } - - if (preferredSystemAudioPath || preferredMicrophonePath) { - console.log( - "[stop-native] Attempting audio mux (merging separate tracks) into:", - finalVideoPath, - ); - try { - await muxNativeMacRecordingWithAudio( - finalVideoPath, - preferredSystemAudioPath, - preferredMicrophonePath, - ); - console.log("[stop-native] Audio mux completed successfully"); - } catch (error) { - console.warn( - "[stop-native] Audio mux failed (video still has inline audio):", - error, - ); - } - } else { - console.log("[stop-native] No separate audio tracks to mux"); - } - - return await finalizeStoredVideo(finalVideoPath); - } catch (error) { - console.error("Failed to stop native ScreenCaptureKit recording:", error); - const fallbackPath = nativeCaptureTargetPath; - const fallbackSystemAudioPath = nativeCaptureSystemAudioPath; - const fallbackMicrophonePath = nativeCaptureMicrophonePath; - const fallbackFileSizeBytes = await getFileSizeIfPresent(fallbackPath); - setNativeScreenRecordingActive(false); - setNativeCaptureProcess(null); - setNativeCaptureTargetPath(null); - setNativeCaptureSystemAudioPath(null); - setNativeCaptureMicrophonePath(null); - setNativeCaptureStopRequested(false); - setNativeCapturePaused(false); - - recordNativeCaptureDiagnostics({ - backend: "mac-screencapturekit", - phase: "stop", - sourceId: lastNativeCaptureDiagnostics?.sourceId ?? null, - sourceType: lastNativeCaptureDiagnostics?.sourceType ?? "unknown", - displayId: lastNativeCaptureDiagnostics?.displayId ?? null, - displayBounds: lastNativeCaptureDiagnostics?.displayBounds ?? null, - windowHandle: lastNativeCaptureDiagnostics?.windowHandle ?? null, - helperPath: lastNativeCaptureDiagnostics?.helperPath ?? null, - outputPath: fallbackPath, - systemAudioPath: fallbackSystemAudioPath, - microphonePath: fallbackMicrophonePath, - osRelease: lastNativeCaptureDiagnostics?.osRelease, - supported: lastNativeCaptureDiagnostics?.supported, - helperExists: lastNativeCaptureDiagnostics?.helperExists, - processOutput: nativeCaptureOutputBuffer.trim() || undefined, - fileSizeBytes: fallbackFileSizeBytes, - error: String(error), - }); - - // Try to recover: if the target file exists on disk, finalize with it - if (fallbackPath) { - try { - await fs.access(fallbackPath); - console.log( - "[stop-native-screen-recording] Recovering with fallback path:", - fallbackPath, - ); - if (fallbackSystemAudioPath || fallbackMicrophonePath) { - try { - await muxNativeMacRecordingWithAudio( - fallbackPath, - fallbackSystemAudioPath, - fallbackMicrophonePath, - ); - } catch (muxError) { - console.warn( - "Failed to mux recovered native macOS audio into capture:", - muxError, - ); + await fs.access(fallbackPath); + console.log( + "[stop-native-screen-recording] Recovering with fallback path:", + fallbackPath, + ); + if (fallbackSystemAudioPath || fallbackMicrophonePath) { + try { + await muxNativeMacRecordingWithAudio( + fallbackPath, + fallbackSystemAudioPath, + fallbackMicrophonePath, + ); + } catch (muxError) { + console.warn( + "Failed to mux recovered native macOS audio into capture:", + muxError, + ); + } } + return await finalizeStoredVideo(fallbackPath); + } catch { + // File doesn't exist or isn't accessible } - return await finalizeStoredVideo(fallbackPath); - } catch { - // File doesn't exist or isn't accessible } - } - const recovered = await recoverNativeMacCaptureOutput(); - if (recovered) { - return recovered; - } + const recovered = await recoverNativeMacCaptureOutput(); + if (recovered) { + return recovered; + } return { success: false, diff --git a/electron/ipc/register/sourceMapping.test.ts b/electron/ipc/register/sourceMapping.test.ts index d0b68e75..84351a50 100644 --- a/electron/ipc/register/sourceMapping.test.ts +++ b/electron/ipc/register/sourceMapping.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - getScreenSourceIdForDisplay, - LINUX_PORTAL_SCREEN_SOURCE_ID, -} from "./sourceMapping"; +import { getScreenSourceIdForDisplay, LINUX_PORTAL_SCREEN_SOURCE_ID } from "./sourceMapping"; describe("getScreenSourceIdForDisplay", () => { it("keeps the live Electron screen source when one is available", () => { @@ -47,4 +44,4 @@ describe("getScreenSourceIdForDisplay", () => { }), ).toBe("screen:fallback:42"); }); -}); \ No newline at end of file +}); diff --git a/electron/ipc/register/sourceMapping.ts b/electron/ipc/register/sourceMapping.ts index a61b4cf7..8b13501a 100644 --- a/electron/ipc/register/sourceMapping.ts +++ b/electron/ipc/register/sourceMapping.ts @@ -32,4 +32,4 @@ export function getScreenSourceIdForDisplay({ } return `screen:fallback:${displayId}`; -} \ No newline at end of file +} diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index 33c9ee74..a92e5580 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -18,13 +18,11 @@ import { reassertHudOverlayMousePassthrough } from "../../windows"; const execFileAsync = promisify(execFile); const SOURCE_LIST_CACHE_TTL_MS = 1200; -let sourceListCache: - | { - key: string; - expiresAt: number; - value: Array>; - } - | null = null; +let sourceListCache: { + key: string; + expiresAt: number; + value: Array>; +} | null = null; function normalizeDesktopSourceName(value: string) { return value.trim().replace(/\s+/g, " ").toLowerCase(); @@ -53,7 +51,11 @@ export function registerSourceHandlers({ thumbnailSize: opts?.thumbnailSize, fetchWindowIcons: opts?.fetchWindowIcons, }); - if (sourceListCache && sourceListCache.key === cacheKey && sourceListCache.expiresAt > Date.now()) { + if ( + sourceListCache && + sourceListCache.key === cacheKey && + sourceListCache.expiresAt > Date.now() + ) { return sourceListCache.value; } @@ -236,13 +238,12 @@ export function registerSourceHandlers({ thumbnail: electronWindowSource?.thumbnail ? electronWindowSource.thumbnail.toDataURL() : null, - appIcon: - includeWindowIcons - ? (source.appIcon ?? - (electronWindowSource?.appIcon - ? electronWindowSource.appIcon.toDataURL() - : null)) - : null, + appIcon: includeWindowIcons + ? (source.appIcon ?? + (electronWindowSource?.appIcon + ? electronWindowSource.appIcon.toDataURL() + : null)) + : null, appName: source.appName, windowTitle: source.windowTitle, sourceType: "window" as const, @@ -483,15 +484,17 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
-` +`; try { - await highlightWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) + await highlightWin.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(html)}`, + ); } catch (loadError) { if (!highlightWin.isDestroyed()) { - highlightWin.close() + highlightWin.close(); } - throw loadError + throw loadError; } // The highlight window appearing (even with focusable:false) can corrupt @@ -501,8 +504,8 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} reassertHudOverlayMousePassthrough(); const highlightCloseTimer = setTimeout(() => { - if (!highlightWin.isDestroyed()) highlightWin.close() - }, 1700) + if (!highlightWin.isDestroyed()) highlightWin.close(); + }, 1700); highlightWin.on("closed", () => { clearTimeout(highlightCloseTimer); @@ -511,32 +514,31 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} reassertHudOverlayMousePassthrough(); }); - return { success: true } - } catch (error) { - console.error('Failed to show source highlight:', error) - return { success: false } - } - }) + return { success: true }; + } catch (error) { + console.error("Failed to show source highlight:", error); + return { success: false }; + } + }); - ipcMain.handle('get-selected-source', () => { - return selectedSource - }) - - ipcMain.handle('open-source-selector', () => { - const sourceSelectorWin = getSourceSelectorWindow() - if (sourceSelectorWin) { - sourceSelectorWin.focus() - return - } - createSourceSelectorWindow() - }) - ipcMain.handle('switch-to-editor', () => { - console.log('[switch-to-editor] Opening editor window') - const sourceSelectorWin = getSourceSelectorWindow() - if (sourceSelectorWin && !sourceSelectorWin.isDestroyed()) { - sourceSelectorWin.close() - } - createEditorWindow() - }) + ipcMain.handle("get-selected-source", () => { + return selectedSource; + }); + ipcMain.handle("open-source-selector", () => { + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin) { + sourceSelectorWin.focus(); + return; + } + createSourceSelectorWindow(); + }); + ipcMain.handle("switch-to-editor", () => { + console.log("[switch-to-editor] Opening editor window"); + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin && !sourceSelectorWin.isDestroyed()) { + sourceSelectorWin.close(); + } + createEditorWindow(); + }); } diff --git a/electron/ipc/utils.ts b/electron/ipc/utils.ts index 3f2efb06..23960f20 100644 --- a/electron/ipc/utils.ts +++ b/electron/ipc/utils.ts @@ -129,4 +129,3 @@ export function approveUserPath(filePath: string | null | undefined): void { // Ignore invalid paths; later reads will surface the underlying error. } } - diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index c4755869..62ee78b5 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -1,35 +1,35 @@ { - "version": 1, - "platform": "win32", - "arch": "x64", - "helpers": { - "wgc-capture": { - "binaryName": "wgc-capture.exe", - "binarySha256": "4d89fdff8e3343998c7a3b4d75d964c1f75f93594aa097c6681e477d25ec9f01", - "sourceDir": "electron/native/wgc-capture", - "sourceFingerprint": "c6dac250c9d16f7aa881998353441b4ae3fb59731b802e3b8491a136e79726b0", - "updatedAt": "2026-07-11T11:58:45.856Z" - }, - "cursor-monitor": { - "binaryName": "cursor-monitor.exe", - "binarySha256": "f1d8f30e8d7bee19ecea91c9a90a95ea4824138b8336b18030fa0602a641d70d", - "sourceDir": "electron/native/cursor-monitor", - "sourceFingerprint": "45bb72e4039e061a354af87317d7c42ec3d2118369b3f4379046ff497b701e72", - "updatedAt": "2026-07-11T11:58:56.534Z" - }, - "recordly-gpu-export": { - "binaryName": "recordly-gpu-export.exe", - "binarySha256": "49a2ac588206305d0129e6263ce4be49780c50a9dc4efc7df63aad09178a919f", - "sourceDir": "electron/native/gpu-export-probe", - "sourceFingerprint": "37a5842eba63cdeccfddd02cc278a98207248fb0aa6b56153fb85ed152c908c1", - "updatedAt": "2026-07-11T11:58:51.659Z" - }, - "recordly-nvidia-cuda-compositor": { - "binaryName": "recordly-nvidia-cuda-compositor.exe", - "binarySha256": "250a3f8cac7c6ea38a873434d23d4b2be7d6555e42cc0b405aa26f774169159c", - "sourceDir": "electron/native/nvidia-cuda-compositor", - "sourceFingerprint": "de1219228ce326e96d1f4815a3763b10d5f235cc1286bc6542c99707a85d5947", - "updatedAt": "2026-05-27T11:29:32.957Z" - } - } + "version": 1, + "platform": "win32", + "arch": "x64", + "helpers": { + "wgc-capture": { + "binaryName": "wgc-capture.exe", + "binarySha256": "4d89fdff8e3343998c7a3b4d75d964c1f75f93594aa097c6681e477d25ec9f01", + "sourceDir": "electron/native/wgc-capture", + "sourceFingerprint": "c6dac250c9d16f7aa881998353441b4ae3fb59731b802e3b8491a136e79726b0", + "updatedAt": "2026-07-11T11:58:45.856Z" + }, + "cursor-monitor": { + "binaryName": "cursor-monitor.exe", + "binarySha256": "f1d8f30e8d7bee19ecea91c9a90a95ea4824138b8336b18030fa0602a641d70d", + "sourceDir": "electron/native/cursor-monitor", + "sourceFingerprint": "45bb72e4039e061a354af87317d7c42ec3d2118369b3f4379046ff497b701e72", + "updatedAt": "2026-07-11T11:58:56.534Z" + }, + "recordly-gpu-export": { + "binaryName": "recordly-gpu-export.exe", + "binarySha256": "49a2ac588206305d0129e6263ce4be49780c50a9dc4efc7df63aad09178a919f", + "sourceDir": "electron/native/gpu-export-probe", + "sourceFingerprint": "37a5842eba63cdeccfddd02cc278a98207248fb0aa6b56153fb85ed152c908c1", + "updatedAt": "2026-07-11T11:58:51.659Z" + }, + "recordly-nvidia-cuda-compositor": { + "binaryName": "recordly-nvidia-cuda-compositor.exe", + "binarySha256": "250a3f8cac7c6ea38a873434d23d4b2be7d6555e42cc0b405aa26f774169159c", + "sourceDir": "electron/native/nvidia-cuda-compositor", + "sourceFingerprint": "de1219228ce326e96d1f4815a3763b10d5f235cc1286bc6542c99707a85d5947", + "updatedAt": "2026-05-27T11:29:32.957Z" + } + } } diff --git a/electron/native/nvidia-cuda-compositor/render-tahoe-cursor-atlas.cjs b/electron/native/nvidia-cuda-compositor/render-tahoe-cursor-atlas.cjs index 5407d327..96c72c4b 100644 --- a/electron/native/nvidia-cuda-compositor/render-tahoe-cursor-atlas.cjs +++ b/electron/native/nvidia-cuda-compositor/render-tahoe-cursor-atlas.cjs @@ -4,31 +4,31 @@ const path = require("node:path"); const drawHeight = 256; const padding = 2; const cursorTypes = [ - "arrow", - "text", - "pointer", - "crosshair", - "open-hand", - "closed-hand", - "resize-ew", - "resize-ns", - "not-allowed", + "arrow", + "text", + "pointer", + "crosshair", + "open-hand", + "closed-hand", + "resize-ew", + "resize-ns", + "not-allowed", ]; const tahoeAssets = { - arrow: ["pointer-1__14-6.svg", 0.14, 0.06], - text: ["ibeam-1__50-44.svg", 0.5, 0.44], - pointer: ["pointinghand-1__40-10.svg", 0.4, 0.1], - crosshair: ["crosshair-1__50-50.svg", 0.5, 0.5], - "open-hand": ["openhand-1__55-57.svg", 0.55, 0.57], - "closed-hand": ["closedhand-1__50-46.svg", 0.5, 0.46], - "resize-ew": ["resizeeastwest-1__50-50.svg", 0.5, 0.5], - "resize-ns": ["resizenorthsouth-1__50-49.svg", 0.5, 0.49], - "not-allowed": ["notallowed-1__23-0.svg", 0.23, 0], + arrow: ["pointer-1__14-6.svg", 0.14, 0.06], + text: ["ibeam-1__50-44.svg", 0.5, 0.44], + pointer: ["pointinghand-1__40-10.svg", 0.4, 0.1], + crosshair: ["crosshair-1__50-50.svg", 0.5, 0.5], + "open-hand": ["openhand-1__55-57.svg", 0.55, 0.57], + "closed-hand": ["closedhand-1__50-46.svg", 0.5, 0.46], + "resize-ew": ["resizeeastwest-1__50-50.svg", 0.5, 0.5], + "resize-ns": ["resizenorthsouth-1__50-49.svg", 0.5, 0.49], + "not-allowed": ["notallowed-1__23-0.svg", 0.23, 0], }; function arg(name, fallback = "") { - const index = process.argv.indexOf(name); - return index >= 0 && index + 1 < process.argv.length ? process.argv[index + 1] : fallback; + const index = process.argv.indexOf(name); + return index >= 0 && index + 1 < process.argv.length ? process.argv[index + 1] : fallback; } const repoRoot = arg("--repo-root"); @@ -36,41 +36,43 @@ const atlasRgbaPath = arg("--output-rgba"); const atlasMetadataPath = arg("--output-metadata"); if (!repoRoot || !atlasRgbaPath || !atlasMetadataPath) { - console.error("Usage: electron render-tahoe-cursor-atlas.cjs --repo-root --output-rgba --output-metadata "); - process.exit(1); + console.error( + "Usage: electron render-tahoe-cursor-atlas.cjs --repo-root --output-rgba --output-metadata ", + ); + process.exit(1); } const assets = cursorTypes.map((type, index) => { - const [fileName, anchorX, anchorY] = tahoeAssets[type]; - return { - type, - index, - filePath: path.join(repoRoot, "src", "assets", "cursors", "tahoe", fileName), - anchorX, - anchorY, - }; + const [fileName, anchorX, anchorY] = tahoeAssets[type]; + return { + type, + index, + filePath: path.join(repoRoot, "src", "assets", "cursors", "tahoe", fileName), + anchorX, + anchorY, + }; }); app.disableHardwareAcceleration(); app.whenReady().then(async () => { - const window = new BrowserWindow({ - show: false, - width: 1, - height: 1, - webPreferences: { - nodeIntegration: true, - contextIsolation: false, - backgroundThrottling: false, - }, - }); + const window = new BrowserWindow({ + show: false, + width: 1, + height: 1, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + backgroundThrottling: false, + }, + }); - ipcMain.once("atlas-ready", (_event, result) => { - console.log(JSON.stringify(result)); - app.quit(); - }); + ipcMain.once("atlas-ready", (_event, result) => { + console.log(JSON.stringify(result)); + app.quit(); + }); - const html = ` + const html = ` `; - await window.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html)); + await window.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html)); }); diff --git a/electron/navigationPolicy.test.ts b/electron/navigationPolicy.test.ts index e021c96a..5c23c737 100644 --- a/electron/navigationPolicy.test.ts +++ b/electron/navigationPolicy.test.ts @@ -224,7 +224,9 @@ describe("navigation event handlers", () => { // history.replaceState() changes getURL() without crossing a document-navigation boundary. currentUrl = "file:///opt/Recordly/dist/index.html?windowType=source-selector"; - const willNavigate = on.mock.calls.find(([eventName]) => eventName === "will-navigate")?.[1]; + const willNavigate = on.mock.calls.find( + ([eventName]) => eventName === "will-navigate", + )?.[1]; if (typeof willNavigate !== "function") { throw new Error("will-navigate handler was not registered"); } @@ -250,7 +252,9 @@ describe("navigation event handlers", () => { ); const didNavigate = on.mock.calls.find(([eventName]) => eventName === "did-navigate")?.[1]; - const willNavigate = on.mock.calls.find(([eventName]) => eventName === "will-navigate")?.[1]; + const willNavigate = on.mock.calls.find( + ([eventName]) => eventName === "will-navigate", + )?.[1]; if (typeof didNavigate !== "function" || typeof willNavigate !== "function") { throw new Error("navigation handlers were not registered"); } diff --git a/electron/permissionPolicy.test.ts b/electron/permissionPolicy.test.ts index 45ffc82f..36b5dce6 100644 --- a/electron/permissionPolicy.test.ts +++ b/electron/permissionPolicy.test.ts @@ -169,17 +169,18 @@ describe("shouldGrantDisplayCapture", () => { ).toBe(true); }); - it.each(["null", "file://", "file:///"])( - "accepts Chromium's packaged file origin form: %s", - (securityOrigin) => { - expect( - shouldGrantDisplayCapture( - makeRequest({ currentDocumentUrl: FILE_HUD_URL, securityOrigin }), - TRUSTED_DOCUMENT_BASE_URLS, - ), - ).toBe(true); - }, - ); + it.each([ + "null", + "file://", + "file:///", + ])("accepts Chromium's packaged file origin form: %s", (securityOrigin) => { + expect( + shouldGrantDisplayCapture( + makeRequest({ currentDocumentUrl: FILE_HUD_URL, securityOrigin }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }); it.each([ ["another BrowserWindow", { isTrustedCaptureWindow: false }], diff --git a/scripts/benchmark-export-queues.mjs b/scripts/benchmark-export-queues.mjs index 6628b9d8..57b030c6 100644 --- a/scripts/benchmark-export-queues.mjs +++ b/scripts/benchmark-export-queues.mjs @@ -201,7 +201,12 @@ function parseExportQuality(rawValue) { return null; } - if (rawValue === "medium" || rawValue === "good" || rawValue === "high" || rawValue === "source") { + if ( + rawValue === "medium" || + rawValue === "good" || + rawValue === "high" || + rawValue === "source" + ) { return rawValue; } @@ -928,7 +933,9 @@ async function main() { printRequestedConfigTable(benchmarkRequests); if (providedInputPath) { - console.log(`[benchmark-export-queues] Using provided input video: ${providedInputPath}`); + console.log( + `[benchmark-export-queues] Using provided input video: ${providedInputPath}`, + ); await fs.copyFile(providedInputPath, inputPath); } else { console.log(`[benchmark-export-queues] Generating fixture video: ${inputPath}`); diff --git a/scripts/build-windows-capture.mjs b/scripts/build-windows-capture.mjs index 82f9c939..14cc8346 100644 --- a/scripts/build-windows-capture.mjs +++ b/scripts/build-windows-capture.mjs @@ -128,11 +128,14 @@ try { prefix: "build-windows-capture", clearCache: clearCmakeCache, configure: (generator, toolset) => - execSync(`${cmake} .. -G "${generator}" -A ${generatorArch}${toolset ? ` -T ${toolset}` : ""}`, { - cwd: buildDir, - stdio: "inherit", - timeout: 120000, - }), + execSync( + `${cmake} .. -G "${generator}" -A ${generatorArch}${toolset ? ` -T ${toolset}` : ""}`, + { + cwd: buildDir, + stdio: "inherit", + timeout: 120000, + }, + ), }); } catch (error) { console.error("[build-windows-capture] CMake configure failed:", error.message); diff --git a/scripts/build-windows-gpu-export.mjs b/scripts/build-windows-gpu-export.mjs index 27730fbf..63e756be 100644 --- a/scripts/build-windows-gpu-export.mjs +++ b/scripts/build-windows-gpu-export.mjs @@ -97,7 +97,9 @@ if (!cmake) { binaryName: "recordly-gpu-export.exe", }); if (!verification.ok) { - console.error(formatNativeHelperManifestWarning("build-windows-gpu-export", verification)); + console.error( + formatNativeHelperManifestWarning("build-windows-gpu-export", verification), + ); process.exit(1); } console.log(`[build-windows-gpu-export] Using bundled helper: ${bundledExePath}`); @@ -123,11 +125,14 @@ try { prefix: "build-windows-gpu-export", clearCache: clearCmakeCache, configure: (generator, toolset) => - execSync(`${cmake} .. -G "${generator}" -A ${generatorArch}${toolset ? ` -T ${toolset}` : ""}`, { - cwd: buildDir, - stdio: "inherit", - timeout: 120000, - }), + execSync( + `${cmake} .. -G "${generator}" -A ${generatorArch}${toolset ? ` -T ${toolset}` : ""}`, + { + cwd: buildDir, + stdio: "inherit", + timeout: 120000, + }, + ), }); } catch (error) { console.error("[build-windows-gpu-export] CMake configure failed:", error.message); diff --git a/scripts/create-release.mjs b/scripts/create-release.mjs index 3a586bde..25c36c6d 100644 --- a/scripts/create-release.mjs +++ b/scripts/create-release.mjs @@ -72,9 +72,12 @@ function loadNotes({ notes, notesFile }) { } function resolveGhBinary() { - const candidates = [process.env.GH_BIN, "gh", "/opt/homebrew/bin/gh", "/usr/local/bin/gh"].filter( - Boolean, - ); + const candidates = [ + process.env.GH_BIN, + "gh", + "/opt/homebrew/bin/gh", + "/usr/local/bin/gh", + ].filter(Boolean); for (const candidate of candidates) { try { diff --git a/scripts/normalize-electron-main-cjs.mjs b/scripts/normalize-electron-main-cjs.mjs index fcbca31f..082ab426 100644 --- a/scripts/normalize-electron-main-cjs.mjs +++ b/scripts/normalize-electron-main-cjs.mjs @@ -76,9 +76,7 @@ function convertNamedExports(namedSpec, indent = "") { } function convertExportLine(line) { - const singleLineMatch = line.match( - /^([ \t]*)export\s*\{\s*([^}]*)\s*\}\s*;?[ \t]*$/, - ); + const singleLineMatch = line.match(/^([ \t]*)export\s*\{\s*([^}]*)\s*\}\s*;?[ \t]*$/); if (singleLineMatch) { const [, indent, namedSpec] = singleLineMatch; return convertNamedExports(namedSpec, indent); @@ -235,10 +233,7 @@ function replaceImportMetaUrlInCode(line, state) { continue; } - if ( - line.startsWith(token, index) && - hasTokenBoundary(line, index, index + token.length) - ) { + if (line.startsWith(token, index) && hasTokenBoundary(line, index, index + token.length)) { normalizedLine += IMPORT_META_URL_CJS_REPLACEMENT; changed = true; index += token.length - 1; @@ -322,10 +317,7 @@ function containsImportMetaInCode(line, state) { continue; } - if ( - line.startsWith(token, index) && - hasTokenBoundary(line, index, index + token.length) - ) { + if (line.startsWith(token, index) && hasTokenBoundary(line, index, index + token.length)) { return true; } } diff --git a/src/components/launch/hooks/useHudBarDrag.ts b/src/components/launch/hooks/useHudBarDrag.ts index 6f612448..9c93aa7f 100644 --- a/src/components/launch/hooks/useHudBarDrag.ts +++ b/src/components/launch/hooks/useHudBarDrag.ts @@ -1,12 +1,8 @@ +import { type PointerEvent, type RefObject, useCallback, useEffect, useRef, useState } from "react"; import { - type PointerEvent, - type RefObject, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { mergeHudInteractiveBounds, shouldRestoreHudMousePassthroughAfterDrag } from "../hudMousePassthrough"; + mergeHudInteractiveBounds, + shouldRestoreHudMousePassthroughAfterDrag, +} from "../hudMousePassthrough"; import { clampHudOffsetToViewport } from "../hudViewportBounds"; const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 }; @@ -24,20 +20,17 @@ export function useHudBarDrag({ const [isHudDragging, setIsHudDragging] = useState(false); const hudBarTransformRef = useRef(null); const recordingHudOffsetRef = useRef(DEFAULT_RECORDING_HUD_OFFSET); - const hudDragStartRef = useRef< - | { - pointerId: number; - startX: number; - startY: number; - originX: number; - originY: number; - initialLeft: number; - initialTop: number; - hudWidth: number; - hudHeight: number; - } - | null - >(null); + const hudDragStartRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + originX: number; + originY: number; + initialLeft: number; + initialTop: number; + hudWidth: number; + hudHeight: number; + } | null>(null); const isHudDraggingRef = useRef(false); const hudDragMoveRafRef = useRef(null); const hudDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); @@ -55,11 +48,10 @@ export function useHudBarDrag({ } const bounds = hudBarRef.current.getBoundingClientRect(); - const nextOffset = clampHudOffsetToViewport( - recordingHudOffsetRef.current, - bounds, - { width: window.innerWidth, height: window.innerHeight }, - ); + const nextOffset = clampHudOffsetToViewport(recordingHudOffsetRef.current, bounds, { + width: window.innerWidth, + height: window.innerHeight, + }); if ( nextOffset.x === recordingHudOffsetRef.current.x && nextOffset.y === recordingHudOffsetRef.current.y @@ -90,32 +82,35 @@ export function useHudBarDrag({ }; }, [hudBarRef, keepHudBarInsideViewport]); - const handleHudBarPointerDown = useCallback((event: PointerEvent) => { - if (event.button !== 0) { - return; - } + const handleHudBarPointerDown = useCallback( + (event: PointerEvent) => { + if (event.button !== 0) { + return; + } - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - isHudDraggingRef.current = true; - setIsHudDragging(true); - window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); - if (!hudBarRef.current) { - return; - } - const hudRect = hudBarRef.current.getBoundingClientRect(); - hudDragStartRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: recordingHudOffsetRef.current.x, - originY: recordingHudOffsetRef.current.y, - initialLeft: hudRect.left, - initialTop: hudRect.top, - hudWidth: hudRect.width, - hudHeight: hudRect.height, - }; - }, [hudBarRef]); + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + isHudDraggingRef.current = true; + setIsHudDragging(true); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + if (!hudBarRef.current) { + return; + } + const hudRect = hudBarRef.current.getBoundingClientRect(); + hudDragStartRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: recordingHudOffsetRef.current.x, + originY: recordingHudOffsetRef.current.y, + initialLeft: hudRect.left, + initialTop: hudRect.top, + hudWidth: hudRect.width, + hudHeight: hudRect.height, + }; + }, + [hudBarRef], + ); const handleHudBarPointerMove = useCallback((event: PointerEvent) => { const dragState = hudDragStartRef.current; @@ -162,66 +157,75 @@ export function useHudBarDrag({ }); }, []); - const handleHudBarPointerUp = useCallback((event: PointerEvent) => { - const dragState = hudDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } + const handleHudBarPointerUp = useCallback( + (event: PointerEvent) => { + const dragState = hudDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } - const pointer = hudDragPendingPointerRef.current || { clientX: event.clientX, clientY: event.clientY }; - const deltaX = pointer.clientX - dragState.startX; - const deltaY = pointer.clientY - dragState.startY; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; + const pointer = hudDragPendingPointerRef.current || { + clientX: event.clientX, + clientY: event.clientY, + }; + const deltaX = pointer.clientX - dragState.startX; + const deltaY = pointer.clientY - dragState.startY; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; - const clampedLeft = Math.min( - Math.max(0, dragState.initialLeft + deltaX), - Math.max(0, viewportWidth - dragState.hudWidth), - ); - const clampedTop = Math.min( - Math.max(0, dragState.initialTop + deltaY), - Math.max(0, viewportHeight - dragState.hudHeight), - ); + const clampedLeft = Math.min( + Math.max(0, dragState.initialLeft + deltaX), + Math.max(0, viewportWidth - dragState.hudWidth), + ); + const clampedTop = Math.min( + Math.max(0, dragState.initialTop + deltaY), + Math.max(0, viewportHeight - dragState.hudHeight), + ); - recordingHudOffsetRef.current = { - x: dragState.originX + (clampedLeft - dragState.initialLeft), - y: dragState.originY + (clampedTop - dragState.initialTop), - }; + recordingHudOffsetRef.current = { + x: dragState.originX + (clampedLeft - dragState.initialLeft), + y: dragState.originY + (clampedTop - dragState.initialTop), + }; - if (hudDragMoveRafRef.current !== null) { - cancelAnimationFrame(hudDragMoveRafRef.current); - hudDragMoveRafRef.current = null; - } - hudDragPendingPointerRef.current = null; + if (hudDragMoveRafRef.current !== null) { + cancelAnimationFrame(hudDragMoveRafRef.current); + hudDragMoveRafRef.current = null; + } + hudDragPendingPointerRef.current = null; - hudDragStartRef.current = null; - const wasDragging = isHudDraggingRef.current; - isHudDraggingRef.current = false; - setRecordingHudOffset({ ...recordingHudOffsetRef.current }); - setIsHudDragging(false); - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - const hudBounds = mergeHudInteractiveBounds( - [ - hudContentRef.current?.getBoundingClientRect(), - hudBarRef.current?.getBoundingClientRect(), - recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(), - ].map((bounds) => - bounds - ? { - left: bounds.left, - top: bounds.top, - right: bounds.right, - bottom: bounds.bottom, - } - : null, - ), - ); - if (wasDragging && shouldRestoreHudMousePassthroughAfterDrag(hudBounds, event.clientX, event.clientY)) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }, [hudBarRef, hudContentRef, recordingWebcamPreviewContainerRef]); + hudDragStartRef.current = null; + const wasDragging = isHudDraggingRef.current; + isHudDraggingRef.current = false; + setRecordingHudOffset({ ...recordingHudOffsetRef.current }); + setIsHudDragging(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + const hudBounds = mergeHudInteractiveBounds( + [ + hudContentRef.current?.getBoundingClientRect(), + hudBarRef.current?.getBoundingClientRect(), + recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(), + ].map((bounds) => + bounds + ? { + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + } + : null, + ), + ); + if ( + wasDragging && + shouldRestoreHudMousePassthroughAfterDrag(hudBounds, event.clientX, event.clientY) + ) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, + [hudBarRef, hudContentRef, recordingWebcamPreviewContainerRef], + ); useEffect(() => { return () => { diff --git a/src/components/launch/hooks/useWebcamPreviewOverlay.ts b/src/components/launch/hooks/useWebcamPreviewOverlay.ts index 7c93899a..50d5c60e 100644 --- a/src/components/launch/hooks/useWebcamPreviewOverlay.ts +++ b/src/components/launch/hooks/useWebcamPreviewOverlay.ts @@ -61,32 +61,29 @@ export function useWebcamPreviewOverlay({ } }, [webcamEnabled]); - const handleWebcamPreviewPointerDown = useCallback( - (event: PointerEvent) => { - if (event.button !== 0) { - return; - } + const handleWebcamPreviewPointerDown = useCallback((event: PointerEvent) => { + if (event.button !== 0) { + return; + } - const previewRect = event.currentTarget.getBoundingClientRect(); + const previewRect = event.currentTarget.getBoundingClientRect(); - event.preventDefault(); - window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); - webcamPreviewDragStartRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: webcamPreviewOffsetRef.current.x, - originY: webcamPreviewOffsetRef.current.y, - initialLeft: previewRect.left, - initialTop: previewRect.top, - previewWidth: previewRect.width, - previewHeight: previewRect.height, - dragging: false, - }; - event.currentTarget.setPointerCapture(event.pointerId); - }, - [], - ); + event.preventDefault(); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + webcamPreviewDragStartRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: webcamPreviewOffsetRef.current.x, + originY: webcamPreviewOffsetRef.current.y, + initialLeft: previewRect.left, + initialTop: previewRect.top, + previewWidth: previewRect.width, + previewHeight: previewRect.height, + dragging: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, []); const handleWebcamPreviewPointerMove = useCallback((event: PointerEvent) => { const dragState = webcamPreviewDragStartRef.current; @@ -225,12 +222,12 @@ export function useWebcamPreviewOverlay({ width: { ideal: 320 }, height: { ideal: 320 }, frameRate: { ideal: 24, max: 30 }, - } + } : { width: { ideal: 320 }, height: { ideal: 320 }, frameRate: { ideal: 24, max: 30 }, - }, + }, audio: false, }); diff --git a/src/components/launch/popovers/LaunchPopoverCoordinator.tsx b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx index 55aa5f47..7087f78c 100644 --- a/src/components/launch/popovers/LaunchPopoverCoordinator.tsx +++ b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx @@ -1,4 +1,12 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; interface LaunchPopoverCoordinatorValue { openId: string | null; @@ -48,7 +56,9 @@ export function LaunchPopoverCoordinatorProvider({ children }: { children: React export function useLaunchPopoverCoordinator() { const context = useContext(LaunchPopoverCoordinatorContext); if (!context) { - throw new Error("useLaunchPopoverCoordinator must be used within LaunchPopoverCoordinatorProvider"); + throw new Error( + "useLaunchPopoverCoordinator must be used within LaunchPopoverCoordinatorProvider", + ); } return context; } diff --git a/src/components/launch/popovers/MicPopover.tsx b/src/components/launch/popovers/MicPopover.tsx index 02247d66..cc5eb0f0 100644 --- a/src/components/launch/popovers/MicPopover.tsx +++ b/src/components/launch/popovers/MicPopover.tsx @@ -53,7 +53,9 @@ export function MicPopover({ >
{t("recording.microphone")}
: } + icon={ + systemAudioEnabled ? : + } selected={systemAudioEnabled} onClick={onToggleSystemAudio} > @@ -73,7 +75,9 @@ export function MicPopover({ )} {!microphoneEnabled && ( -
{t("recording.selectMicToEnable")}
+
+ {t("recording.selectMicToEnable")} +
)} {devices.map((device) => ( onSelectDevice(device.deviceId)} /> ))} {devices.length === 0 && ( -
{t("recording.noMicrophonesFound")}
+
+ {t("recording.noMicrophonesFound")} +
)} ); diff --git a/src/components/launch/popovers/PopoverScaffold.tsx b/src/components/launch/popovers/PopoverScaffold.tsx index be349192..3be3dfcc 100644 --- a/src/components/launch/popovers/PopoverScaffold.tsx +++ b/src/components/launch/popovers/PopoverScaffold.tsx @@ -54,7 +54,9 @@ export function MicDeviceRow({ className={`${styles.ddItem} ${selected ? styles.ddItemSelected : ""}`} onClick={onSelect} > - {selected ? : } + + {selected ? : } + {device.label} diff --git a/src/components/launch/popovers/WebcamPopover.tsx b/src/components/launch/popovers/WebcamPopover.tsx index 945ffac6..0c04ed89 100644 --- a/src/components/launch/popovers/WebcamPopover.tsx +++ b/src/components/launch/popovers/WebcamPopover.tsx @@ -66,15 +66,20 @@ export function WebcamPopover({ {webcamEnabled && ( <> - } onClick={() => { - onDisableWebcam(); - requestClose(POPOVER_ID); - }}> + } + onClick={() => { + onDisableWebcam(); + requestClose(POPOVER_ID); + }} + > {t("recording.turnOffWebcam")} {canToggleFloatingPreview ? ( : } + icon={ + showFloatingWebcamPreview ? : + } selected={showFloatingWebcamPreview} onClick={onToggleFloatingPreview} > @@ -86,7 +91,9 @@ export function WebcamPopover({ )} {!webcamEnabled && ( -
{t("recording.selectWebcamToEnable")}
+
+ {t("recording.selectWebcamToEnable")} +
)} {showWebcamControls && (
@@ -106,7 +113,8 @@ export function WebcamPopover({ key={device.deviceId} icon={ webcamEnabled && - (webcamDeviceId === device.deviceId || selectedVideoDeviceId === device.deviceId) ? ( + (webcamDeviceId === device.deviceId || + selectedVideoDeviceId === device.deviceId) ? (