diff --git a/.eslintrc.cjs b/.eslintrc.cjs index dc6ce092..59670cb2 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,19 +1,15 @@ module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', - ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - plugins: ['react-refresh'], - rules: { - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, -} - + root: true, + env: { browser: true, es2020: true }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + ], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parser: "@typescript-eslint/parser", + plugins: ["react-refresh"], + rules: { + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + }, +}; diff --git a/.gitignore b/.gitignore index bc75a52a..6a4a0e49 100644 --- a/.gitignore +++ b/.gitignore @@ -3,32 +3,32 @@ logs *.log npm-debug.log* yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-electron -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? -release/** -*.kiro/ -# npx electron-builder --mac --win -.tmp/ -.history/ -*.tsbuildinfo +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-electron +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +release/** +*.kiro/ +# npx electron-builder --mac --win +.tmp/ +.history/ +*.tsbuildinfo vite.config.js vite.config.d.ts diff --git a/biome.json b/biome.json index 634484cc..33c079ae 100644 --- a/biome.json +++ b/biome.json @@ -162,4 +162,3 @@ "actions": { "source": { "organizeImports": "on" } } } } - diff --git a/components.json b/components.json index d6ebdc92..f6dc1d59 100644 --- a/components.json +++ b/components.json @@ -1,23 +1,22 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "tailwind.config.cjs", - "css": "src/index.css", - "baseColor": "stone", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "registries": {} + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.cjs", + "css": "src/index.css", + "baseColor": "stone", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} } - diff --git a/electron/appPaths.ts b/electron/appPaths.ts index 75f7b51b..68d6cfc3 100644 --- a/electron/appPaths.ts +++ b/electron/appPaths.ts @@ -8,4 +8,4 @@ if (process.env["VITE_DEV_SERVER_URL"]) { } export const USER_DATA_PATH = app.getPath("userData"); -export const RECORDINGS_DIR = path.join(USER_DATA_PATH, "recordings"); \ No newline at end of file +export const RECORDINGS_DIR = path.join(USER_DATA_PATH, "recordings"); diff --git a/electron/cursorHider.ts b/electron/cursorHider.ts index 228aa7dc..35dbef8b 100644 --- a/electron/cursorHider.ts +++ b/electron/cursorHider.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process' +import { spawnSync } from "node:child_process"; const PY_HIDE_WIN = ` import ctypes, sys @@ -25,7 +25,7 @@ for _ in range(32): user32.ShowCursor(False) sys.exit(0) -`.trim() +`.trim(); const PY_SHOW_WIN = ` import ctypes, sys @@ -52,90 +52,92 @@ for _ in range(32): user32.ShowCursor(True) sys.exit(0) -`.trim() +`.trim(); function getPowerShellCommand(show: boolean) { - const desiredFlag = show ? 1 : 0 - const showLiteral = show ? '$true' : '$false' - - return [ - '$signature = @"', - 'using System;', - 'using System.Runtime.InteropServices;', - 'public struct POINT { public int X; public int Y; }', - 'public struct CURSORINFO { public int cbSize; public int flags; public IntPtr hCursor; public POINT ptScreenPos; }', - 'public static class CursorNative {', - ' [DllImport("user32.dll")] public static extern int ShowCursor(bool show);', - ' [DllImport("user32.dll")] public static extern bool GetCursorInfo(ref CURSORINFO info);', - '}', - '"@;', - 'Add-Type -TypeDefinition $signature -Language CSharp -ErrorAction SilentlyContinue | Out-Null;', - '$info = New-Object CURSORINFO;', - '$info.cbSize = [Runtime.InteropServices.Marshal]::SizeOf([type]CURSORINFO);', - 'for ($i = 0; $i -lt 32; $i++) {', - ' if ([CursorNative]::GetCursorInfo([ref]$info) -and (($info.flags -band 1) -eq ' + desiredFlag + ')) { exit 0 }', - ' [CursorNative]::ShowCursor(' + showLiteral + ') | Out-Null;', - '}', - 'exit 0', - ].join(' ') + const desiredFlag = show ? 1 : 0; + const showLiteral = show ? "$true" : "$false"; + + return [ + '$signature = @"', + "using System;", + "using System.Runtime.InteropServices;", + "public struct POINT { public int X; public int Y; }", + "public struct CURSORINFO { public int cbSize; public int flags; public IntPtr hCursor; public POINT ptScreenPos; }", + "public static class CursorNative {", + ' [DllImport("user32.dll")] public static extern int ShowCursor(bool show);', + ' [DllImport("user32.dll")] public static extern bool GetCursorInfo(ref CURSORINFO info);', + "}", + '"@;', + "Add-Type -TypeDefinition $signature -Language CSharp -ErrorAction SilentlyContinue | Out-Null;", + "$info = New-Object CURSORINFO;", + "$info.cbSize = [Runtime.InteropServices.Marshal]::SizeOf([type]CURSORINFO);", + "for ($i = 0; $i -lt 32; $i++) {", + " if ([CursorNative]::GetCursorInfo([ref]$info) -and (($info.flags -band 1) -eq " + + desiredFlag + + ")) { exit 0 }", + " [CursorNative]::ShowCursor(" + showLiteral + ") | Out-Null;", + "}", + "exit 0", + ].join(" "); } function runPythonSnippet(code: string) { - for (const executable of ['python', 'python3', 'py']) { - const result = spawnSync(executable, ['-c', code], { timeout: 5000 }) - if (!result.error && result.status === 0) { - return true - } - } - - return false + for (const executable of ["python", "python3", "py"]) { + const result = spawnSync(executable, ["-c", code], { timeout: 5000 }); + if (!result.error && result.status === 0) { + return true; + } + } + + return false; } function runPowerShellSnippet(command: string) { - const result = spawnSync( - 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', command], - { timeout: 8000 }, - ) + const result = spawnSync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", command], + { timeout: 8000 }, + ); - return !result.error && result.status === 0 + return !result.error && result.status === 0; } -let cursorHidden = false +let cursorHidden = false; export function hideCursor() { - if (process.platform !== 'win32' || cursorHidden) { - return false - } - - try { - const didHide = runPythonSnippet(PY_HIDE_WIN) - || runPowerShellSnippet(getPowerShellCommand(false)) - - if (didHide) { - cursorHidden = true - } - - return didHide - } catch (error) { - console.error('[cursorHider] Failed to hide Windows cursor:', error) - return false - } + if (process.platform !== "win32" || cursorHidden) { + return false; + } + + try { + const didHide = + runPythonSnippet(PY_HIDE_WIN) || runPowerShellSnippet(getPowerShellCommand(false)); + + if (didHide) { + cursorHidden = true; + } + + return didHide; + } catch (error) { + console.error("[cursorHider] Failed to hide Windows cursor:", error); + return false; + } } export function showCursor() { - if (process.platform !== 'win32' || !cursorHidden) { - return false - } - - try { - const didShow = runPythonSnippet(PY_SHOW_WIN) - || runPowerShellSnippet(getPowerShellCommand(true)) - return didShow - } catch (error) { - console.error('[cursorHider] Failed to show Windows cursor:', error) - return false - } finally { - cursorHidden = false - } -} \ No newline at end of file + if (process.platform !== "win32" || !cursorHidden) { + return false; + } + + try { + const didShow = + runPythonSnippet(PY_SHOW_WIN) || runPowerShellSnippet(getPowerShellCommand(true)); + return didShow; + } catch (error) { + console.error("[cursorHider] Failed to show Windows cursor:", error); + return false; + } finally { + cursorHidden = false; + } +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 98675762..60d0bd25 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -88,7 +88,13 @@ interface Window { microphoneDeviceId?: string; microphoneLabel?: string; }, - ) => Promise<{ success: boolean; path?: string; message?: string; error?: string; userNotified?: boolean }>; + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + userNotified?: boolean; + }>; stopNativeScreenRecording: () => Promise<{ success: boolean; path?: string; @@ -244,9 +250,7 @@ interface Window { }>; getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; clearCurrentVideoPath: () => Promise<{ success: boolean }>; - deleteRecordingFile: ( - filePath: string, - ) => Promise<{ success: boolean; error?: string }>; + deleteRecordingFile: (filePath: string) => Promise<{ success: boolean; error?: string }>; saveProjectFile: ( projectData: unknown, suggestedName?: string, @@ -320,12 +324,14 @@ interface Window { previewUpdateToast: () => Promise<{ success: boolean }>; checkForAppUpdates: () => Promise<{ success: boolean; logPath: string }>; onUpdateToastStateChanged: (callback: (payload: UpdateToastState | null) => void) => () => void; - onUpdateReadyToast: (callback: (payload: { - version: string; - detail: string; - delayMs: number; - isPreview?: boolean; - }) => void) => () => void; + onUpdateReadyToast: ( + callback: (payload: { + version: string; + detail: string; + delayMs: number; + isPreview?: boolean; + }) => void, + ) => () => void; onMenuLoadProject: (callback: () => void) => () => void; onMenuSaveProject: (callback: () => void) => () => void; onMenuSaveProjectAs: (callback: () => void) => () => void; @@ -353,7 +359,9 @@ interface Window { setHasUnsavedChanges: (hasChanges: boolean) => void; onRequestSaveBeforeClose: (callback: () => Promise) => () => void; isNativeWindowsCaptureAvailable: () => Promise<{ available: boolean }>; - muxNativeWindowsRecording: (pauseSegments?: Array<{ startMs: number; endMs: number }>) => Promise<{ + muxNativeWindowsRecording: ( + pauseSegments?: Array<{ startMs: number; endMs: number }>, + ) => Promise<{ success: boolean; path?: string; message?: string; @@ -364,8 +372,17 @@ interface Window { /** Hide the OS cursor before browser capture starts. */ hideOsCursor: () => Promise<{ success: boolean }>; /** Recording preferences (mic, system audio) */ - getRecordingPreferences: () => Promise<{ success: boolean; microphoneEnabled: boolean; microphoneDeviceId?: string; systemAudioEnabled: boolean }>; - setRecordingPreferences: (prefs: { microphoneEnabled?: boolean; microphoneDeviceId?: string; systemAudioEnabled?: boolean }) => Promise<{ success: boolean; error?: string }>; + getRecordingPreferences: () => Promise<{ + success: boolean; + microphoneEnabled: boolean; + microphoneDeviceId?: string; + systemAudioEnabled: boolean; + }>; + setRecordingPreferences: (prefs: { + microphoneEnabled?: boolean; + microphoneDeviceId?: string; + systemAudioEnabled?: boolean; + }) => Promise<{ success: boolean; error?: string }>; /** Countdown timer before recording */ getCountdownDelay: () => Promise<{ success: boolean; delay: number }>; setCountdownDelay: (delay: number) => Promise<{ success: boolean; error?: string }>; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index c342abd4..b0b90861 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,869 +1,915 @@ -import type { ChildProcessWithoutNullStreams } from 'node:child_process' -import { execFile, spawn, spawnSync } from 'node:child_process' -import { createWriteStream, constants as fsConstants, existsSync } from 'node:fs' -import fs from 'node:fs/promises' -import { get as httpsGet } from 'node:https' -import { createRequire } from 'node:module' -import path from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { promisify } from 'node:util' -import type { SaveDialogOptions } from 'electron' -import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, shell, systemPreferences } from 'electron' -import { RECORDINGS_DIR, USER_DATA_PATH } from '../appPaths' -import { hideCursor, showCursor } from '../cursorHider' -import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from '../windows' -import { resolveWindowsCaptureDisplay } from './windowsCaptureSelection' - -const execFileAsync = promisify(execFile) -const nodeRequire = createRequire(import.meta.url) - -const PROJECT_FILE_EXTENSION = 'recordly' -const LEGACY_PROJECT_FILE_EXTENSIONS = ['openscreen'] -const PROJECTS_DIRECTORY_NAME = 'Projects' -const PROJECT_THUMBNAIL_SUFFIX = '.preview.png' -const RECENT_PROJECTS_FILE = path.join(USER_DATA_PATH, 'recent-projects.json') -const MAX_RECENT_PROJECTS = 16 -const SHORTCUTS_FILE = path.join(USER_DATA_PATH, 'shortcuts.json') -const RECORDINGS_SETTINGS_FILE = path.join(USER_DATA_PATH, 'recordings-settings.json') -const COUNTDOWN_SETTINGS_FILE = path.join(USER_DATA_PATH, 'countdown-settings.json') -const AUTO_RECORDING_PREFIX = 'recording-' -const AUTO_RECORDING_RETENTION_COUNT = 20 -const AUTO_RECORDING_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000 -const ALLOW_RECORDLY_WINDOW_CAPTURE = Boolean(process.env['VITE_DEV_SERVER_URL']) -const RECORDING_SESSION_MANIFEST_SUFFIX = '.recordly-session.json' -const WHISPER_MODEL_DOWNLOAD_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin' -const WHISPER_MODEL_DIR = path.join(USER_DATA_PATH, 'whisper') -const WHISPER_SMALL_MODEL_PATH = path.join(WHISPER_MODEL_DIR, 'ggml-small.bin') +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { execFile, spawn, spawnSync } from "node:child_process"; +import { createWriteStream, existsSync, constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import { get as httpsGet } from "node:https"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import type { SaveDialogOptions } from "electron"; +import { + app, + BrowserWindow, + desktopCapturer, + dialog, + ipcMain, + shell, + systemPreferences, +} from "electron"; +import { RECORDINGS_DIR, USER_DATA_PATH } from "../appPaths"; +import { hideCursor, showCursor } from "../cursorHider"; +import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../windows"; +import { resolveWindowsCaptureDisplay } from "./windowsCaptureSelection"; + +const execFileAsync = promisify(execFile); +const nodeRequire = createRequire(import.meta.url); + +const PROJECT_FILE_EXTENSION = "recordly"; +const LEGACY_PROJECT_FILE_EXTENSIONS = ["openscreen"]; +const PROJECTS_DIRECTORY_NAME = "Projects"; +const PROJECT_THUMBNAIL_SUFFIX = ".preview.png"; +const RECENT_PROJECTS_FILE = path.join(USER_DATA_PATH, "recent-projects.json"); +const MAX_RECENT_PROJECTS = 16; +const SHORTCUTS_FILE = path.join(USER_DATA_PATH, "shortcuts.json"); +const RECORDINGS_SETTINGS_FILE = path.join(USER_DATA_PATH, "recordings-settings.json"); +const COUNTDOWN_SETTINGS_FILE = path.join(USER_DATA_PATH, "countdown-settings.json"); +const AUTO_RECORDING_PREFIX = "recording-"; +const AUTO_RECORDING_RETENTION_COUNT = 20; +const AUTO_RECORDING_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; +const ALLOW_RECORDLY_WINDOW_CAPTURE = Boolean(process.env["VITE_DEV_SERVER_URL"]); +const RECORDING_SESSION_MANIFEST_SUFFIX = ".recordly-session.json"; +const WHISPER_MODEL_DOWNLOAD_URL = + "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin"; +const WHISPER_MODEL_DIR = path.join(USER_DATA_PATH, "whisper"); +const WHISPER_SMALL_MODEL_PATH = path.join(WHISPER_MODEL_DIR, "ggml-small.bin"); const COMPANION_AUDIO_LAYOUTS = [ - { platform: 'mac' as const, systemSuffix: '.system.m4a', micSuffix: '.mic.m4a' }, - { platform: 'win' as const, systemSuffix: '.system.wav', micSuffix: '.mic.wav' }, -] + { platform: "mac" as const, systemSuffix: ".system.m4a", micSuffix: ".mic.m4a" }, + { platform: "win" as const, systemSuffix: ".system.wav", micSuffix: ".mic.wav" }, +]; function getAssetRootPath() { - if (app.isPackaged) { - return path.join(process.resourcesPath, 'assets') - } + if (app.isPackaged) { + return path.join(process.resourcesPath, "assets"); + } - return path.join(app.getAppPath(), 'public') + return path.join(app.getAppPath(), "public"); } function getScreen() { - if (!app.isReady()) { - throw new Error("getScreen() called before app is ready. Ensure all screen access happens after app.whenReady().") - } - return nodeRequire('electron').screen as typeof import('electron').screen + if (!app.isReady()) { + throw new Error( + "getScreen() called before app is ready. Ensure all screen access happens after app.whenReady().", + ); + } + return nodeRequire("electron").screen as typeof import("electron").screen; } function normalizeRecordingTimeOffsetMs(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) - ? Math.round(value) - : 0 + return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0; } function broadcastSelectedSourceChange() { - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send('selected-source-changed', selectedSource) - } - } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("selected-source-changed", selectedSource); + } + } } type SelectedSource = { - id?: string - name: string - display_id?: string - sourceType?: 'screen' | 'window' - appName?: string - windowTitle?: string - [key: string]: unknown -} + id?: string; + name: string; + display_id?: string; + sourceType?: "screen" | "window"; + appName?: string; + windowTitle?: string; + [key: string]: unknown; +}; type NativeMacRecordingOptions = { - capturesSystemAudio?: boolean - capturesMicrophone?: boolean - microphoneDeviceId?: string - microphoneLabel?: string -} + capturesSystemAudio?: boolean; + capturesMicrophone?: boolean; + microphoneDeviceId?: string; + microphoneLabel?: string; +}; type WindowBounds = { - x: number - y: number - width: number - height: number -} + x: number; + y: number; + width: number; + height: number; +}; type NativeCaptureDiagnostics = { - backend: 'windows-wgc' | 'mac-screencapturekit' | 'browser-store' | 'ffmpeg' - phase: 'availability' | 'start' | 'stop' | 'mux' - timestamp: string - sourceId?: string | null - sourceType?: SelectedSource['sourceType'] | 'unknown' - displayId?: number | null - displayBounds?: WindowBounds | null - windowHandle?: number | null - helperPath?: string | null - outputPath?: string | null - systemAudioPath?: string | null - microphonePath?: string | null - osRelease?: string - supported?: boolean - helperExists?: boolean - fileSizeBytes?: number | null - processOutput?: string - error?: string -} + backend: "windows-wgc" | "mac-screencapturekit" | "browser-store" | "ffmpeg"; + phase: "availability" | "start" | "stop" | "mux"; + timestamp: string; + sourceId?: string | null; + sourceType?: SelectedSource["sourceType"] | "unknown"; + displayId?: number | null; + displayBounds?: WindowBounds | null; + windowHandle?: number | null; + helperPath?: string | null; + outputPath?: string | null; + systemAudioPath?: string | null; + microphonePath?: string | null; + osRelease?: string; + supported?: boolean; + helperExists?: boolean; + fileSizeBytes?: number | null; + processOutput?: string; + error?: string; +}; type RecordingSessionData = { - videoPath: string - webcamPath?: string | null - timeOffsetMs?: number -} + videoPath: string; + webcamPath?: string | null; + timeOffsetMs?: number; +}; type PauseSegment = { - startMs: number - endMs: number -} + startMs: number; + endMs: number; +}; type RecordingSessionManifest = { - version: 1 | 2 - videoFileName: string - webcamFileName?: string | null - timeOffsetMs?: number -} + version: 1 | 2; + videoFileName: string; + webcamFileName?: string | null; + timeOffsetMs?: number; +}; type ProjectLibraryEntry = { - path: string - name: string - updatedAt: number - thumbnailPath: string | null - isCurrent: boolean - isInProjectsDirectory: boolean -} - -let selectedSource: SelectedSource | null = null -let currentProjectPath: string | null = null -let nativeScreenRecordingActive = false -let currentVideoPath: string | null = null -let currentRecordingSession: RecordingSessionData | null = null -let nativeCaptureProcess: ChildProcessWithoutNullStreams | null = null -let nativeCaptureOutputBuffer = '' -let nativeCaptureTargetPath: string | null = null -let nativeCaptureStopRequested = false -let nativeCaptureSystemAudioPath: string | null = null -let nativeCaptureMicrophonePath: string | null = null -let nativeCapturePaused = false -let nativeCursorMonitorProcess: ChildProcessWithoutNullStreams | null = null -let nativeCursorMonitorOutputBuffer = '' -let windowsCaptureProcess: ChildProcessWithoutNullStreams | null = null -let windowsCaptureOutputBuffer = '' -let windowsCaptureTargetPath: string | null = null -let windowsNativeCaptureActive = false -let windowsCaptureStopRequested = false -let windowsCapturePaused = false -let windowsSystemAudioPath: string | null = null -let windowsMicAudioPath: string | null = null -let windowsPendingVideoPath: string | null = null -let lastNativeCaptureDiagnostics: NativeCaptureDiagnostics | null = null -let ffmpegScreenRecordingActive = false -let ffmpegCaptureProcess: ChildProcessWithoutNullStreams | null = null -let ffmpegCaptureOutputBuffer = '' -let ffmpegCaptureTargetPath: string | null = null -let customRecordingsDir: string | null = null -let recordingsDirLoaded = false -let cachedSystemCursorAssets: Record | null = null -let cachedSystemCursorAssetsSourceMtimeMs: number | null = null -let countdownTimer: ReturnType | null = null -let countdownCancelled = false -let countdownInProgress = false -let countdownRemaining: number | null = null + path: string; + name: string; + updatedAt: number; + thumbnailPath: string | null; + isCurrent: boolean; + isInProjectsDirectory: boolean; +}; + +let selectedSource: SelectedSource | null = null; +let currentProjectPath: string | null = null; +let nativeScreenRecordingActive = false; +let currentVideoPath: string | null = null; +let currentRecordingSession: RecordingSessionData | null = null; +let nativeCaptureProcess: ChildProcessWithoutNullStreams | null = null; +let nativeCaptureOutputBuffer = ""; +let nativeCaptureTargetPath: string | null = null; +let nativeCaptureStopRequested = false; +let nativeCaptureSystemAudioPath: string | null = null; +let nativeCaptureMicrophonePath: string | null = null; +let nativeCapturePaused = false; +let nativeCursorMonitorProcess: ChildProcessWithoutNullStreams | null = null; +let nativeCursorMonitorOutputBuffer = ""; +let windowsCaptureProcess: ChildProcessWithoutNullStreams | null = null; +let windowsCaptureOutputBuffer = ""; +let windowsCaptureTargetPath: string | null = null; +let windowsNativeCaptureActive = false; +let windowsCaptureStopRequested = false; +let windowsCapturePaused = false; +let windowsSystemAudioPath: string | null = null; +let windowsMicAudioPath: string | null = null; +let windowsPendingVideoPath: string | null = null; +let lastNativeCaptureDiagnostics: NativeCaptureDiagnostics | null = null; +let ffmpegScreenRecordingActive = false; +let ffmpegCaptureProcess: ChildProcessWithoutNullStreams | null = null; +let ffmpegCaptureOutputBuffer = ""; +let ffmpegCaptureTargetPath: string | null = null; +let customRecordingsDir: string | null = null; +let recordingsDirLoaded = false; +let cachedSystemCursorAssets: Record | null = null; +let cachedSystemCursorAssetsSourceMtimeMs: number | null = null; +let countdownTimer: ReturnType | null = null; +let countdownCancelled = false; +let countdownInProgress = false; +let countdownRemaining: number | null = null; type SystemCursorAsset = { - dataUrl: string - hotspotX: number - hotspotY: number - width: number - height: number -} - -type CursorVisualType = 'arrow' | 'text' | 'pointer' | 'crosshair' | 'open-hand' | 'closed-hand' | 'resize-ew' | 'resize-ns' | 'not-allowed' - -let currentCursorVisualType: CursorVisualType | undefined = undefined + dataUrl: string; + hotspotX: number; + hotspotY: number; + width: number; + height: number; +}; + +type CursorVisualType = + | "arrow" + | "text" + | "pointer" + | "crosshair" + | "open-hand" + | "closed-hand" + | "resize-ew" + | "resize-ns" + | "not-allowed"; + +let currentCursorVisualType: CursorVisualType | undefined = undefined; /** Returns the currently selected source ID for setDisplayMediaRequestHandler */ export function getSelectedSourceId(): string | null { - return selectedSource?.id as string | null ?? null + return (selectedSource?.id as string | null) ?? null; } export function killWindowsCaptureProcess() { - if (windowsCaptureProcess) { - try { windowsCaptureProcess.kill() } catch { /* ignore */ } - windowsCaptureProcess = null - windowsCaptureTargetPath = null - windowsNativeCaptureActive = false - nativeScreenRecordingActive = false - windowsCaptureStopRequested = false - windowsCapturePaused = false - windowsSystemAudioPath = null - windowsMicAudioPath = null - windowsPendingVideoPath = null - } + if (windowsCaptureProcess) { + try { + windowsCaptureProcess.kill(); + } catch { + /* ignore */ + } + windowsCaptureProcess = null; + windowsCaptureTargetPath = null; + windowsNativeCaptureActive = false; + nativeScreenRecordingActive = false; + windowsCaptureStopRequested = false; + windowsCapturePaused = false; + windowsSystemAudioPath = null; + windowsMicAudioPath = null; + windowsPendingVideoPath = null; + } } function normalizePath(filePath: string) { - return path.resolve(filePath) + return path.resolve(filePath); } function normalizeDesktopSourceName(value: string) { - return value.trim().replace(/\s+/g, ' ').toLowerCase() + return value.trim().replace(/\s+/g, " ").toLowerCase(); } function hasUsableSourceThumbnail( - thumbnail: - | { - isEmpty: () => boolean - getSize: () => { width: number; height: number } - } - | null - | undefined, + thumbnail: + | { + isEmpty: () => boolean; + getSize: () => { width: number; height: number }; + } + | null + | undefined, ) { - if (!thumbnail || thumbnail.isEmpty()) { - return false - } + if (!thumbnail || thumbnail.isEmpty()) { + return false; + } - const size = thumbnail.getSize() - return size.width > 1 && size.height > 1 + const size = thumbnail.getSize(); + return size.width > 1 && size.height > 1; } -function getMacPrivacySettingsUrl(pane: 'screen' | 'accessibility' | 'microphone') { - if (pane === 'screen') return 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture' - if (pane === 'microphone') return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone' - return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' +function getMacPrivacySettingsUrl(pane: "screen" | "accessibility" | "microphone") { + if (pane === "screen") + return "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"; + if (pane === "microphone") + return "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"; + return "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; } function isAutoRecordingPath(filePath: string) { - return path.basename(filePath).startsWith(AUTO_RECORDING_PREFIX) + return path.basename(filePath).startsWith(AUTO_RECORDING_PREFIX); } function getTelemetryPathForVideo(videoPath: string) { - return `${videoPath}.cursor.json` + return `${videoPath}.cursor.json`; } async function loadRecordingsDirectorySetting() { - if (recordingsDirLoaded) { - return - } + if (recordingsDirLoaded) { + return; + } - recordingsDirLoaded = true + recordingsDirLoaded = true; - try { - const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, 'utf-8') - const parsed = JSON.parse(content) as { recordingsDir?: unknown } - if (typeof parsed.recordingsDir === 'string' && parsed.recordingsDir.trim()) { - customRecordingsDir = path.resolve(parsed.recordingsDir) - } - } catch { - customRecordingsDir = null - } + try { + const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, "utf-8"); + const parsed = JSON.parse(content) as { recordingsDir?: unknown }; + if (typeof parsed.recordingsDir === "string" && parsed.recordingsDir.trim()) { + customRecordingsDir = path.resolve(parsed.recordingsDir); + } + } catch { + customRecordingsDir = null; + } } async function getRecordingsDir() { - await loadRecordingsDirectorySetting() - const targetDir = customRecordingsDir ?? RECORDINGS_DIR - await fs.mkdir(targetDir, { recursive: true }) - return targetDir + await loadRecordingsDirectorySetting(); + const targetDir = customRecordingsDir ?? RECORDINGS_DIR; + await fs.mkdir(targetDir, { recursive: true }); + return targetDir; } -function recordNativeCaptureDiagnostics( - diagnostics: Omit, -) { - lastNativeCaptureDiagnostics = { - timestamp: new Date().toISOString(), - ...diagnostics, - } +function recordNativeCaptureDiagnostics(diagnostics: Omit) { + lastNativeCaptureDiagnostics = { + timestamp: new Date().toISOString(), + ...diagnostics, + }; - return lastNativeCaptureDiagnostics + return lastNativeCaptureDiagnostics; } async function getFileSizeIfPresent(filePath: string | null | undefined) { - if (!filePath) { - return null - } + if (!filePath) { + return null; + } - try { - const stat = await fs.stat(filePath) - return stat.size - } catch { - return null - } + try { + const stat = await fs.stat(filePath); + return stat.size; + } catch { + return null; + } } function parseFfmpegDurationSeconds(stderr: string) { - const match = stderr.match(/Duration:\s+(\d+):(\d+):(\d+(?:\.\d+)?)/i) - if (!match) { - return null - } + const match = stderr.match(/Duration:\s+(\d+):(\d+):(\d+(?:\.\d+)?)/i); + if (!match) { + return null; + } - const hours = Number(match[1]) - const minutes = Number(match[2]) - const seconds = Number(match[3]) - if (![hours, minutes, seconds].every(Number.isFinite)) { - return null - } + const hours = Number(match[1]); + const minutes = Number(match[2]); + const seconds = Number(match[3]); + if (![hours, minutes, seconds].every(Number.isFinite)) { + return null; + } - return hours * 3600 + minutes * 60 + seconds + return hours * 3600 + minutes * 60 + seconds; } type CompanionAudioCandidate = { - platform: (typeof COMPANION_AUDIO_LAYOUTS)[number]['platform'] - systemPath: string - micPath: string - usablePaths: string[] -} - -async function getUsableCompanionAudioCandidates(videoPath: string): Promise { - const basePath = videoPath.replace(/\.[^.]+$/u, '') - const candidates: CompanionAudioCandidate[] = [] - - for (const layout of COMPANION_AUDIO_LAYOUTS) { - const systemPath = `${basePath}${layout.systemSuffix}` - const micPath = `${basePath}${layout.micSuffix}` - const usablePaths: string[] = [] - - for (const companionPath of [systemPath, micPath]) { - try { - const stat = await fs.stat(companionPath) - if (stat.size > 0) { - usablePaths.push(companionPath) - } - } catch { - // Missing companion audio is expected for many recordings. - } - } - - if (usablePaths.length > 0) { - candidates.push({ - platform: layout.platform, - systemPath, - micPath, - usablePaths, - }) - } - } - - return candidates + platform: (typeof COMPANION_AUDIO_LAYOUTS)[number]["platform"]; + systemPath: string; + micPath: string; + usablePaths: string[]; +}; + +async function getUsableCompanionAudioCandidates( + videoPath: string, +): Promise { + const basePath = videoPath.replace(/\.[^.]+$/u, ""); + const candidates: CompanionAudioCandidate[] = []; + + for (const layout of COMPANION_AUDIO_LAYOUTS) { + const systemPath = `${basePath}${layout.systemSuffix}`; + const micPath = `${basePath}${layout.micSuffix}`; + const usablePaths: string[] = []; + + for (const companionPath of [systemPath, micPath]) { + try { + const stat = await fs.stat(companionPath); + if (stat.size > 0) { + usablePaths.push(companionPath); + } + } catch { + // Missing companion audio is expected for many recordings. + } + } + + if (usablePaths.length > 0) { + candidates.push({ + platform: layout.platform, + systemPath, + micPath, + usablePaths, + }); + } + } + + return candidates; } async function hasEmbeddedAudioStream(videoPath: string) { - const ffmpegPath = getFfmpegBinaryPath() - let stderr = '' + const ffmpegPath = getFfmpegBinaryPath(); + let stderr = ""; - try { - const result = await execFileAsync( - ffmpegPath, - ['-hide_banner', '-i', videoPath, '-map', '0:a:0', '-frames:a', '1', '-f', 'null', '-'], - { timeout: 20000, maxBuffer: 10 * 1024 * 1024 }, - ) - stderr = result.stderr - } catch (error) { - stderr = (error as NodeJS.ErrnoException & { stderr?: string }).stderr ?? '' - } + try { + const result = await execFileAsync( + ffmpegPath, + ["-hide_banner", "-i", videoPath, "-map", "0:a:0", "-frames:a", "1", "-f", "null", "-"], + { timeout: 20000, maxBuffer: 10 * 1024 * 1024 }, + ); + stderr = result.stderr; + } catch (error) { + stderr = (error as NodeJS.ErrnoException & { stderr?: string }).stderr ?? ""; + } - return /Stream #.*Audio:/i.test(stderr) + return /Stream #.*Audio:/i.test(stderr); } async function getCompanionAudioFallbackPaths(videoPath: string) { - const companionCandidates = await getUsableCompanionAudioCandidates(videoPath) - if (companionCandidates.length === 0) { - return [] - } + const companionCandidates = await getUsableCompanionAudioCandidates(videoPath); + if (companionCandidates.length === 0) { + return []; + } - if (await hasEmbeddedAudioStream(videoPath)) { - return [] - } + if (await hasEmbeddedAudioStream(videoPath)) { + return []; + } - return companionCandidates.flatMap((candidate) => candidate.usablePaths) + return companionCandidates.flatMap((candidate) => candidate.usablePaths); } async function validateRecordedVideo(videoPath: string) { - const stat = await fs.stat(videoPath) - if (!stat.isFile()) { - throw new Error(`Recorded output is not a file: ${videoPath}`) - } - - if (stat.size <= 0) { - throw new Error(`Recorded output is empty: ${videoPath}`) - } - - const ffmpegPath = getFfmpegBinaryPath() - let stderr = '' - - try { - const result = await execFileAsync( - ffmpegPath, - ['-hide_banner', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'], - { timeout: 20000, maxBuffer: 10 * 1024 * 1024 }, - ) - stderr = result.stderr - } catch (error) { - const execError = error as NodeJS.ErrnoException & { stderr?: string } - const output = execError.stderr?.trim() - throw new Error(output || `Recorded output could not be decoded: ${videoPath}`) - } - - if (!/Stream #.*Video:/i.test(stderr)) { - throw new Error(`Recorded output does not contain a readable video stream: ${videoPath}`) - } - - const durationSeconds = parseFfmpegDurationSeconds(stderr) - if (durationSeconds !== null && durationSeconds <= 0) { - throw new Error(`Recorded output has an invalid duration: ${videoPath}`) - } - - return { - fileSizeBytes: stat.size, - durationSeconds, - } + const stat = await fs.stat(videoPath); + if (!stat.isFile()) { + throw new Error(`Recorded output is not a file: ${videoPath}`); + } + + if (stat.size <= 0) { + throw new Error(`Recorded output is empty: ${videoPath}`); + } + + const ffmpegPath = getFfmpegBinaryPath(); + let stderr = ""; + + try { + const result = await execFileAsync( + ffmpegPath, + ["-hide_banner", "-i", videoPath, "-map", "0:v:0", "-frames:v", "1", "-f", "null", "-"], + { timeout: 20000, maxBuffer: 10 * 1024 * 1024 }, + ); + stderr = result.stderr; + } catch (error) { + const execError = error as NodeJS.ErrnoException & { stderr?: string }; + const output = execError.stderr?.trim(); + throw new Error(output || `Recorded output could not be decoded: ${videoPath}`); + } + + if (!/Stream #.*Video:/i.test(stderr)) { + throw new Error(`Recorded output does not contain a readable video stream: ${videoPath}`); + } + + const durationSeconds = parseFfmpegDurationSeconds(stderr); + if (durationSeconds !== null && durationSeconds <= 0) { + throw new Error(`Recorded output has an invalid duration: ${videoPath}`); + } + + return { + fileSizeBytes: stat.size, + durationSeconds, + }; } async function getProjectsDir() { - const projectsDir = path.join(await getRecordingsDir(), PROJECTS_DIRECTORY_NAME) - await fs.mkdir(projectsDir, { recursive: true }) - return projectsDir + const projectsDir = path.join(await getRecordingsDir(), PROJECTS_DIRECTORY_NAME); + await fs.mkdir(projectsDir, { recursive: true }); + return projectsDir; } async function persistRecordingsDirectorySetting(nextDir: string) { - customRecordingsDir = path.resolve(nextDir) - recordingsDirLoaded = true - await fs.writeFile( - RECORDINGS_SETTINGS_FILE, - JSON.stringify({ recordingsDir: customRecordingsDir }, null, 2), - 'utf-8', - ) + customRecordingsDir = path.resolve(nextDir); + recordingsDirLoaded = true; + await fs.writeFile( + RECORDINGS_SETTINGS_FILE, + JSON.stringify({ recordingsDir: customRecordingsDir }, null, 2), + "utf-8", + ); } function hasProjectFileExtension(filePath: string) { - const extension = path.extname(filePath).replace(/^\./, '').toLowerCase() - return [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS].includes(extension) + const extension = path.extname(filePath).replace(/^\./, "").toLowerCase(); + return [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS].includes(extension); } function getProjectThumbnailPath(projectPath: string) { - return `${projectPath}${PROJECT_THUMBNAIL_SUFFIX}` + return `${projectPath}${PROJECT_THUMBNAIL_SUFFIX}`; } async function saveProjectThumbnail(projectPath: string, thumbnailDataUrl?: string | null) { - const thumbnailPath = getProjectThumbnailPath(projectPath) - if (!thumbnailDataUrl) { - await fs.rm(thumbnailPath, { force: true }).catch(() => undefined) - return null - } + const thumbnailPath = getProjectThumbnailPath(projectPath); + if (!thumbnailDataUrl) { + await fs.rm(thumbnailPath, { force: true }).catch(() => undefined); + return null; + } - const match = thumbnailDataUrl.match(/^data:image\/png;base64,(.+)$/) - if (!match) { - throw new Error('Project thumbnail must be a PNG data URL.') - } + const match = thumbnailDataUrl.match(/^data:image\/png;base64,(.+)$/); + if (!match) { + throw new Error("Project thumbnail must be a PNG data URL."); + } - await fs.writeFile(thumbnailPath, Buffer.from(match[1], 'base64')) - return thumbnailPath + await fs.writeFile(thumbnailPath, Buffer.from(match[1], "base64")); + return thumbnailPath; } async function loadRecentProjectPaths() { - try { - const content = await fs.readFile(RECENT_PROJECTS_FILE, 'utf-8') - const parsed = JSON.parse(content) as { paths?: unknown } - return Array.isArray(parsed.paths) - ? parsed.paths.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) - : [] - } catch { - return [] - } + try { + const content = await fs.readFile(RECENT_PROJECTS_FILE, "utf-8"); + const parsed = JSON.parse(content) as { paths?: unknown }; + return Array.isArray(parsed.paths) + ? parsed.paths.filter( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ) + : []; + } catch { + return []; + } } async function saveRecentProjectPaths(paths: string[]) { - const normalizedPaths = Array.from(new Set(paths.map((value) => normalizePath(value)))).slice(0, MAX_RECENT_PROJECTS) - await fs.writeFile( - RECENT_PROJECTS_FILE, - JSON.stringify({ paths: normalizedPaths }, null, 2), - 'utf-8', - ) + const normalizedPaths = Array.from(new Set(paths.map((value) => normalizePath(value)))).slice( + 0, + MAX_RECENT_PROJECTS, + ); + await fs.writeFile( + RECENT_PROJECTS_FILE, + JSON.stringify({ paths: normalizedPaths }, null, 2), + "utf-8", + ); } async function rememberRecentProject(projectPath: string) { - if (!hasProjectFileExtension(projectPath)) { - return - } - - const existingPaths = await loadRecentProjectPaths() - await saveRecentProjectPaths([projectPath, ...existingPaths]) -} - -async function buildProjectLibraryEntry(projectPath: string, projectsDir: string): Promise { - try { - const normalizedPath = normalizePath(projectPath) - if (!hasProjectFileExtension(normalizedPath)) { - return null - } - - const stats = await fs.stat(normalizedPath) - if (!stats.isFile()) { - return null - } - - const thumbnailPath = getProjectThumbnailPath(normalizedPath) - const thumbnailExists = await fs.access(thumbnailPath, fsConstants.R_OK) - .then(() => true) - .catch(() => false) - - return { - path: normalizedPath, - name: path.basename(normalizedPath).replace(/\.(recordly|openscreen)$/i, ''), - updatedAt: stats.mtimeMs, - thumbnailPath: thumbnailExists ? thumbnailPath : null, - isCurrent: Boolean(currentProjectPath && normalizePath(currentProjectPath) === normalizedPath), - isInProjectsDirectory: path.dirname(normalizedPath) === normalizePath(projectsDir), - } - } catch { - return null - } + if (!hasProjectFileExtension(projectPath)) { + return; + } + + const existingPaths = await loadRecentProjectPaths(); + await saveRecentProjectPaths([projectPath, ...existingPaths]); +} + +async function buildProjectLibraryEntry( + projectPath: string, + projectsDir: string, +): Promise { + try { + const normalizedPath = normalizePath(projectPath); + if (!hasProjectFileExtension(normalizedPath)) { + return null; + } + + const stats = await fs.stat(normalizedPath); + if (!stats.isFile()) { + return null; + } + + const thumbnailPath = getProjectThumbnailPath(normalizedPath); + const thumbnailExists = await fs + .access(thumbnailPath, fsConstants.R_OK) + .then(() => true) + .catch(() => false); + + return { + path: normalizedPath, + name: path.basename(normalizedPath).replace(/\.(recordly|openscreen)$/i, ""), + updatedAt: stats.mtimeMs, + thumbnailPath: thumbnailExists ? thumbnailPath : null, + isCurrent: Boolean( + currentProjectPath && normalizePath(currentProjectPath) === normalizedPath, + ), + isInProjectsDirectory: path.dirname(normalizedPath) === normalizePath(projectsDir), + }; + } catch { + return null; + } } async function listProjectLibraryEntries() { - const projectsDir = await getProjectsDir() - const projectPaths: string[] = [] - - try { - const entries = await fs.readdir(projectsDir, { withFileTypes: true }) - for (const entry of entries) { - if (!entry.isFile()) { - continue - } - - const entryPath = path.join(projectsDir, entry.name) - if (hasProjectFileExtension(entryPath)) { - projectPaths.push(entryPath) - } - } - } catch { - // Ignore directory read failures and fall back to recent files. - } - - const recentProjectPaths = await loadRecentProjectPaths() - const candidatePaths = Array.from(new Set([...projectPaths, ...recentProjectPaths])) - const entries = (await Promise.all(candidatePaths.map((candidatePath) => buildProjectLibraryEntry(candidatePath, projectsDir)))) - .filter((entry): entry is ProjectLibraryEntry => entry != null) - .sort((left, right) => right.updatedAt - left.updatedAt) - - await saveRecentProjectPaths(entries.map((entry) => entry.path)) - - return { - projectsDir, - entries, - } + const projectsDir = await getProjectsDir(); + const projectPaths: string[] = []; + + try { + const entries = await fs.readdir(projectsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + + const entryPath = path.join(projectsDir, entry.name); + if (hasProjectFileExtension(entryPath)) { + projectPaths.push(entryPath); + } + } + } catch { + // Ignore directory read failures and fall back to recent files. + } + + const recentProjectPaths = await loadRecentProjectPaths(); + const candidatePaths = Array.from(new Set([...projectPaths, ...recentProjectPaths])); + const entries = ( + await Promise.all( + candidatePaths.map((candidatePath) => buildProjectLibraryEntry(candidatePath, projectsDir)), + ) + ) + .filter((entry): entry is ProjectLibraryEntry => entry != null) + .sort((left, right) => right.updatedAt - left.updatedAt); + + await saveRecentProjectPaths(entries.map((entry) => entry.path)); + + return { + projectsDir, + entries, + }; } async function loadProjectFromPath(projectPath: string) { - const normalizedPath = normalizePath(projectPath) - const content = await fs.readFile(normalizedPath, 'utf-8') - const project = JSON.parse(content) - const mediaSources = await resolveProjectMediaSources(project) - - if (!mediaSources.success) { - return { - success: false, - canceled: false, - message: mediaSources.message, - } - } - - currentProjectPath = normalizedPath - currentVideoPath = mediaSources.videoPath - currentRecordingSession = { - videoPath: mediaSources.videoPath, - webcamPath: mediaSources.webcamPath, - timeOffsetMs: 0, - } - await rememberRecentProject(normalizedPath) - - return { - success: true, - path: normalizedPath, - project, - } + const normalizedPath = normalizePath(projectPath); + const content = await fs.readFile(normalizedPath, "utf-8"); + const project = JSON.parse(content); + const mediaSources = await resolveProjectMediaSources(project); + + if (!mediaSources.success) { + return { + success: false, + canceled: false, + message: mediaSources.message, + }; + } + + currentProjectPath = normalizedPath; + currentVideoPath = mediaSources.videoPath; + currentRecordingSession = { + videoPath: mediaSources.videoPath, + webcamPath: mediaSources.webcamPath, + timeOffsetMs: 0, + }; + await rememberRecentProject(normalizedPath); + + return { + success: true, + path: normalizedPath, + project, + }; } function normalizeVideoSourcePath(videoPath?: string | null): string | null { - if (typeof videoPath !== 'string') { - return null - } + if (typeof videoPath !== "string") { + return null; + } - const trimmed = videoPath.trim() - if (!trimmed) { - return null - } + const trimmed = videoPath.trim(); + if (!trimmed) { + return null; + } - if (/^file:\/\//i.test(trimmed)) { - try { - return fileURLToPath(trimmed) - } catch { - // Fall through and keep best-effort string path below. - } - } + if (/^file:\/\//i.test(trimmed)) { + try { + return fileURLToPath(trimmed); + } catch { + // Fall through and keep best-effort string path below. + } + } - return trimmed + return trimmed; } async function resolveProjectMediaSources(project: unknown): Promise< - | { - success: true - videoPath: string - webcamPath: string | null - } - | { - success: false - message: string - } + | { + success: true; + videoPath: string; + webcamPath: string | null; + } + | { + success: false; + message: string; + } > { - if (!project || typeof project !== 'object') { - return { success: false, message: 'Invalid project file format' } - } - - const rawVideoPath = (project as { videoPath?: unknown }).videoPath - if (typeof rawVideoPath !== 'string') { - return { success: false, message: 'Project file is missing a video path' } - } - - const normalizedVideoPath = normalizeVideoSourcePath(rawVideoPath) - if (!normalizedVideoPath) { - return { success: false, message: 'Project file is missing a valid video path' } - } - - try { - await fs.access(normalizedVideoPath, fsConstants.F_OK) - } catch { - return { - success: false, - message: `Project video file not found: ${normalizedVideoPath}`, - } - } - - const rawWebcamPath = - typeof (project as { editor?: { webcam?: { sourcePath?: unknown } } }).editor?.webcam?.sourcePath === 'string' - ? ((project as { editor?: { webcam?: { sourcePath?: string } } }).editor?.webcam?.sourcePath ?? null) - : null - const normalizedWebcamPath = normalizeVideoSourcePath(rawWebcamPath) - - if (!normalizedWebcamPath) { - return { - success: true, - videoPath: normalizedVideoPath, - webcamPath: null, - } - } - - try { - await fs.access(normalizedWebcamPath, fsConstants.F_OK) - return { - success: true, - videoPath: normalizedVideoPath, - webcamPath: normalizedWebcamPath, - } - } catch { - return { - success: true, - videoPath: normalizedVideoPath, - webcamPath: null, - } - } + if (!project || typeof project !== "object") { + return { success: false, message: "Invalid project file format" }; + } + + const rawVideoPath = (project as { videoPath?: unknown }).videoPath; + if (typeof rawVideoPath !== "string") { + return { success: false, message: "Project file is missing a video path" }; + } + + const normalizedVideoPath = normalizeVideoSourcePath(rawVideoPath); + if (!normalizedVideoPath) { + return { success: false, message: "Project file is missing a valid video path" }; + } + + try { + await fs.access(normalizedVideoPath, fsConstants.F_OK); + } catch { + return { + success: false, + message: `Project video file not found: ${normalizedVideoPath}`, + }; + } + + const rawWebcamPath = + typeof (project as { editor?: { webcam?: { sourcePath?: unknown } } }).editor?.webcam + ?.sourcePath === "string" + ? ((project as { editor?: { webcam?: { sourcePath?: string } } }).editor?.webcam + ?.sourcePath ?? null) + : null; + const normalizedWebcamPath = normalizeVideoSourcePath(rawWebcamPath); + + if (!normalizedWebcamPath) { + return { + success: true, + videoPath: normalizedVideoPath, + webcamPath: null, + }; + } + + try { + await fs.access(normalizedWebcamPath, fsConstants.F_OK); + return { + success: true, + videoPath: normalizedVideoPath, + webcamPath: normalizedWebcamPath, + }; + } catch { + return { + success: true, + videoPath: normalizedVideoPath, + webcamPath: null, + }; + } } function getRecordingSessionManifestPath(videoPath: string) { - const extension = path.extname(videoPath) - const baseName = path.basename(videoPath, extension) - return path.join(path.dirname(videoPath), `${baseName}${RECORDING_SESSION_MANIFEST_SUFFIX}`) + const extension = path.extname(videoPath); + const baseName = path.basename(videoPath, extension); + return path.join(path.dirname(videoPath), `${baseName}${RECORDING_SESSION_MANIFEST_SUFFIX}`); } async function persistRecordingSessionManifest(session: RecordingSessionData): Promise { - const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) - if (!normalizedVideoPath) { - return - } - - const normalizedWebcamPath = normalizeVideoSourcePath(session.webcamPath ?? null) - const manifestPath = getRecordingSessionManifestPath(normalizedVideoPath) - - if (!normalizedWebcamPath) { - await fs.rm(manifestPath, { force: true }) - return - } - - const manifest: RecordingSessionManifest = { - version: 2, - videoFileName: path.basename(normalizedVideoPath), - webcamFileName: path.basename(normalizedWebcamPath), - timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), - } - - await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8') -} - -async function resolveRecordingSessionManifest(videoPath?: string | null): Promise { - const normalizedVideoPath = normalizeVideoSourcePath(videoPath) - if (!normalizedVideoPath) { - return null - } - - const manifestPath = getRecordingSessionManifestPath(normalizedVideoPath) - - try { - const content = await fs.readFile(manifestPath, 'utf-8') - const parsed = JSON.parse(content) as Partial - if (parsed.version !== 1 && parsed.version !== 2) { - return null - } - - const webcamFileName = typeof parsed.webcamFileName === 'string' && parsed.webcamFileName.trim() - ? parsed.webcamFileName.trim() - : null - - if (!webcamFileName) { - return { - videoPath: normalizedVideoPath, - webcamPath: null, - timeOffsetMs: 0, - } - } - - const webcamPath = path.join(path.dirname(normalizedVideoPath), webcamFileName) - await fs.access(webcamPath, fsConstants.F_OK) - - return { - videoPath: normalizedVideoPath, - webcamPath, - timeOffsetMs: normalizeRecordingTimeOffsetMs(parsed.timeOffsetMs), - } - } catch { - return null - } + const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath); + if (!normalizedVideoPath) { + return; + } + + const normalizedWebcamPath = normalizeVideoSourcePath(session.webcamPath ?? null); + const manifestPath = getRecordingSessionManifestPath(normalizedVideoPath); + + if (!normalizedWebcamPath) { + await fs.rm(manifestPath, { force: true }); + return; + } + + const manifest: RecordingSessionManifest = { + version: 2, + videoFileName: path.basename(normalizedVideoPath), + webcamFileName: path.basename(normalizedWebcamPath), + timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), + }; + + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf-8"); +} + +async function resolveRecordingSessionManifest( + videoPath?: string | null, +): Promise { + const normalizedVideoPath = normalizeVideoSourcePath(videoPath); + if (!normalizedVideoPath) { + return null; + } + + const manifestPath = getRecordingSessionManifestPath(normalizedVideoPath); + + try { + const content = await fs.readFile(manifestPath, "utf-8"); + const parsed = JSON.parse(content) as Partial; + if (parsed.version !== 1 && parsed.version !== 2) { + return null; + } + + const webcamFileName = + typeof parsed.webcamFileName === "string" && parsed.webcamFileName.trim() + ? parsed.webcamFileName.trim() + : null; + + if (!webcamFileName) { + return { + videoPath: normalizedVideoPath, + webcamPath: null, + timeOffsetMs: 0, + }; + } + + const webcamPath = path.join(path.dirname(normalizedVideoPath), webcamFileName); + await fs.access(webcamPath, fsConstants.F_OK); + + return { + videoPath: normalizedVideoPath, + webcamPath, + timeOffsetMs: normalizeRecordingTimeOffsetMs(parsed.timeOffsetMs), + }; + } catch { + return null; + } } async function resolveLinkedWebcamPath(videoPath?: string | null): Promise { - const normalizedVideoPath = normalizeVideoSourcePath(videoPath) - if (!normalizedVideoPath) { - return null - } - - const extension = path.extname(normalizedVideoPath) - const baseName = path.basename(normalizedVideoPath, extension) - if (!baseName || baseName.endsWith('-webcam')) { - return null - } - - const candidateExtensions = Array.from( - new Set([extension, '.webm', '.mp4', '.mov', '.mkv', '.avi'].filter(Boolean)), - ) - - for (const candidateExtension of candidateExtensions) { - const candidatePath = path.join( - path.dirname(normalizedVideoPath), - `${baseName}-webcam${candidateExtension}`, - ) - - try { - await fs.access(candidatePath, fsConstants.F_OK) - return candidatePath - } catch { - continue - } - } - - return null -} - -async function resolveRecordingSession(videoPath?: string | null): Promise { - const manifestSession = await resolveRecordingSessionManifest(videoPath) - if (manifestSession) { - return manifestSession - } - - const normalizedVideoPath = normalizeVideoSourcePath(videoPath) - if (!normalizedVideoPath) { - return null - } - - const linkedWebcamPath = await resolveLinkedWebcamPath(normalizedVideoPath) - return { - videoPath: normalizedVideoPath, - webcamPath: linkedWebcamPath, - } + const normalizedVideoPath = normalizeVideoSourcePath(videoPath); + if (!normalizedVideoPath) { + return null; + } + + const extension = path.extname(normalizedVideoPath); + const baseName = path.basename(normalizedVideoPath, extension); + if (!baseName || baseName.endsWith("-webcam")) { + return null; + } + + const candidateExtensions = Array.from( + new Set([extension, ".webm", ".mp4", ".mov", ".mkv", ".avi"].filter(Boolean)), + ); + + for (const candidateExtension of candidateExtensions) { + const candidatePath = path.join( + path.dirname(normalizedVideoPath), + `${baseName}-webcam${candidateExtension}`, + ); + + try { + await fs.access(candidatePath, fsConstants.F_OK); + return candidatePath; + } catch { + continue; + } + } + + return null; +} + +async function resolveRecordingSession( + videoPath?: string | null, +): Promise { + const manifestSession = await resolveRecordingSessionManifest(videoPath); + if (manifestSession) { + return manifestSession; + } + + const normalizedVideoPath = normalizeVideoSourcePath(videoPath); + if (!normalizedVideoPath) { + return null; + } + + const linkedWebcamPath = await resolveLinkedWebcamPath(normalizedVideoPath); + return { + videoPath: normalizedVideoPath, + webcamPath: linkedWebcamPath, + }; } async function hasSiblingProjectFile(videoPath: string) { - const baseName = path.basename(videoPath, path.extname(videoPath)) - const candidateExtensions = [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS] + const baseName = path.basename(videoPath, path.extname(videoPath)); + const candidateExtensions = [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS]; - for (const extension of candidateExtensions) { - const projectPath = path.join(path.dirname(videoPath), `${baseName}.${extension}`) + for (const extension of candidateExtensions) { + const projectPath = path.join(path.dirname(videoPath), `${baseName}.${extension}`); - try { - await fs.access(projectPath) - return true - } catch { - continue - } - } + try { + await fs.access(projectPath); + return true; + } catch { + continue; + } + } - return false + return false; } async function pruneAutoRecordings(exemptPaths: string[] = []) { - const recordingsDir = await getRecordingsDir() - const exempt = new Set( - [currentVideoPath, ...exemptPaths] - .filter((value): value is string => Boolean(value)) - .map((value) => normalizePath(value)), - ) - - const entries = await fs.readdir(recordingsDir, { withFileTypes: true }) - const autoRecordingStats = await Promise.all( - entries - .filter((entry) => entry.isFile() && /^recording-.*\.(mp4|mov|webm)$/i.test(entry.name)) - .map(async (entry) => { - const filePath = path.join(recordingsDir, entry.name) - const stats = await fs.stat(filePath) - return { filePath, stats } - }), - ) - - const sorted = autoRecordingStats.sort((left, right) => right.stats.mtimeMs - left.stats.mtimeMs) - const now = Date.now() - - for (const [index, entry] of sorted.entries()) { - const normalizedFilePath = normalizePath(entry.filePath) - if (exempt.has(normalizedFilePath)) { - continue - } - - if (await hasSiblingProjectFile(entry.filePath)) { - continue - } - - const tooOld = now - entry.stats.mtimeMs > AUTO_RECORDING_MAX_AGE_MS - const overLimit = index >= AUTO_RECORDING_RETENTION_COUNT - if (!tooOld && !overLimit) { - continue - } - - try { - await fs.rm(entry.filePath, { force: true }) - await fs.rm(getTelemetryPathForVideo(entry.filePath), { force: true }) - // Clean up companion audio files left from recording (macOS .m4a, Windows .wav) - const base = entry.filePath.replace(/\.(mp4|mov|webm)$/i, '') - for (const suffix of ['.system.m4a', '.mic.m4a', '.system.wav', '.mic.wav']) { - await fs.rm(base + suffix, { force: true }).catch(() => {}) - } - } catch (error) { - console.warn('Failed to prune old auto recording:', entry.filePath, error) - } - } + const recordingsDir = await getRecordingsDir(); + const exempt = new Set( + [currentVideoPath, ...exemptPaths] + .filter((value): value is string => Boolean(value)) + .map((value) => normalizePath(value)), + ); + + const entries = await fs.readdir(recordingsDir, { withFileTypes: true }); + const autoRecordingStats = await Promise.all( + entries + .filter((entry) => entry.isFile() && /^recording-.*\.(mp4|mov|webm)$/i.test(entry.name)) + .map(async (entry) => { + const filePath = path.join(recordingsDir, entry.name); + const stats = await fs.stat(filePath); + return { filePath, stats }; + }), + ); + + const sorted = autoRecordingStats.sort((left, right) => right.stats.mtimeMs - left.stats.mtimeMs); + const now = Date.now(); + + for (const [index, entry] of sorted.entries()) { + const normalizedFilePath = normalizePath(entry.filePath); + if (exempt.has(normalizedFilePath)) { + continue; + } + + if (await hasSiblingProjectFile(entry.filePath)) { + continue; + } + + const tooOld = now - entry.stats.mtimeMs > AUTO_RECORDING_MAX_AGE_MS; + const overLimit = index >= AUTO_RECORDING_RETENTION_COUNT; + if (!tooOld && !overLimit) { + continue; + } + + try { + await fs.rm(entry.filePath, { force: true }); + await fs.rm(getTelemetryPathForVideo(entry.filePath), { force: true }); + // Clean up companion audio files left from recording (macOS .m4a, Windows .wav) + const base = entry.filePath.replace(/\.(mp4|mov|webm)$/i, ""); + for (const suffix of [".system.m4a", ".mic.m4a", ".system.wav", ".mic.wav"]) { + await fs.rm(base + suffix, { force: true }).catch(() => {}); + } + } catch (error) { + console.warn("Failed to prune old auto recording:", entry.filePath, error); + } + } } /** @@ -872,2655 +918,2919 @@ async function pruneAutoRecordings(exemptPaths: string[] = []) { * accessed via that path instead of the asar virtual filesystem. */ function resolveUnpackedAppPath(...segments: string[]) { - const base = app.getAppPath() - const resolved = path.join(base, ...segments) - if (app.isPackaged) { - return resolved.replace(/\.asar([/\\])/, '.asar.unpacked$1') - } - return resolved + const base = app.getAppPath(); + const resolved = path.join(base, ...segments); + if (app.isPackaged) { + return resolved.replace(/\.asar([/\\])/, ".asar.unpacked$1"); + } + return resolved; } function getNativeCaptureHelperSourcePath() { - return resolveUnpackedAppPath('electron', 'native', 'ScreenCaptureKitRecorder.swift') + return resolveUnpackedAppPath("electron", "native", "ScreenCaptureKitRecorder.swift"); } function getNativeArchTag() { - if (process.platform === 'darwin') { - return process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64' - } + if (process.platform === "darwin") { + return process.arch === "arm64" ? "darwin-arm64" : "darwin-x64"; + } - if (process.platform === 'win32') { - return process.arch === 'arm64' ? 'win32-arm64' : 'win32-x64' - } + if (process.platform === "win32") { + return process.arch === "arm64" ? "win32-arm64" : "win32-x64"; + } - if (process.platform === 'linux') { - return process.arch === 'arm64' ? 'linux-arm64' : 'linux-x64' - } + if (process.platform === "linux") { + return process.arch === "arm64" ? "linux-arm64" : "linux-x64"; + } - return `${process.platform}-${process.arch}` + return `${process.platform}-${process.arch}`; } function getPrebundledNativeHelperPath(binaryName: string) { - return resolveUnpackedAppPath('electron', 'native', 'bin', getNativeArchTag(), binaryName) + return resolveUnpackedAppPath("electron", "native", "bin", getNativeArchTag(), binaryName); } function resolvePreferredWindowsNativeHelperPath(helperDirectory: string, binaryName: string) { - const buildOutputPath = resolveUnpackedAppPath( - 'electron', - 'native', - helperDirectory, - 'build', - 'Release', - binaryName, - ) - const prebundledPath = getPrebundledNativeHelperPath(binaryName) + const buildOutputPath = resolveUnpackedAppPath( + "electron", + "native", + helperDirectory, + "build", + "Release", + binaryName, + ); + const prebundledPath = getPrebundledNativeHelperPath(binaryName); - if (existsSync(buildOutputPath)) { - return buildOutputPath - } + if (existsSync(buildOutputPath)) { + return buildOutputPath; + } - if (existsSync(prebundledPath)) { - return prebundledPath - } + if (existsSync(prebundledPath)) { + return prebundledPath; + } - return buildOutputPath + return buildOutputPath; } function getBundledWhisperExecutableCandidates() { - const binaryNames = process.platform === 'win32' - ? ['whisper-cli.exe', 'whisper-cpp.exe', 'whisper.exe', 'main.exe'] - : ['whisper-cli', 'whisper-cpp', 'whisper', 'main'] + const binaryNames = + process.platform === "win32" + ? ["whisper-cli.exe", "whisper-cpp.exe", "whisper.exe", "main.exe"] + : ["whisper-cli", "whisper-cpp", "whisper", "main"]; - return binaryNames.map((binaryName) => getPrebundledNativeHelperPath(binaryName)) + return binaryNames.map((binaryName) => getPrebundledNativeHelperPath(binaryName)); } function getNativeCaptureHelperBinaryPath() { - return path.join(app.getPath('userData'), 'native-tools', 'openscreen-screencapturekit-helper') + return path.join(app.getPath("userData"), "native-tools", "openscreen-screencapturekit-helper"); } function getSystemCursorHelperSourcePath() { - return resolveUnpackedAppPath('electron', 'native', 'SystemCursorAssets.swift') + return resolveUnpackedAppPath("electron", "native", "SystemCursorAssets.swift"); } function getSystemCursorHelperBinaryPath() { - return path.join(app.getPath('userData'), 'native-tools', 'openscreen-system-cursors') + return path.join(app.getPath("userData"), "native-tools", "openscreen-system-cursors"); } function getNativeCursorMonitorSourcePath() { - return resolveUnpackedAppPath('electron', 'native', 'NativeCursorMonitor.swift') + return resolveUnpackedAppPath("electron", "native", "NativeCursorMonitor.swift"); } function getNativeCursorMonitorBinaryPath() { - return path.join(app.getPath('userData'), 'native-tools', 'openscreen-native-cursor-monitor') + return path.join(app.getPath("userData"), "native-tools", "openscreen-native-cursor-monitor"); } function getNativeWindowListSourcePath() { - return resolveUnpackedAppPath('electron', 'native', 'ScreenCaptureKitWindowList.swift') + return resolveUnpackedAppPath("electron", "native", "ScreenCaptureKitWindowList.swift"); } function getNativeWindowListBinaryPath() { - return path.join(app.getPath('userData'), 'native-tools', 'openscreen-window-list') + return path.join(app.getPath("userData"), "native-tools", "openscreen-window-list"); } type NativeMacWindowSource = { - id: string - name: string - display_id?: string - appName?: string - windowTitle?: string - bundleId?: string - appIcon?: string | null - x?: number - y?: number - width?: number - height?: number -} - -let cachedNativeMacWindowSources: NativeMacWindowSource[] | null = null -let cachedNativeMacWindowSourcesAtMs = 0 + id: string; + name: string; + display_id?: string; + appName?: string; + windowTitle?: string; + bundleId?: string; + appIcon?: string | null; + x?: number; + y?: number; + width?: number; + height?: number; +}; + +let cachedNativeMacWindowSources: NativeMacWindowSource[] | null = null; +let cachedNativeMacWindowSourcesAtMs = 0; async function ensureSwiftHelperBinary( - sourcePath: string, - binaryPath: string, - label: string, - prebundledBinaryName?: string, + sourcePath: string, + binaryPath: string, + label: string, + prebundledBinaryName?: string, ) { - if (prebundledBinaryName) { - const prebundledPath = getPrebundledNativeHelperPath(prebundledBinaryName) - try { - await fs.access(prebundledPath, fsConstants.X_OK) - return prebundledPath - } catch { - if (app.isPackaged) { - throw new Error( - `${label} is missing from this app build (${prebundledPath}). Reinstall or update the app.` - ) - } - } - } - - const helperDir = path.dirname(binaryPath) - - await fs.mkdir(helperDir, { recursive: true }) - - let shouldCompile = false - try { - const [sourceStat, binaryStat] = await Promise.all([ - fs.stat(sourcePath), - fs.stat(binaryPath).catch(() => null), - ]) - shouldCompile = !binaryStat || sourceStat.mtimeMs > binaryStat.mtimeMs - } catch (error) { - throw new Error(`${label} source is unavailable: ${String(error)}`) - } - - if (!shouldCompile) { - return binaryPath - } - - const result = spawnSync('swiftc', ['-O', sourcePath, '-o', binaryPath], { - encoding: 'utf8', - timeout: 120000, - }) - - if (result.status !== 0) { - const details = [result.stderr, result.stdout].filter(Boolean).join('\n').trim() - throw new Error(details || `Failed to compile ${label}`) - } - - return binaryPath + if (prebundledBinaryName) { + const prebundledPath = getPrebundledNativeHelperPath(prebundledBinaryName); + try { + await fs.access(prebundledPath, fsConstants.X_OK); + return prebundledPath; + } catch { + if (app.isPackaged) { + throw new Error( + `${label} is missing from this app build (${prebundledPath}). Reinstall or update the app.`, + ); + } + } + } + + const helperDir = path.dirname(binaryPath); + + await fs.mkdir(helperDir, { recursive: true }); + + let shouldCompile = false; + try { + const [sourceStat, binaryStat] = await Promise.all([ + fs.stat(sourcePath), + fs.stat(binaryPath).catch(() => null), + ]); + shouldCompile = !binaryStat || sourceStat.mtimeMs > binaryStat.mtimeMs; + } catch (error) { + throw new Error(`${label} source is unavailable: ${String(error)}`); + } + + if (!shouldCompile) { + return binaryPath; + } + + const result = spawnSync("swiftc", ["-O", sourcePath, "-o", binaryPath], { + encoding: "utf8", + timeout: 120000, + }); + + if (result.status !== 0) { + const details = [result.stderr, result.stdout].filter(Boolean).join("\n").trim(); + throw new Error(details || `Failed to compile ${label}`); + } + + return binaryPath; } async function ensureNativeCaptureHelperBinary() { - return ensureSwiftHelperBinary( - getNativeCaptureHelperSourcePath(), - getNativeCaptureHelperBinaryPath(), - 'native ScreenCaptureKit helper', - 'openscreen-screencapturekit-helper' - ) + return ensureSwiftHelperBinary( + getNativeCaptureHelperSourcePath(), + getNativeCaptureHelperBinaryPath(), + "native ScreenCaptureKit helper", + "openscreen-screencapturekit-helper", + ); } async function ensureNativeWindowListBinary() { - return ensureSwiftHelperBinary( - getNativeWindowListSourcePath(), - getNativeWindowListBinaryPath(), - 'native ScreenCaptureKit window list helper', - 'openscreen-window-list' - ) + return ensureSwiftHelperBinary( + getNativeWindowListSourcePath(), + getNativeWindowListBinaryPath(), + "native ScreenCaptureKit window list helper", + "openscreen-window-list", + ); } async function getNativeMacWindowSources(options?: { maxAgeMs?: number }) { - if (process.platform !== 'darwin') { - return [] as NativeMacWindowSource[] - } + if (process.platform !== "darwin") { + return [] as NativeMacWindowSource[]; + } - const maxAgeMs = options?.maxAgeMs ?? 5000 - const now = Date.now() - if (cachedNativeMacWindowSources && now - cachedNativeMacWindowSourcesAtMs < maxAgeMs) { - return cachedNativeMacWindowSources - } + const maxAgeMs = options?.maxAgeMs ?? 5000; + const now = Date.now(); + if (cachedNativeMacWindowSources && now - cachedNativeMacWindowSourcesAtMs < maxAgeMs) { + return cachedNativeMacWindowSources; + } - const binaryPath = await ensureNativeWindowListBinary() - const { stdout } = await execFileAsync(binaryPath, [], { - timeout: 30000, - maxBuffer: 10 * 1024 * 1024, - }) + const binaryPath = await ensureNativeWindowListBinary(); + const { stdout } = await execFileAsync(binaryPath, [], { + timeout: 30000, + maxBuffer: 10 * 1024 * 1024, + }); - const parsed = JSON.parse(stdout) - if (!Array.isArray(parsed)) { - return [] as NativeMacWindowSource[] - } + const parsed = JSON.parse(stdout); + if (!Array.isArray(parsed)) { + return [] as NativeMacWindowSource[]; + } - const entries = parsed.filter((entry: unknown): entry is NativeMacWindowSource => { - if (!entry || typeof entry !== 'object') { - return false - } + const entries = parsed.filter((entry: unknown): entry is NativeMacWindowSource => { + if (!entry || typeof entry !== "object") { + return false; + } - const candidate = entry as Partial - return typeof candidate.id === 'string' && typeof candidate.name === 'string' - }) + const candidate = entry as Partial; + return typeof candidate.id === "string" && typeof candidate.name === "string"; + }); - cachedNativeMacWindowSources = entries - cachedNativeMacWindowSourcesAtMs = now - return entries + cachedNativeMacWindowSources = entries; + cachedNativeMacWindowSourcesAtMs = now; + return entries; } async function getSystemCursorAssets() { - if (process.platform !== 'darwin') { - cachedSystemCursorAssets = {} - cachedSystemCursorAssetsSourceMtimeMs = null - return cachedSystemCursorAssets - } - - const sourcePath = getSystemCursorHelperSourcePath() - const sourceStat = await fs.stat(sourcePath) - if (cachedSystemCursorAssets && cachedSystemCursorAssetsSourceMtimeMs === sourceStat.mtimeMs) { - return cachedSystemCursorAssets - } - - const binaryPath = await ensureSwiftHelperBinary( - sourcePath, - getSystemCursorHelperBinaryPath(), - 'system cursor helper', - 'openscreen-system-cursors' - ) - - const { stdout } = await execFileAsync(binaryPath, [], { timeout: 15000, maxBuffer: 20 * 1024 * 1024 }) - const parsed = JSON.parse(stdout) as Record> - cachedSystemCursorAssets = Object.fromEntries( - Object.entries(parsed).filter(([, asset]) => ( - typeof asset?.dataUrl === 'string' - && typeof asset?.hotspotX === 'number' - && typeof asset?.hotspotY === 'number' - && typeof asset?.width === 'number' - && typeof asset?.height === 'number' - )) - ) as Record - cachedSystemCursorAssetsSourceMtimeMs = sourceStat.mtimeMs - - return cachedSystemCursorAssets + if (process.platform !== "darwin") { + cachedSystemCursorAssets = {}; + cachedSystemCursorAssetsSourceMtimeMs = null; + return cachedSystemCursorAssets; + } + + const sourcePath = getSystemCursorHelperSourcePath(); + const sourceStat = await fs.stat(sourcePath); + if (cachedSystemCursorAssets && cachedSystemCursorAssetsSourceMtimeMs === sourceStat.mtimeMs) { + return cachedSystemCursorAssets; + } + + const binaryPath = await ensureSwiftHelperBinary( + sourcePath, + getSystemCursorHelperBinaryPath(), + "system cursor helper", + "openscreen-system-cursors", + ); + + const { stdout } = await execFileAsync(binaryPath, [], { + timeout: 15000, + maxBuffer: 20 * 1024 * 1024, + }); + const parsed = JSON.parse(stdout) as Record>; + cachedSystemCursorAssets = Object.fromEntries( + Object.entries(parsed).filter( + ([, asset]) => + typeof asset?.dataUrl === "string" && + typeof asset?.hotspotX === "number" && + typeof asset?.hotspotY === "number" && + typeof asset?.width === "number" && + typeof asset?.height === "number", + ), + ) as Record; + cachedSystemCursorAssetsSourceMtimeMs = sourceStat.mtimeMs; + + return cachedSystemCursorAssets; } function parseWindowId(sourceId?: string) { - if (!sourceId) return null - const match = sourceId.match(/^window:(\d+)/) - return match ? Number.parseInt(match[1], 10) : null + if (!sourceId) return null; + const match = sourceId.match(/^window:(\d+)/); + return match ? Number.parseInt(match[1], 10) : null; } function loadFfmpegStatic() { - const moduleExports = nodeRequire('ffmpeg-static') - if (typeof moduleExports === 'string') { - return moduleExports - } + const moduleExports = nodeRequire("ffmpeg-static"); + if (typeof moduleExports === "string") { + return moduleExports; + } - if (typeof moduleExports?.default === 'string') { - return moduleExports.default as string - } + if (typeof moduleExports?.default === "string") { + return moduleExports.default as string; + } - return null + return null; } function loadUiohookModule() { - const moduleExports = nodeRequire('uiohook-napi') - return ( - (moduleExports as any)?.uIOhook - ?? (moduleExports as any)?.uiohook - ?? (moduleExports as any)?.Uiohook - ?? (moduleExports as any)?.default?.uIOhook - ?? (moduleExports as any)?.default?.uiohook - ?? (moduleExports as any)?.default - ?? moduleExports - ) + const moduleExports = nodeRequire("uiohook-napi"); + return ( + (moduleExports as any)?.uIOhook ?? + (moduleExports as any)?.uiohook ?? + (moduleExports as any)?.Uiohook ?? + (moduleExports as any)?.default?.uIOhook ?? + (moduleExports as any)?.default?.uiohook ?? + (moduleExports as any)?.default ?? + moduleExports + ); } function getFfmpegBinaryPath() { - const ffmpegStatic = loadFfmpegStatic() - if (!ffmpegStatic || typeof ffmpegStatic !== 'string') { - throw new Error('FFmpeg binary is unavailable. Install ffmpeg-static for this platform.') - } + const ffmpegStatic = loadFfmpegStatic(); + if (!ffmpegStatic || typeof ffmpegStatic !== "string") { + throw new Error("FFmpeg binary is unavailable. Install ffmpeg-static for this platform."); + } - if (app.isPackaged) { - return ffmpegStatic.replace(/\.asar([\/\\])/, '.asar.unpacked$1') - } + if (app.isPackaged) { + return ffmpegStatic.replace(/\.asar([/\\])/, ".asar.unpacked$1"); + } - return ffmpegStatic + return ffmpegStatic; } /** Probe the duration of a media file (in seconds) using ffmpeg. */ async function probeMediaDurationSeconds(filePath: string): Promise { - const ffmpegPath = getFfmpegBinaryPath() - try { - await execFileAsync(ffmpegPath, ['-i', filePath, '-f', 'null', '-'], { timeout: 30000, maxBuffer: 2 * 1024 * 1024 }) - } catch (error) { - // ffmpeg reports info on stderr even on "success" — parse it from the error - const stderr = (error as NodeJS.ErrnoException & { stderr?: string })?.stderr ?? '' - // Match "Duration: HH:MM:SS.mm" or "time=HH:MM:SS.mm" (from progress output) - // Prefer the last "time=" value (actual decoded duration) over the container Duration header - const timeMatches = [...stderr.matchAll(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2,3})/g)] - if (timeMatches.length > 0) { - const last = timeMatches[timeMatches.length - 1] - const h = Number(last[1]) - const m = Number(last[2]) - const s = Number(last[3]) - const frac = Number(last[4]) / (last[4].length === 3 ? 1000 : 100) - return h * 3600 + m * 60 + s + frac - } - // Fall back to Duration header - const durationMatch = stderr.match(/Duration:\s*(\d{2}):(\d{2}):(\d{2})\.(\d{2,3})/) - if (durationMatch) { - const h = Number(durationMatch[1]) - const m = Number(durationMatch[2]) - const s = Number(durationMatch[3]) - const frac = Number(durationMatch[4]) / (durationMatch[4].length === 3 ? 1000 : 100) - return h * 3600 + m * 60 + s + frac - } - } - return 0 + const ffmpegPath = getFfmpegBinaryPath(); + try { + await execFileAsync(ffmpegPath, ["-i", filePath, "-f", "null", "-"], { + timeout: 30000, + maxBuffer: 2 * 1024 * 1024, + }); + } catch (error) { + // ffmpeg reports info on stderr even on "success" — parse it from the error + const stderr = (error as NodeJS.ErrnoException & { stderr?: string })?.stderr ?? ""; + // Match "Duration: HH:MM:SS.mm" or "time=HH:MM:SS.mm" (from progress output) + // Prefer the last "time=" value (actual decoded duration) over the container Duration header + const timeMatches = [...stderr.matchAll(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2,3})/g)]; + if (timeMatches.length > 0) { + const last = timeMatches[timeMatches.length - 1]; + const h = Number(last[1]); + const m = Number(last[2]); + const s = Number(last[3]); + const frac = Number(last[4]) / (last[4].length === 3 ? 1000 : 100); + return h * 3600 + m * 60 + s + frac; + } + // Fall back to Duration header + const durationMatch = stderr.match(/Duration:\s*(\d{2}):(\d{2}):(\d{2})\.(\d{2,3})/); + if (durationMatch) { + const h = Number(durationMatch[1]); + const m = Number(durationMatch[2]); + const s = Number(durationMatch[3]); + const frac = Number(durationMatch[4]) / (durationMatch[4].length === 3 ? 1000 : 100); + return h * 3600 + m * 60 + s + frac; + } + } + return 0; } function sendWhisperModelDownloadProgress( - webContents: Electron.WebContents, - payload: { status: 'idle' | 'downloading' | 'downloaded' | 'error'; progress: number; path?: string | null; error?: string }, + webContents: Electron.WebContents, + payload: { + status: "idle" | "downloading" | "downloaded" | "error"; + progress: number; + path?: string | null; + error?: string; + }, ) { - webContents.send('whisper-small-model-download-progress', payload) + webContents.send("whisper-small-model-download-progress", payload); } async function getWhisperSmallModelStatus() { - try { - await fs.access(WHISPER_SMALL_MODEL_PATH, fsConstants.R_OK) - return { - success: true, - exists: true, - path: WHISPER_SMALL_MODEL_PATH, - } - } catch { - return { - success: true, - exists: false, - path: null, - } - } + try { + await fs.access(WHISPER_SMALL_MODEL_PATH, fsConstants.R_OK); + return { + success: true, + exists: true, + path: WHISPER_SMALL_MODEL_PATH, + }; + } catch { + return { + success: true, + exists: false, + path: null, + }; + } } function downloadFileWithProgress( - url: string, - destinationPath: string, - onProgress: (progress: number) => void, + url: string, + destinationPath: string, + onProgress: (progress: number) => void, ): Promise { - const request = (currentUrl: string, redirectCount = 0): Promise => { - return new Promise((resolve, reject) => { - const req = httpsGet(currentUrl, (response) => { - const statusCode = response.statusCode ?? 0 - const location = response.headers.location - - if (statusCode >= 300 && statusCode < 400 && location) { - response.resume() - if (redirectCount >= 5) { - reject(new Error('Too many redirects while downloading Whisper model.')) - return - } - - const nextUrl = new URL(location, currentUrl).toString() - void request(nextUrl, redirectCount + 1).then(resolve).catch(reject) - return - } - - if (statusCode < 200 || statusCode >= 300) { - response.resume() - reject(new Error(`Whisper model download failed with status ${statusCode}.`)) - return - } - - const totalBytes = Number.parseInt(String(response.headers['content-length'] ?? '0'), 10) - let downloadedBytes = 0 - const fileStream = createWriteStream(destinationPath) - - response.on('data', (chunk: Buffer) => { - downloadedBytes += chunk.length - if (Number.isFinite(totalBytes) && totalBytes > 0) { - onProgress(Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))) - } - }) - - response.on('error', (error) => { - fileStream.destroy(error) - }) - - fileStream.on('error', (error) => { - response.destroy(error) - reject(error) - }) - - fileStream.on('finish', () => { - onProgress(100) - resolve() - }) - - response.pipe(fileStream) - }) - - req.on('error', reject) - }) - } - - return request(url) + const request = (currentUrl: string, redirectCount = 0): Promise => { + return new Promise((resolve, reject) => { + const req = httpsGet(currentUrl, (response) => { + const statusCode = response.statusCode ?? 0; + const location = response.headers.location; + + if (statusCode >= 300 && statusCode < 400 && location) { + response.resume(); + if (redirectCount >= 5) { + reject(new Error("Too many redirects while downloading Whisper model.")); + return; + } + + const nextUrl = new URL(location, currentUrl).toString(); + void request(nextUrl, redirectCount + 1) + .then(resolve) + .catch(reject); + return; + } + + if (statusCode < 200 || statusCode >= 300) { + response.resume(); + reject(new Error(`Whisper model download failed with status ${statusCode}.`)); + return; + } + + const totalBytes = Number.parseInt(String(response.headers["content-length"] ?? "0"), 10); + let downloadedBytes = 0; + const fileStream = createWriteStream(destinationPath); + + response.on("data", (chunk: Buffer) => { + downloadedBytes += chunk.length; + if (Number.isFinite(totalBytes) && totalBytes > 0) { + onProgress(Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))); + } + }); + + response.on("error", (error) => { + fileStream.destroy(error); + }); + + fileStream.on("error", (error) => { + response.destroy(error); + reject(error); + }); + + fileStream.on("finish", () => { + onProgress(100); + resolve(); + }); + + response.pipe(fileStream); + }); + + req.on("error", reject); + }); + }; + + return request(url); } async function downloadWhisperSmallModel(webContents: Electron.WebContents) { - await fs.mkdir(WHISPER_MODEL_DIR, { recursive: true }) - const tempPath = `${WHISPER_SMALL_MODEL_PATH}.download` - - sendWhisperModelDownloadProgress(webContents, { - status: 'downloading', - progress: 0, - path: null, - }) - - try { - await fs.rm(tempPath, { force: true }) - await downloadFileWithProgress(WHISPER_MODEL_DOWNLOAD_URL, tempPath, (progress) => { - sendWhisperModelDownloadProgress(webContents, { - status: 'downloading', - progress, - path: null, - }) - }) - await fs.rename(tempPath, WHISPER_SMALL_MODEL_PATH) - sendWhisperModelDownloadProgress(webContents, { - status: 'downloaded', - progress: 100, - path: WHISPER_SMALL_MODEL_PATH, - }) - return WHISPER_SMALL_MODEL_PATH - } catch (error) { - await fs.rm(tempPath, { force: true }).catch(() => undefined) - sendWhisperModelDownloadProgress(webContents, { - status: 'error', - progress: 0, - path: null, - error: String(error), - }) - throw error - } + await fs.mkdir(WHISPER_MODEL_DIR, { recursive: true }); + const tempPath = `${WHISPER_SMALL_MODEL_PATH}.download`; + + sendWhisperModelDownloadProgress(webContents, { + status: "downloading", + progress: 0, + path: null, + }); + + try { + await fs.rm(tempPath, { force: true }); + await downloadFileWithProgress(WHISPER_MODEL_DOWNLOAD_URL, tempPath, (progress) => { + sendWhisperModelDownloadProgress(webContents, { + status: "downloading", + progress, + path: null, + }); + }); + await fs.rename(tempPath, WHISPER_SMALL_MODEL_PATH); + sendWhisperModelDownloadProgress(webContents, { + status: "downloaded", + progress: 100, + path: WHISPER_SMALL_MODEL_PATH, + }); + return WHISPER_SMALL_MODEL_PATH; + } catch (error) { + await fs.rm(tempPath, { force: true }).catch(() => undefined); + sendWhisperModelDownloadProgress(webContents, { + status: "error", + progress: 0, + path: null, + error: String(error), + }); + throw error; + } } async function deleteWhisperSmallModel() { - await fs.rm(WHISPER_SMALL_MODEL_PATH, { force: true }) + await fs.rm(WHISPER_SMALL_MODEL_PATH, { force: true }); } function parseSrtTimestamp(value: string) { - const match = value.trim().match(/^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/) - if (!match) { - return null - } + const match = value.trim().match(/^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/); + if (!match) { + return null; + } - const [, hours, minutes, seconds, milliseconds] = match - return ( - Number(hours) * 60 * 60 * 1000 - + Number(minutes) * 60 * 1000 - + Number(seconds) * 1000 - + Number(milliseconds) - ) + const [, hours, minutes, seconds, milliseconds] = match; + return ( + Number(hours) * 60 * 60 * 1000 + + Number(minutes) * 60 * 1000 + + Number(seconds) * 1000 + + Number(milliseconds) + ); } type CaptionWordPayload = { - text: string - startMs: number - endMs: number - leadingSpace?: boolean -} + text: string; + startMs: number; + endMs: number; + leadingSpace?: boolean; +}; type CaptionCuePayload = { - id: string - startMs: number - endMs: number - text: string - words?: CaptionWordPayload[] -} + id: string; + startMs: number; + endMs: number; + text: string; + words?: CaptionWordPayload[]; +}; type WhisperJsonToken = { - text?: unknown - offsets?: { - from?: unknown - to?: unknown - } -} + text?: unknown; + offsets?: { + from?: unknown; + to?: unknown; + }; +}; type WhisperJsonSegment = { - text?: unknown - offsets?: { - from?: unknown - to?: unknown - } - tokens?: unknown -} + text?: unknown; + offsets?: { + from?: unknown; + to?: unknown; + }; + tokens?: unknown; +}; function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) + return typeof value === "number" && Number.isFinite(value); } function buildCaptionTextFromWords(words: CaptionWordPayload[]) { - return words - .map((word, index) => `${index > 0 && word.leadingSpace ? ' ' : ''}${word.text}`) - .join('') - .trim() + return words + .map((word, index) => `${index > 0 && word.leadingSpace ? " " : ""}${word.text}`) + .join("") + .trim(); } function parseWhisperJsonWords(tokens: unknown) { - if (!Array.isArray(tokens)) { - return [] - } - - const words: CaptionWordPayload[] = [] - let nextLeadingSpace = false - - for (const token of tokens) { - if (!token || typeof token !== 'object') { - continue - } - - const tokenData = token as WhisperJsonToken - const tokenText = typeof tokenData.text === 'string' ? tokenData.text : '' - if (!tokenText) { - continue - } - - const tokenStartMs = isFiniteNumber(tokenData.offsets?.from) ? Math.round(tokenData.offsets.from) : null - const tokenEndMs = isFiniteNumber(tokenData.offsets?.to) ? Math.round(tokenData.offsets.to) : null - const parts = tokenText.match(/\s+|[^\s]+/g) ?? [] - - for (const part of parts) { - if (/^\s+$/.test(part)) { - nextLeadingSpace = words.length > 0 - continue - } - - if (tokenStartMs == null || tokenEndMs == null || tokenEndMs <= tokenStartMs) { - return [] - } - - const previousWord = words.length > 0 ? words[words.length - 1] : null - if (!previousWord || nextLeadingSpace) { - words.push({ - text: part, - startMs: tokenStartMs, - endMs: tokenEndMs, - ...(words.length > 0 && nextLeadingSpace ? { leadingSpace: true } : {}), - }) - } else { - previousWord.text += part - previousWord.endMs = Math.max(previousWord.endMs, tokenEndMs) - } - - nextLeadingSpace = false - } - } - - return words.filter((word) => word.text.trim().length > 0) + if (!Array.isArray(tokens)) { + return []; + } + + const words: CaptionWordPayload[] = []; + let nextLeadingSpace = false; + + for (const token of tokens) { + if (!token || typeof token !== "object") { + continue; + } + + const tokenData = token as WhisperJsonToken; + const tokenText = typeof tokenData.text === "string" ? tokenData.text : ""; + if (!tokenText) { + continue; + } + + const tokenStartMs = isFiniteNumber(tokenData.offsets?.from) + ? Math.round(tokenData.offsets.from) + : null; + const tokenEndMs = isFiniteNumber(tokenData.offsets?.to) + ? Math.round(tokenData.offsets.to) + : null; + const parts = tokenText.match(/\s+|[^\s]+/g) ?? []; + + for (const part of parts) { + if (/^\s+$/.test(part)) { + nextLeadingSpace = words.length > 0; + continue; + } + + if (tokenStartMs == null || tokenEndMs == null || tokenEndMs <= tokenStartMs) { + return []; + } + + const previousWord = words.length > 0 ? words[words.length - 1] : null; + if (!previousWord || nextLeadingSpace) { + words.push({ + text: part, + startMs: tokenStartMs, + endMs: tokenEndMs, + ...(words.length > 0 && nextLeadingSpace ? { leadingSpace: true } : {}), + }); + } else { + previousWord.text += part; + previousWord.endMs = Math.max(previousWord.endMs, tokenEndMs); + } + + nextLeadingSpace = false; + } + } + + return words.filter((word) => word.text.trim().length > 0); } function parseWhisperJsonCues(content: string) { - try { - const parsed = JSON.parse(content) as { - transcription?: unknown - } - - if (!Array.isArray(parsed.transcription)) { - return [] - } - - return parsed.transcription - .map((segment, index) => { - if (!segment || typeof segment !== 'object') { - return null - } - - const segmentData = segment as WhisperJsonSegment - const startMs = isFiniteNumber(segmentData.offsets?.from) ? Math.round(segmentData.offsets.from) : null - const endMs = isFiniteNumber(segmentData.offsets?.to) ? Math.round(segmentData.offsets.to) : null - const segmentText = typeof segmentData.text === 'string' ? segmentData.text.trim() : '' - - if (startMs == null || endMs == null || endMs <= startMs) { - return null - } - - const words = parseWhisperJsonWords(segmentData.tokens) - const text = words.length > 0 ? buildCaptionTextFromWords(words) : segmentText - - if (!text) { - return null - } - - return { - id: `caption-${index + 1}`, - startMs, - endMs, - text, - ...(words.length > 0 ? { words } : {}), - } - }) - .filter((cue): cue is CaptionCuePayload => cue != null) - } catch (error) { - console.warn('[auto-captions] Failed to parse Whisper JSON output:', error) - return [] - } + try { + const parsed = JSON.parse(content) as { + transcription?: unknown; + }; + + if (!Array.isArray(parsed.transcription)) { + return []; + } + + return parsed.transcription + .map((segment, index) => { + if (!segment || typeof segment !== "object") { + return null; + } + + const segmentData = segment as WhisperJsonSegment; + const startMs = isFiniteNumber(segmentData.offsets?.from) + ? Math.round(segmentData.offsets.from) + : null; + const endMs = isFiniteNumber(segmentData.offsets?.to) + ? Math.round(segmentData.offsets.to) + : null; + const segmentText = typeof segmentData.text === "string" ? segmentData.text.trim() : ""; + + if (startMs == null || endMs == null || endMs <= startMs) { + return null; + } + + const words = parseWhisperJsonWords(segmentData.tokens); + const text = words.length > 0 ? buildCaptionTextFromWords(words) : segmentText; + + if (!text) { + return null; + } + + return { + id: `caption-${index + 1}`, + startMs, + endMs, + text, + ...(words.length > 0 ? { words } : {}), + }; + }) + .filter((cue): cue is CaptionCuePayload => cue != null); + } catch (error) { + console.warn("[auto-captions] Failed to parse Whisper JSON output:", error); + return []; + } } function parseSrtCues(content: string) { - return content - .split(/\r?\n\r?\n/) - .map((block, index) => { - const lines = block.split(/\r?\n/).map((line) => line.trim()) - const timingLine = lines.find((line) => line.includes('-->')) - if (!timingLine) { - return null - } - - const [rawStart, rawEnd] = timingLine.split('-->').map((part) => part.trim()) - const startMs = parseSrtTimestamp(rawStart) - const endMs = parseSrtTimestamp(rawEnd) - if (startMs == null || endMs == null || endMs <= startMs) { - return null - } - - const text = lines - .slice(lines.indexOf(timingLine) + 1) - .filter((line) => line.length > 0) - .join('\n') - .trim() - - if (!text) { - return null - } - - return { - id: `caption-${index + 1}`, - startMs, - endMs, - text, - } - }) - .filter((cue): cue is CaptionCuePayload => cue != null) + return content + .split(/\r?\n\r?\n/) + .map((block, index) => { + const lines = block.split(/\r?\n/).map((line) => line.trim()); + const timingLine = lines.find((line) => line.includes("-->")); + if (!timingLine) { + return null; + } + + const [rawStart, rawEnd] = timingLine.split("-->").map((part) => part.trim()); + const startMs = parseSrtTimestamp(rawStart); + const endMs = parseSrtTimestamp(rawEnd); + if (startMs == null || endMs == null || endMs <= startMs) { + return null; + } + + const text = lines + .slice(lines.indexOf(timingLine) + 1) + .filter((line) => line.length > 0) + .join("\n") + .trim(); + + if (!text) { + return null; + } + + return { + id: `caption-${index + 1}`, + startMs, + endMs, + text, + }; + }) + .filter((cue): cue is CaptionCuePayload => cue != null); } function shouldRetryWhisperWithoutJson(error: unknown) { - const message = error instanceof Error ? error.message : String(error) - return /unknown argument|output-json-full|output-json|ojf|\boj\b/i.test(message) + const message = error instanceof Error ? error.message : String(error); + return /unknown argument|output-json-full|output-json|ojf|\boj\b/i.test(message); } async function ensureReadableFile(filePath: string, description: string) { - await fs.access(filePath, fsConstants.R_OK) - if (description === 'whisper executable') { - try { - await fs.access(filePath, fsConstants.X_OK) - } catch { - throw new Error('The selected Whisper executable is not marked as executable.') - } - } + await fs.access(filePath, fsConstants.R_OK); + if (description === "whisper executable") { + try { + await fs.access(filePath, fsConstants.X_OK); + } catch { + throw new Error("The selected Whisper executable is not marked as executable."); + } + } } async function isExecutableFile(filePath: string) { - try { - await fs.access(filePath, fsConstants.R_OK | fsConstants.X_OK) - return true - } catch { - return false - } + try { + await fs.access(filePath, fsConstants.R_OK | fsConstants.X_OK); + return true; + } catch { + return false; + } } async function resolveWhisperExecutablePath(preferredPath?: string | null) { - const candidatePaths = [ - preferredPath?.trim() || null, - ...getBundledWhisperExecutableCandidates(), - process.env['WHISPER_CPP_PATH']?.trim() || null, - process.platform === 'darwin' ? '/opt/homebrew/bin/whisper-cli' : null, - process.platform === 'darwin' ? '/usr/local/bin/whisper-cli' : null, - process.platform === 'darwin' ? '/opt/homebrew/bin/whisper-cpp' : null, - process.platform === 'darwin' ? '/usr/local/bin/whisper-cpp' : null, - ].filter((value): value is string => Boolean(value)) - - for (const candidate of candidatePaths) { - const normalized = path.resolve(candidate) - if (await isExecutableFile(normalized)) { - return normalized - } - } - - const pathCommand = process.platform === 'win32' ? 'where' : 'which' - const binaryNames = process.platform === 'win32' - ? ['whisper-cli.exe', 'whisper.exe', 'main.exe'] - : ['whisper-cli', 'whisper-cpp', 'whisper', 'main'] - - for (const binaryName of binaryNames) { - const result = spawnSync(pathCommand, [binaryName], { encoding: 'utf-8' }) - if (result.status === 0) { - const resolvedPath = result.stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .find(Boolean) - - if (resolvedPath && await isExecutableFile(resolvedPath)) { - return resolvedPath - } - } - } - - throw new Error('No Whisper runtime was found. Recordly looked for a bundled binary first, then checked common system install locations.') + const candidatePaths = [ + preferredPath?.trim() || null, + ...getBundledWhisperExecutableCandidates(), + process.env["WHISPER_CPP_PATH"]?.trim() || null, + process.platform === "darwin" ? "/opt/homebrew/bin/whisper-cli" : null, + process.platform === "darwin" ? "/usr/local/bin/whisper-cli" : null, + process.platform === "darwin" ? "/opt/homebrew/bin/whisper-cpp" : null, + process.platform === "darwin" ? "/usr/local/bin/whisper-cpp" : null, + ].filter((value): value is string => Boolean(value)); + + for (const candidate of candidatePaths) { + const normalized = path.resolve(candidate); + if (await isExecutableFile(normalized)) { + return normalized; + } + } + + const pathCommand = process.platform === "win32" ? "where" : "which"; + const binaryNames = + process.platform === "win32" + ? ["whisper-cli.exe", "whisper.exe", "main.exe"] + : ["whisper-cli", "whisper-cpp", "whisper", "main"]; + + for (const binaryName of binaryNames) { + const result = spawnSync(pathCommand, [binaryName], { encoding: "utf-8" }); + if (result.status === 0) { + const resolvedPath = result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + + if (resolvedPath && (await isExecutableFile(resolvedPath))) { + return resolvedPath; + } + } + } + + throw new Error( + "No Whisper runtime was found. Recordly looked for a bundled binary first, then checked common system install locations.", + ); } async function resolveCaptionAudioCandidates(videoPath: string) { - const candidates: Array<{ path: string; label: string }> = [] - const seenPaths = new Set() + const candidates: Array<{ path: string; label: string }> = []; + const seenPaths = new Set(); - const pushCandidate = (candidatePath: string | null | undefined, label: string) => { - const normalizedCandidatePath = normalizeVideoSourcePath(candidatePath) - if (!normalizedCandidatePath || seenPaths.has(normalizedCandidatePath)) { - return - } + const pushCandidate = (candidatePath: string | null | undefined, label: string) => { + const normalizedCandidatePath = normalizeVideoSourcePath(candidatePath); + if (!normalizedCandidatePath || seenPaths.has(normalizedCandidatePath)) { + return; + } - seenPaths.add(normalizedCandidatePath) - candidates.push({ path: normalizedCandidatePath, label }) - } + seenPaths.add(normalizedCandidatePath); + candidates.push({ path: normalizedCandidatePath, label }); + }; - pushCandidate(videoPath, 'recording') + pushCandidate(videoPath, "recording"); - const requestedRecordingSession = await resolveRecordingSession(videoPath) - pushCandidate(requestedRecordingSession?.webcamPath, 'linked webcam recording') + const requestedRecordingSession = await resolveRecordingSession(videoPath); + pushCandidate(requestedRecordingSession?.webcamPath, "linked webcam recording"); - return candidates + return candidates; } async function extractCaptionAudioSource(options: { - videoPath: string - ffmpegPath: string - wavPath: string + videoPath: string; + ffmpegPath: string; + wavPath: string; }) { - const candidates = await resolveCaptionAudioCandidates(options.videoPath) - const attemptedCandidates: Array<{ - path: string - label: string - readable: boolean - extractedAudio: boolean - error?: string - }> = [] - - for (const candidate of candidates) { - try { - await ensureReadableFile(candidate.path, 'video file') - await execFileAsync( - options.ffmpegPath, - ['-y', '-i', candidate.path, '-map', '0:a:0', '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', options.wavPath], - { timeout: 5 * 60 * 1000, maxBuffer: 20 * 1024 * 1024 }, - ) - attemptedCandidates.push({ ...candidate, readable: true, extractedAudio: true }) - return candidate - } catch (error) { - attemptedCandidates.push({ - ...candidate, - readable: true, - extractedAudio: false, - error: error instanceof Error ? error.message : String(error), - }) - // Try the next candidate instead of failing on stale editor state. - } - } - - console.warn('[auto-captions] No audio source candidate could be extracted:', attemptedCandidates) - - throw new Error('No audio was found to transcribe in the saved recording file. Captions need an audio track. If this recording should have contained sound, the recording was saved without an audio stream.') + const candidates = await resolveCaptionAudioCandidates(options.videoPath); + const attemptedCandidates: Array<{ + path: string; + label: string; + readable: boolean; + extractedAudio: boolean; + error?: string; + }> = []; + + for (const candidate of candidates) { + try { + await ensureReadableFile(candidate.path, "video file"); + await execFileAsync( + options.ffmpegPath, + [ + "-y", + "-i", + candidate.path, + "-map", + "0:a:0", + "-vn", + "-ac", + "1", + "-ar", + "16000", + "-c:a", + "pcm_s16le", + options.wavPath, + ], + { timeout: 5 * 60 * 1000, maxBuffer: 20 * 1024 * 1024 }, + ); + attemptedCandidates.push({ ...candidate, readable: true, extractedAudio: true }); + return candidate; + } catch (error) { + attemptedCandidates.push({ + ...candidate, + readable: true, + extractedAudio: false, + error: error instanceof Error ? error.message : String(error), + }); + // Try the next candidate instead of failing on stale editor state. + } + } + + console.warn( + "[auto-captions] No audio source candidate could be extracted:", + attemptedCandidates, + ); + + throw new Error( + "No audio was found to transcribe in the saved recording file. Captions need an audio track. If this recording should have contained sound, the recording was saved without an audio stream.", + ); } async function generateAutoCaptionsFromVideo(options: { - videoPath: string - whisperExecutablePath?: string - whisperModelPath: string - language?: string + videoPath: string; + whisperExecutablePath?: string; + whisperModelPath: string; + language?: string; }) { - const ffmpegPath = getFfmpegBinaryPath() - const normalizedVideoPath = normalizeVideoSourcePath(options.videoPath) - if (!normalizedVideoPath) { - throw new Error('Missing source video path.') - } - - const whisperExecutablePath = await resolveWhisperExecutablePath(options.whisperExecutablePath) - const whisperModelPath = path.resolve(options.whisperModelPath) - await ensureReadableFile(whisperExecutablePath, 'whisper executable') - await ensureReadableFile(whisperModelPath, 'whisper model') - - const tempBase = path.join(app.getPath('temp'), `recordly-captions-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) - const wavPath = `${tempBase}.wav` - const outputBase = `${tempBase}-whisper` - const srtPath = `${outputBase}.srt` - const jsonPath = `${outputBase}.json` - - try { - const audioSource = await extractCaptionAudioSource({ - videoPath: normalizedVideoPath, - ffmpegPath, - wavPath, - }) - - const language = options.language && options.language.trim() ? options.language.trim() : 'auto' - const whisperBaseArgs = [ - '-m', whisperModelPath, - '-f', wavPath, - '-osrt', - '-of', outputBase, - '-l', language, - '-np', - ] - - let jsonEnabled = true - try { - await execFileAsync(whisperExecutablePath, [...whisperBaseArgs, '-ojf'], { - timeout: 30 * 60 * 1000, - maxBuffer: 20 * 1024 * 1024, - }) - } catch (error) { - if (!shouldRetryWhisperWithoutJson(error)) { - throw error - } - - jsonEnabled = false - console.warn('[auto-captions] Whisper runtime does not support JSON full output, retrying with SRT only:', error) - await execFileAsync(whisperExecutablePath, whisperBaseArgs, { - timeout: 30 * 60 * 1000, - maxBuffer: 20 * 1024 * 1024, - }) - } - - const timedCues = jsonEnabled - ? parseWhisperJsonCues(await fs.readFile(jsonPath, 'utf-8')) - : [] - const cues = timedCues.length > 0 - ? timedCues - : parseSrtCues(await fs.readFile(srtPath, 'utf-8')) - if (cues.length === 0) { - throw new Error('Whisper completed, but no caption cues were produced.') - } - - return { - cues, - audioSourceLabel: audioSource.label, - } - } finally { - await Promise.allSettled([ - fs.rm(wavPath, { force: true }), - fs.rm(srtPath, { force: true }), - fs.rm(jsonPath, { force: true }), - ]) - } + const ffmpegPath = getFfmpegBinaryPath(); + const normalizedVideoPath = normalizeVideoSourcePath(options.videoPath); + if (!normalizedVideoPath) { + throw new Error("Missing source video path."); + } + + const whisperExecutablePath = await resolveWhisperExecutablePath(options.whisperExecutablePath); + const whisperModelPath = path.resolve(options.whisperModelPath); + await ensureReadableFile(whisperExecutablePath, "whisper executable"); + await ensureReadableFile(whisperModelPath, "whisper model"); + + const tempBase = path.join( + app.getPath("temp"), + `recordly-captions-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + const wavPath = `${tempBase}.wav`; + const outputBase = `${tempBase}-whisper`; + const srtPath = `${outputBase}.srt`; + const jsonPath = `${outputBase}.json`; + + try { + const audioSource = await extractCaptionAudioSource({ + videoPath: normalizedVideoPath, + ffmpegPath, + wavPath, + }); + + const language = options.language && options.language.trim() ? options.language.trim() : "auto"; + const whisperBaseArgs = [ + "-m", + whisperModelPath, + "-f", + wavPath, + "-osrt", + "-of", + outputBase, + "-l", + language, + "-np", + ]; + + let jsonEnabled = true; + try { + await execFileAsync(whisperExecutablePath, [...whisperBaseArgs, "-ojf"], { + timeout: 30 * 60 * 1000, + maxBuffer: 20 * 1024 * 1024, + }); + } catch (error) { + if (!shouldRetryWhisperWithoutJson(error)) { + throw error; + } + + jsonEnabled = false; + console.warn( + "[auto-captions] Whisper runtime does not support JSON full output, retrying with SRT only:", + error, + ); + await execFileAsync(whisperExecutablePath, whisperBaseArgs, { + timeout: 30 * 60 * 1000, + maxBuffer: 20 * 1024 * 1024, + }); + } + + const timedCues = jsonEnabled ? parseWhisperJsonCues(await fs.readFile(jsonPath, "utf-8")) : []; + const cues = + timedCues.length > 0 ? timedCues : parseSrtCues(await fs.readFile(srtPath, "utf-8")); + if (cues.length === 0) { + throw new Error("Whisper completed, but no caption cues were produced."); + } + + return { + cues, + audioSourceLabel: audioSource.label, + }; + } finally { + await Promise.allSettled([ + fs.rm(wavPath, { force: true }), + fs.rm(srtPath, { force: true }), + fs.rm(jsonPath, { force: true }), + ]); + } } function waitForFfmpegCaptureStart(process: ChildProcessWithoutNullStreams) { - return new Promise((resolve, reject) => { - const onError = (error: Error) => { - cleanup() - reject(error) - } - - const onExit = (code: number | null) => { - cleanup() - reject(new Error(ffmpegCaptureOutputBuffer.trim() || `FFmpeg exited before recording started (code ${code ?? 'unknown'})`)) - } - - const timer = setTimeout(() => { - cleanup() - resolve() - }, 900) - - const cleanup = () => { - clearTimeout(timer) - process.off('error', onError) - process.off('exit', onExit) - } - - process.once('error', onError) - process.once('exit', onExit) - }) + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const onExit = (code: number | null) => { + cleanup(); + reject( + new Error( + ffmpegCaptureOutputBuffer.trim() || + `FFmpeg exited before recording started (code ${code ?? "unknown"})`, + ), + ); + }; + + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, 900); + + const cleanup = () => { + clearTimeout(timer); + process.off("error", onError); + process.off("exit", onExit); + }; + + process.once("error", onError); + process.once("exit", onExit); + }); } function waitForFfmpegCaptureStop(process: ChildProcessWithoutNullStreams, outputPath: string) { - return new Promise((resolve, reject) => { - const onClose = async (code: number | null) => { - cleanup() - - try { - await fs.access(outputPath) - if (code === 0 || code === null) { - resolve(outputPath) - return - } - - if (ffmpegCaptureOutputBuffer.includes('Exiting normally')) { - resolve(outputPath) - return - } - } catch { - // handled below - } - - reject(new Error(ffmpegCaptureOutputBuffer.trim() || `FFmpeg exited with code ${code ?? 'unknown'}`)) - } - - const onError = (error: Error) => { - cleanup() - reject(error) - } - - const cleanup = () => { - process.off('close', onClose) - process.off('error', onError) - } - - process.once('close', onClose) - process.once('error', onError) - }) + return new Promise((resolve, reject) => { + const onClose = async (code: number | null) => { + cleanup(); + + try { + await fs.access(outputPath); + if (code === 0 || code === null) { + resolve(outputPath); + return; + } + + if (ffmpegCaptureOutputBuffer.includes("Exiting normally")) { + resolve(outputPath); + return; + } + } catch { + // handled below + } + + reject( + new Error( + ffmpegCaptureOutputBuffer.trim() || `FFmpeg exited with code ${code ?? "unknown"}`, + ), + ); + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const cleanup = () => { + process.off("close", onClose); + process.off("error", onError); + }; + + process.once("close", onClose); + process.once("error", onError); + }); } function getDisplayBoundsForSource(source: SelectedSource) { - return resolveWindowsCaptureDisplay( - source, - getScreen().getAllDisplays(), - getScreen().getPrimaryDisplay(), - ).bounds + return resolveWindowsCaptureDisplay( + source, + getScreen().getAllDisplays(), + getScreen().getPrimaryDisplay(), + ).bounds; } function parseXwininfoBounds(stdout: string): WindowBounds | null { - const absX = stdout.match(/Absolute upper-left X:\s+(-?\d+)/) - const absY = stdout.match(/Absolute upper-left Y:\s+(-?\d+)/) - const width = stdout.match(/Width:\s+(\d+)/) - const height = stdout.match(/Height:\s+(\d+)/) + const absX = stdout.match(/Absolute upper-left X:\s+(-?\d+)/); + const absY = stdout.match(/Absolute upper-left Y:\s+(-?\d+)/); + const width = stdout.match(/Width:\s+(\d+)/); + const height = stdout.match(/Height:\s+(\d+)/); - if (!absX || !absY || !width || !height) { - return null - } + if (!absX || !absY || !width || !height) { + return null; + } - return { - x: Number.parseInt(absX[1], 10), - y: Number.parseInt(absY[1], 10), - width: Number.parseInt(width[1], 10), - height: Number.parseInt(height[1], 10), - } + return { + x: Number.parseInt(absX[1], 10), + y: Number.parseInt(absY[1], 10), + width: Number.parseInt(width[1], 10), + height: Number.parseInt(height[1], 10), + }; } async function resolveLinuxWindowBounds(source: SelectedSource): Promise { - const windowId = parseWindowId(source?.id) - - if (windowId) { - try { - const { stdout } = await execFileAsync('xwininfo', ['-id', String(windowId)], { timeout: 1500 }) - const bounds = parseXwininfoBounds(stdout) - if (bounds && bounds.width > 0 && bounds.height > 0) { - return bounds - } - } catch { - // fall back to title lookup below - } - } - - const windowTitle = typeof source.windowTitle === 'string' ? source.windowTitle.trim() : source.name.trim() - if (!windowTitle) { - return null - } - - try { - const { stdout } = await execFileAsync('xwininfo', ['-name', windowTitle], { timeout: 1500 }) - const bounds = parseXwininfoBounds(stdout) - return bounds && bounds.width > 0 && bounds.height > 0 ? bounds : null - } catch { - return null - } + const windowId = parseWindowId(source?.id); + + if (windowId) { + try { + const { stdout } = await execFileAsync("xwininfo", ["-id", String(windowId)], { + timeout: 1500, + }); + const bounds = parseXwininfoBounds(stdout); + if (bounds && bounds.width > 0 && bounds.height > 0) { + return bounds; + } + } catch { + // fall back to title lookup below + } + } + + const windowTitle = + typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim(); + if (!windowTitle) { + return null; + } + + try { + const { stdout } = await execFileAsync("xwininfo", ["-name", windowTitle], { timeout: 1500 }); + const bounds = parseXwininfoBounds(stdout); + return bounds && bounds.width > 0 && bounds.height > 0 ? bounds : null; + } catch { + return null; + } } async function resolveWindowsWindowBounds(source: SelectedSource): Promise { - const windowId = parseWindowId(source?.id) - const windowTitle = typeof source.windowTitle === 'string' ? source.windowTitle.trim() : source.name.trim() - - if (!windowId && !windowTitle) { - return null - } - - const script = [ - 'param([string]$windowId, [string]$windowTitle)', - 'Add-Type -TypeDefinition @"', - 'using System;', - 'using System.Runtime.InteropServices;', - 'public static class RecordlyWindowBounds {', - ' [StructLayout(LayoutKind.Sequential)]', - ' public struct RECT {', - ' public int Left;', - ' public int Top;', - ' public int Right;', - ' public int Bottom;', - ' }', - ' [DllImport("user32.dll")]', - ' [return: MarshalAs(UnmanagedType.Bool)]', - ' public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);', - '}', - '"@', - '$handle = [Int64]0', - 'if ($windowId) {', - ' $handle = [Int64]$windowId', - '}', - 'if ($handle -le 0 -and $windowTitle) {', - ' $matchingProcess = Get-Process | Where-Object { $_.MainWindowTitle -eq $windowTitle -or $_.MainWindowTitle -like "*$windowTitle*" } | Select-Object -First 1', - ' if ($matchingProcess) {', - ' $handle = $matchingProcess.MainWindowHandle.ToInt64()', - ' }', - '}', - 'if ($handle -le 0) {', - ' exit 1', - '}', - '$rect = New-Object RecordlyWindowBounds+RECT', - 'if (-not [RecordlyWindowBounds]::GetWindowRect([IntPtr]$handle, [ref]$rect)) {', - ' exit 1', - '}', - '@{ x = $rect.Left; y = $rect.Top; width = $rect.Right - $rect.Left; height = $rect.Bottom - $rect.Top } | ConvertTo-Json -Compress', - ].join('\n') - - try { - const { stdout } = await execFileAsync( - 'powershell.exe', - ['-NoProfile', '-Command', script, String(windowId ?? ''), windowTitle], - { timeout: 1500 }, - ) - const bounds = JSON.parse(stdout) as WindowBounds - return bounds && bounds.width > 0 && bounds.height > 0 ? bounds : null - } catch { - return null - } + const windowId = parseWindowId(source?.id); + const windowTitle = + typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim(); + + if (!windowId && !windowTitle) { + return null; + } + + const script = [ + "param([string]$windowId, [string]$windowTitle)", + 'Add-Type -TypeDefinition @"', + "using System;", + "using System.Runtime.InteropServices;", + "public static class RecordlyWindowBounds {", + " [StructLayout(LayoutKind.Sequential)]", + " public struct RECT {", + " public int Left;", + " public int Top;", + " public int Right;", + " public int Bottom;", + " }", + ' [DllImport("user32.dll")]', + " [return: MarshalAs(UnmanagedType.Bool)]", + " public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);", + "}", + '"@', + "$handle = [Int64]0", + "if ($windowId) {", + " $handle = [Int64]$windowId", + "}", + "if ($handle -le 0 -and $windowTitle) {", + ' $matchingProcess = Get-Process | Where-Object { $_.MainWindowTitle -eq $windowTitle -or $_.MainWindowTitle -like "*$windowTitle*" } | Select-Object -First 1', + " if ($matchingProcess) {", + " $handle = $matchingProcess.MainWindowHandle.ToInt64()", + " }", + "}", + "if ($handle -le 0) {", + " exit 1", + "}", + "$rect = New-Object RecordlyWindowBounds+RECT", + "if (-not [RecordlyWindowBounds]::GetWindowRect([IntPtr]$handle, [ref]$rect)) {", + " exit 1", + "}", + "@{ x = $rect.Left; y = $rect.Top; width = $rect.Right - $rect.Left; height = $rect.Bottom - $rect.Top } | ConvertTo-Json -Compress", + ].join("\n"); + + try { + const { stdout } = await execFileAsync( + "powershell.exe", + ["-NoProfile", "-Command", script, String(windowId ?? ""), windowTitle], + { timeout: 1500 }, + ); + const bounds = JSON.parse(stdout) as WindowBounds; + return bounds && bounds.width > 0 && bounds.height > 0 ? bounds : null; + } catch { + return null; + } } async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath: string) { - const commonOutputArgs = ['-an', '-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', outputPath] - - if (process.platform === 'win32') { - if (source?.id?.startsWith('window:')) { - const windowTitle = typeof source.windowTitle === 'string' ? source.windowTitle.trim() : source.name.trim() - if (!windowTitle) { - throw new Error('Missing window title for FFmpeg window capture') - } - - return ['-y', '-f', 'gdigrab', '-framerate', '60', '-draw_mouse', '0', '-i', `title=${windowTitle}`, ...commonOutputArgs] - } - - return ['-y', '-f', 'gdigrab', '-framerate', '60', '-draw_mouse', '0', '-i', 'desktop', ...commonOutputArgs] - } - - if (process.platform === 'linux') { - const displayEnv = process.env.DISPLAY || ':0.0' - if (source?.id?.startsWith('window:')) { - const bounds = await resolveLinuxWindowBounds(source) - if (!bounds) { - throw new Error('Unable to resolve Linux window bounds for FFmpeg capture') - } - - return [ - '-y', - '-f', 'x11grab', - '-framerate', '60', - '-draw_mouse', '0', - '-video_size', `${Math.max(2, bounds.width)}x${Math.max(2, bounds.height)}`, - '-i', `${displayEnv}+${Math.round(bounds.x)},${Math.round(bounds.y)}`, - ...commonOutputArgs, - ] - } - - const bounds = getDisplayBoundsForSource(source) - return [ - '-y', - '-f', 'x11grab', - '-framerate', '60', - '-draw_mouse', '0', - '-video_size', `${Math.max(2, bounds.width)}x${Math.max(2, bounds.height)}`, - '-i', `${displayEnv}+${Math.round(bounds.x)},${Math.round(bounds.y)}`, - ...commonOutputArgs, - ] - } - - if (process.platform === 'darwin') { - return ['-y', '-f', 'avfoundation', '-capture_cursor', '0', '-framerate', '60', '-i', '1:none', ...commonOutputArgs] - } - - throw new Error(`FFmpeg capture is not supported on ${process.platform}`) + const commonOutputArgs = [ + "-an", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + outputPath, + ]; + + if (process.platform === "win32") { + if (source?.id?.startsWith("window:")) { + const windowTitle = + typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim(); + if (!windowTitle) { + throw new Error("Missing window title for FFmpeg window capture"); + } + + return [ + "-y", + "-f", + "gdigrab", + "-framerate", + "60", + "-draw_mouse", + "0", + "-i", + `title=${windowTitle}`, + ...commonOutputArgs, + ]; + } + + return [ + "-y", + "-f", + "gdigrab", + "-framerate", + "60", + "-draw_mouse", + "0", + "-i", + "desktop", + ...commonOutputArgs, + ]; + } + + if (process.platform === "linux") { + const displayEnv = process.env.DISPLAY || ":0.0"; + if (source?.id?.startsWith("window:")) { + const bounds = await resolveLinuxWindowBounds(source); + if (!bounds) { + throw new Error("Unable to resolve Linux window bounds for FFmpeg capture"); + } + + return [ + "-y", + "-f", + "x11grab", + "-framerate", + "60", + "-draw_mouse", + "0", + "-video_size", + `${Math.max(2, bounds.width)}x${Math.max(2, bounds.height)}`, + "-i", + `${displayEnv}+${Math.round(bounds.x)},${Math.round(bounds.y)}`, + ...commonOutputArgs, + ]; + } + + const bounds = getDisplayBoundsForSource(source); + return [ + "-y", + "-f", + "x11grab", + "-framerate", + "60", + "-draw_mouse", + "0", + "-video_size", + `${Math.max(2, bounds.width)}x${Math.max(2, bounds.height)}`, + "-i", + `${displayEnv}+${Math.round(bounds.x)},${Math.round(bounds.y)}`, + ...commonOutputArgs, + ]; + } + + if (process.platform === "darwin") { + return [ + "-y", + "-f", + "avfoundation", + "-capture_cursor", + "0", + "-framerate", + "60", + "-i", + "1:none", + ...commonOutputArgs, + ]; + } + + throw new Error(`FFmpeg capture is not supported on ${process.platform}`); } function getWindowsCaptureExePath() { - return resolvePreferredWindowsNativeHelperPath('wgc-capture', 'wgc-capture.exe') + return resolvePreferredWindowsNativeHelperPath("wgc-capture", "wgc-capture.exe"); } function getCursorMonitorExePath() { - return resolvePreferredWindowsNativeHelperPath('cursor-monitor', 'cursor-monitor.exe') + return resolvePreferredWindowsNativeHelperPath("cursor-monitor", "cursor-monitor.exe"); } async function isNativeWindowsCaptureAvailable(): Promise { - if (process.platform !== 'win32') return false - - const helperPath = getWindowsCaptureExePath() - const os = await import('node:os') - const [major, , build] = os.release().split('.').map(Number) - const supported = major >= 10 && build >= 19041 - let helperExists = false - - try { - await fs.access(helperPath, fsConstants.X_OK) - helperExists = true - } catch { - recordNativeCaptureDiagnostics({ - backend: 'windows-wgc', - phase: 'availability', - helperPath, - helperExists, - osRelease: os.release(), - supported, - error: 'Native Windows capture helper is missing or not executable.', - }) - return false - } - - recordNativeCaptureDiagnostics({ - backend: 'windows-wgc', - phase: 'availability', - helperPath, - helperExists, - osRelease: os.release(), - supported, - }) - - return supported + if (process.platform !== "win32") return false; + + const helperPath = getWindowsCaptureExePath(); + const os = await import("node:os"); + const [major, , build] = os.release().split(".").map(Number); + const supported = major >= 10 && build >= 19041; + let helperExists = false; + + try { + await fs.access(helperPath, fsConstants.X_OK); + helperExists = true; + } catch { + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "availability", + helperPath, + helperExists, + osRelease: os.release(), + supported, + error: "Native Windows capture helper is missing or not executable.", + }); + return false; + } + + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "availability", + helperPath, + helperExists, + osRelease: os.release(), + supported, + }); + + return supported; } function waitForWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup() - reject(new Error('Timed out waiting for native Windows capture to start')) - }, 12000) - - const onStdout = (chunk: Buffer) => { - const text = chunk.toString() - if (text.includes('Recording started')) { - cleanup() - resolve() - } - } - - const onError = (error: Error) => { - cleanup() - reject(error) - } - - const onExit = (code: number | null) => { - cleanup() - reject(new Error(windowsCaptureOutputBuffer.trim() || `Native Windows capture exited before recording started (code ${code ?? 'unknown'})`)) - } - - const cleanup = () => { - clearTimeout(timer) - proc.stdout.off('data', onStdout) - proc.off('error', onError) - proc.off('exit', onExit) - } - - proc.stdout.on('data', onStdout) - proc.once('error', onError) - proc.once('exit', onExit) - }) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for native Windows capture to start")); + }, 12000); + + const onStdout = (chunk: Buffer) => { + const text = chunk.toString(); + if (text.includes("Recording started")) { + cleanup(); + resolve(); + } + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const onExit = (code: number | null) => { + cleanup(); + reject( + new Error( + windowsCaptureOutputBuffer.trim() || + `Native Windows capture exited before recording started (code ${code ?? "unknown"})`, + ), + ); + }; + + const cleanup = () => { + clearTimeout(timer); + proc.stdout.off("data", onStdout); + proc.off("error", onError); + proc.off("exit", onExit); + }; + + proc.stdout.on("data", onStdout); + proc.once("error", onError); + proc.once("exit", onExit); + }); } function waitForWindowsCaptureStop(proc: ChildProcessWithoutNullStreams) { - return new Promise((resolve, reject) => { - const onClose = (code: number | null) => { - cleanup() - const match = windowsCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/) - if (match?.[1]) { - resolve(match[1].trim()) - return - } - if (code === 0 && windowsCaptureTargetPath) { - resolve(windowsCaptureTargetPath) - return - } - reject(new Error(windowsCaptureOutputBuffer.trim() || `Native Windows capture exited with code ${code ?? 'unknown'}`)) - } - - const onError = (error: Error) => { - cleanup() - reject(error) - } - - const cleanup = () => { - proc.off('close', onClose) - proc.off('error', onError) - } - - proc.once('close', onClose) - proc.once('error', onError) - }) + return new Promise((resolve, reject) => { + const onClose = (code: number | null) => { + cleanup(); + const match = windowsCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/); + if (match?.[1]) { + resolve(match[1].trim()); + return; + } + if (code === 0 && windowsCaptureTargetPath) { + resolve(windowsCaptureTargetPath); + return; + } + reject( + new Error( + windowsCaptureOutputBuffer.trim() || + `Native Windows capture exited with code ${code ?? "unknown"}`, + ), + ); + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const cleanup = () => { + proc.off("close", onClose); + proc.off("error", onError); + }; + + proc.once("close", onClose); + proc.once("error", onError); + }); } function attachWindowsCaptureLifecycle(proc: ChildProcessWithoutNullStreams) { - proc.once('close', () => { - const wasActive = windowsNativeCaptureActive - windowsCaptureProcess = null - - if (!wasActive || windowsCaptureStopRequested) { - return - } - - windowsNativeCaptureActive = false - windowsCaptureTargetPath = null - windowsCaptureStopRequested = false - - const sourceName = selectedSource?.name ?? 'Screen' - BrowserWindow.getAllWindows().forEach((window) => { - if (!window.isDestroyed()) { - window.webContents.send('recording-state-changed', { - recording: false, - sourceName, - }) - } - }) - - emitRecordingInterrupted('capture-stopped', 'Recording stopped unexpectedly.') - }) -} - -async function muxNativeWindowsVideoWithAudio(videoPath: string, systemAudioPath: string | null, micAudioPath: string | null, pauseSegments: PauseSegment[] = []) { - const ffmpegPath = getFfmpegBinaryPath() - const inputs: string[] = ['-i', videoPath] - const audioInputs: string[] = [] - const audioFilePaths: string[] = [] - - for (const [label, audioPath] of [['system', systemAudioPath], ['mic', micAudioPath]] as const) { - if (!audioPath) continue - try { - const stat = await fs.stat(audioPath) - if (stat.size <= 0) { - console.warn(`[mux-win] Skipping ${label} audio: file is empty (${audioPath})`) - await fs.rm(audioPath, { force: true }).catch(() => {}) - continue - } - inputs.push('-i', audioPath) - audioInputs.push(label) - audioFilePaths.push(audioPath) - } catch { - console.warn(`[mux-win] Skipping ${label} audio: file not accessible (${audioPath})`) - } - } - - if (audioInputs.length === 0) return - - // Probe durations to compute audio delay offsets. - // If an audio file is shorter than the video it means the audio device - // started delivering samples late (common with Bluetooth / iPhone mics). - const videoDuration = await probeMediaDurationSeconds(videoPath) - const audioDelays: Map = new Map() - - if (videoDuration > 0) { - for (let i = 0; i < audioFilePaths.length; i++) { - const audioDuration = await probeMediaDurationSeconds(audioFilePaths[i]) - const delayMs = audioDuration > 0 ? Math.max(0, Math.round((videoDuration - audioDuration) * 1000)) : 0 - audioDelays.set(audioInputs[i], delayMs) - if (delayMs > 0) { - console.log(`[mux-win] ${audioInputs[i]} audio is ${(delayMs / 1000).toFixed(2)}s shorter than video — adding ${delayMs}ms delay`) - } - } - } - - const mixedOutputPath = `${videoPath}.muxed.mp4` - const normalizedPauseSegments = normalizePauseSegments(pauseSegments) - const systemDelayMs = audioDelays.get('system') ?? 0 - const micDelayMs = audioDelays.get('mic') ?? 0 - - if (audioInputs.length === 2) { - // Both system + mic audio: mix them - const filterParts: string[] = [] - const systemPauseFilter = buildPausedAudioFilter('1:a', 'system_trimmed', normalizedPauseSegments) - const micPauseFilter = buildPausedAudioFilter('2:a', 'mic_trimmed', normalizedPauseSegments) - - if (systemPauseFilter) { - filterParts.push(systemPauseFilter) - } - if (micPauseFilter) { - filterParts.push(micPauseFilter) - } - - const systemLabel = systemPauseFilter ? '[system_trimmed]' : '[1:a]' - const micLabel = micPauseFilter ? '[mic_trimmed]' : '[2:a]' - - // Apply delay to compensate for late audio start - if (micDelayMs > 0) { - filterParts.push(`${micLabel}adelay=${micDelayMs}|${micDelayMs},asetpts=PTS-STARTPTS[m]`) - } else { - filterParts.push(`${micLabel}asetpts=PTS-STARTPTS[m]`) - } - - if (systemDelayMs > 0) { - filterParts.push(`${systemLabel}adelay=${systemDelayMs}|${systemDelayMs},asetpts=PTS-STARTPTS[s]`) - filterParts.push(`[s][m]amix=inputs=2:duration=longest:normalize=0[aout]`) - } else { - filterParts.push(`${systemLabel}[m]amix=inputs=2:duration=longest:normalize=0[aout]`) - } - - await execFileAsync( - ffmpegPath, - [ - '-y', - ...inputs, - '-filter_complex', filterParts.join(';'), - '-map', '0:v:0', - '-map', '[aout]', - '-c:v', 'copy', - '-c:a', 'aac', - '-b:a', '192k', - '-shortest', - mixedOutputPath, - ], - { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, - ) - } else { - // Single audio track - const pauseFilter = buildPausedAudioFilter('1:a', 'aout', normalizedPauseSegments) - const singleDelayMs = audioDelays.get(audioInputs[0]) ?? 0 - - if (pauseFilter || singleDelayMs > 0) { - const filterParts: string[] = [] - if (pauseFilter) { - filterParts.push(pauseFilter) - } - const srcLabel = pauseFilter ? '[aout]' : '[1:a]' - if (singleDelayMs > 0) { - filterParts.push(`${srcLabel}adelay=${singleDelayMs}|${singleDelayMs},asetpts=PTS-STARTPTS[delayed]`) - } - const outLabel = singleDelayMs > 0 ? '[delayed]' : '[aout]' - - await execFileAsync( - ffmpegPath, - [ - '-y', - ...inputs, - '-filter_complex', filterParts.join(';'), - '-map', '0:v:0', - '-map', outLabel, - '-c:v', 'copy', - '-c:a', 'aac', - '-b:a', '192k', - '-shortest', - mixedOutputPath, - ], - { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, - ) - } else { - await execFileAsync( - ffmpegPath, - [ - '-y', - ...inputs, - '-map', '0:v:0', - '-map', '1:a:0', - '-c:v', 'copy', - '-c:a', 'aac', - '-b:a', '192k', - '-shortest', - mixedOutputPath, - ], - { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, - ) - } - } - - await moveFileWithOverwrite(mixedOutputPath, videoPath) - - // Clean up audio files - for (const audioPath of [systemAudioPath, micAudioPath]) { - if (audioPath) { - await fs.rm(audioPath, { force: true }).catch(() => {}) - } - } + proc.once("close", () => { + const wasActive = windowsNativeCaptureActive; + windowsCaptureProcess = null; + + if (!wasActive || windowsCaptureStopRequested) { + return; + } + + windowsNativeCaptureActive = false; + windowsCaptureTargetPath = null; + windowsCaptureStopRequested = false; + + const sourceName = selectedSource?.name ?? "Screen"; + BrowserWindow.getAllWindows().forEach((window) => { + if (!window.isDestroyed()) { + window.webContents.send("recording-state-changed", { + recording: false, + sourceName, + }); + } + }); + + emitRecordingInterrupted("capture-stopped", "Recording stopped unexpectedly."); + }); +} + +async function muxNativeWindowsVideoWithAudio( + videoPath: string, + systemAudioPath: string | null, + micAudioPath: string | null, + pauseSegments: PauseSegment[] = [], +) { + const ffmpegPath = getFfmpegBinaryPath(); + const inputs: string[] = ["-i", videoPath]; + const audioInputs: string[] = []; + const audioFilePaths: string[] = []; + + for (const [label, audioPath] of [ + ["system", systemAudioPath], + ["mic", micAudioPath], + ] as const) { + if (!audioPath) continue; + try { + const stat = await fs.stat(audioPath); + if (stat.size <= 0) { + console.warn(`[mux-win] Skipping ${label} audio: file is empty (${audioPath})`); + await fs.rm(audioPath, { force: true }).catch(() => {}); + continue; + } + inputs.push("-i", audioPath); + audioInputs.push(label); + audioFilePaths.push(audioPath); + } catch { + console.warn(`[mux-win] Skipping ${label} audio: file not accessible (${audioPath})`); + } + } + + if (audioInputs.length === 0) return; + + // Probe durations to compute audio delay offsets. + // If an audio file is shorter than the video it means the audio device + // started delivering samples late (common with Bluetooth / iPhone mics). + const videoDuration = await probeMediaDurationSeconds(videoPath); + const audioDelays: Map = new Map(); + + if (videoDuration > 0) { + for (let i = 0; i < audioFilePaths.length; i++) { + const audioDuration = await probeMediaDurationSeconds(audioFilePaths[i]); + const delayMs = + audioDuration > 0 ? Math.max(0, Math.round((videoDuration - audioDuration) * 1000)) : 0; + audioDelays.set(audioInputs[i], delayMs); + if (delayMs > 0) { + console.log( + `[mux-win] ${audioInputs[i]} audio is ${(delayMs / 1000).toFixed(2)}s shorter than video — adding ${delayMs}ms delay`, + ); + } + } + } + + const mixedOutputPath = `${videoPath}.muxed.mp4`; + const normalizedPauseSegments = normalizePauseSegments(pauseSegments); + const systemDelayMs = audioDelays.get("system") ?? 0; + const micDelayMs = audioDelays.get("mic") ?? 0; + + if (audioInputs.length === 2) { + // Both system + mic audio: mix them + const filterParts: string[] = []; + const systemPauseFilter = buildPausedAudioFilter( + "1:a", + "system_trimmed", + normalizedPauseSegments, + ); + const micPauseFilter = buildPausedAudioFilter("2:a", "mic_trimmed", normalizedPauseSegments); + + if (systemPauseFilter) { + filterParts.push(systemPauseFilter); + } + if (micPauseFilter) { + filterParts.push(micPauseFilter); + } + + const systemLabel = systemPauseFilter ? "[system_trimmed]" : "[1:a]"; + const micLabel = micPauseFilter ? "[mic_trimmed]" : "[2:a]"; + + // Apply delay to compensate for late audio start + if (micDelayMs > 0) { + filterParts.push(`${micLabel}adelay=${micDelayMs}|${micDelayMs},asetpts=PTS-STARTPTS[m]`); + } else { + filterParts.push(`${micLabel}asetpts=PTS-STARTPTS[m]`); + } + + if (systemDelayMs > 0) { + filterParts.push( + `${systemLabel}adelay=${systemDelayMs}|${systemDelayMs},asetpts=PTS-STARTPTS[s]`, + ); + filterParts.push(`[s][m]amix=inputs=2:duration=longest:normalize=0[aout]`); + } else { + filterParts.push(`${systemLabel}[m]amix=inputs=2:duration=longest:normalize=0[aout]`); + } + + await execFileAsync( + ffmpegPath, + [ + "-y", + ...inputs, + "-filter_complex", + filterParts.join(";"), + "-map", + "0:v:0", + "-map", + "[aout]", + "-c:v", + "copy", + "-c:a", + "aac", + "-b:a", + "192k", + "-shortest", + mixedOutputPath, + ], + { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, + ); + } else { + // Single audio track + const pauseFilter = buildPausedAudioFilter("1:a", "aout", normalizedPauseSegments); + const singleDelayMs = audioDelays.get(audioInputs[0]) ?? 0; + + if (pauseFilter || singleDelayMs > 0) { + const filterParts: string[] = []; + if (pauseFilter) { + filterParts.push(pauseFilter); + } + const srcLabel = pauseFilter ? "[aout]" : "[1:a]"; + if (singleDelayMs > 0) { + filterParts.push( + `${srcLabel}adelay=${singleDelayMs}|${singleDelayMs},asetpts=PTS-STARTPTS[delayed]`, + ); + } + const outLabel = singleDelayMs > 0 ? "[delayed]" : "[aout]"; + + await execFileAsync( + ffmpegPath, + [ + "-y", + ...inputs, + "-filter_complex", + filterParts.join(";"), + "-map", + "0:v:0", + "-map", + outLabel, + "-c:v", + "copy", + "-c:a", + "aac", + "-b:a", + "192k", + "-shortest", + mixedOutputPath, + ], + { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, + ); + } else { + await execFileAsync( + ffmpegPath, + [ + "-y", + ...inputs, + "-map", + "0:v:0", + "-map", + "1:a:0", + "-c:v", + "copy", + "-c:a", + "aac", + "-b:a", + "192k", + "-shortest", + mixedOutputPath, + ], + { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, + ); + } + } + + await moveFileWithOverwrite(mixedOutputPath, videoPath); + + // Clean up audio files + for (const audioPath of [systemAudioPath, micAudioPath]) { + if (audioPath) { + await fs.rm(audioPath, { force: true }).catch(() => {}); + } + } } function normalizePauseSegments(pauseSegments: PauseSegment[] | undefined): PauseSegment[] { - if (!Array.isArray(pauseSegments) || pauseSegments.length === 0) { - return [] - } + if (!Array.isArray(pauseSegments) || pauseSegments.length === 0) { + return []; + } - const normalized = pauseSegments - .map((segment) => { - const startMs = Number(segment?.startMs) - const endMs = Number(segment?.endMs) + const normalized = pauseSegments + .map((segment) => { + const startMs = Number(segment?.startMs); + const endMs = Number(segment?.endMs); - if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { - return null - } + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { + return null; + } - const clampedStart = Math.max(0, Math.round(startMs)) - const clampedEnd = Math.max(0, Math.round(endMs)) - if (clampedEnd <= clampedStart) { - return null - } + const clampedStart = Math.max(0, Math.round(startMs)); + const clampedEnd = Math.max(0, Math.round(endMs)); + if (clampedEnd <= clampedStart) { + return null; + } - return { startMs: clampedStart, endMs: clampedEnd } - }) - .filter((segment): segment is PauseSegment => !!segment) - .sort((left, right) => left.startMs - right.startMs) + return { startMs: clampedStart, endMs: clampedEnd }; + }) + .filter((segment): segment is PauseSegment => !!segment) + .sort((left, right) => left.startMs - right.startMs); - if (normalized.length <= 1) { - return normalized - } + if (normalized.length <= 1) { + return normalized; + } - const merged: PauseSegment[] = [{ ...normalized[0] }] + const merged: PauseSegment[] = [{ ...normalized[0] }]; - for (const segment of normalized.slice(1)) { - const previous = merged[merged.length - 1] - if (segment.startMs <= previous.endMs) { - previous.endMs = Math.max(previous.endMs, segment.endMs) - } else { - merged.push({ ...segment }) - } - } + for (const segment of normalized.slice(1)) { + const previous = merged[merged.length - 1]; + if (segment.startMs <= previous.endMs) { + previous.endMs = Math.max(previous.endMs, segment.endMs); + } else { + merged.push({ ...segment }); + } + } - return merged + return merged; } function formatFfmpegSeconds(milliseconds: number) { - return (milliseconds / 1000).toFixed(3) + return (milliseconds / 1000).toFixed(3); } -function buildPausedAudioFilter(inputLabel: string, outputLabel: string, pauseSegments: PauseSegment[]) { - if (pauseSegments.length === 0) { - return null - } +function buildPausedAudioFilter( + inputLabel: string, + outputLabel: string, + pauseSegments: PauseSegment[], +) { + if (pauseSegments.length === 0) { + return null; + } - const activeSegments: Array<{ startMs: number, endMs?: number }> = [] - let cursorMs = 0 + const activeSegments: Array<{ startMs: number; endMs?: number }> = []; + let cursorMs = 0; - for (const pauseSegment of pauseSegments) { - if (pauseSegment.startMs > cursorMs) { - activeSegments.push({ startMs: cursorMs, endMs: pauseSegment.startMs }) - } - cursorMs = Math.max(cursorMs, pauseSegment.endMs) - } + for (const pauseSegment of pauseSegments) { + if (pauseSegment.startMs > cursorMs) { + activeSegments.push({ startMs: cursorMs, endMs: pauseSegment.startMs }); + } + cursorMs = Math.max(cursorMs, pauseSegment.endMs); + } - activeSegments.push({ startMs: cursorMs }) + activeSegments.push({ startMs: cursorMs }); - const filterParts: string[] = [] - const segmentLabels: string[] = [] + const filterParts: string[] = []; + const segmentLabels: string[] = []; - activeSegments.forEach((segment, index) => { - if (typeof segment.endMs === 'number' && segment.endMs <= segment.startMs) { - return - } + activeSegments.forEach((segment, index) => { + if (typeof segment.endMs === "number" && segment.endMs <= segment.startMs) { + return; + } - const segmentLabel = `${outputLabel}_part${index}` - const trimArgs = typeof segment.endMs === 'number' - ? `start=${formatFfmpegSeconds(segment.startMs)}:end=${formatFfmpegSeconds(segment.endMs)}` - : `start=${formatFfmpegSeconds(segment.startMs)}` + const segmentLabel = `${outputLabel}_part${index}`; + const trimArgs = + typeof segment.endMs === "number" + ? `start=${formatFfmpegSeconds(segment.startMs)}:end=${formatFfmpegSeconds(segment.endMs)}` + : `start=${formatFfmpegSeconds(segment.startMs)}`; - filterParts.push(`[${inputLabel}]atrim=${trimArgs},asetpts=PTS-STARTPTS[${segmentLabel}]`) - segmentLabels.push(`[${segmentLabel}]`) - }) + filterParts.push(`[${inputLabel}]atrim=${trimArgs},asetpts=PTS-STARTPTS[${segmentLabel}]`); + segmentLabels.push(`[${segmentLabel}]`); + }); - if (segmentLabels.length === 0) { - return null - } + if (segmentLabels.length === 0) { + return null; + } - if (segmentLabels.length === 1) { - filterParts.push(`${segmentLabels[0]}anull[${outputLabel}]`) - } else { - filterParts.push(`${segmentLabels.join('')}concat=n=${segmentLabels.length}:v=0:a=1[${outputLabel}]`) - } + if (segmentLabels.length === 1) { + filterParts.push(`${segmentLabels[0]}anull[${outputLabel}]`); + } else { + filterParts.push( + `${segmentLabels.join("")}concat=n=${segmentLabels.length}:v=0:a=1[${outputLabel}]`, + ); + } - return filterParts.join(';') + return filterParts.join(";"); } function waitForNativeCaptureStart(process: ChildProcessWithoutNullStreams) { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup() - reject(new Error('Timed out waiting for ScreenCaptureKit recorder to start')) - }, 12000) - - // Only check for the start pattern — the start handler already - // appends stdout/stderr to nativeCaptureOutputBuffer - const onStdout = (chunk: Buffer) => { - const text = chunk.toString() - if (text.includes('Recording started')) { - cleanup() - resolve() - } - } - - const onError = (error: Error) => { - cleanup() - reject(error) - } - - const onExit = (code: number | null) => { - cleanup() - reject(new Error(nativeCaptureOutputBuffer.trim() || `Native capture helper exited before recording started (code ${code ?? 'unknown'})`)) - } - - const cleanup = () => { - clearTimeout(timer) - process.stdout.off('data', onStdout) - process.off('error', onError) - process.off('exit', onExit) - } - - process.stdout.on('data', onStdout) - process.once('error', onError) - process.once('exit', onExit) - }) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for ScreenCaptureKit recorder to start")); + }, 12000); + + // Only check for the start pattern — the start handler already + // appends stdout/stderr to nativeCaptureOutputBuffer + const onStdout = (chunk: Buffer) => { + const text = chunk.toString(); + if (text.includes("Recording started")) { + cleanup(); + resolve(); + } + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const onExit = (code: number | null) => { + cleanup(); + reject( + new Error( + nativeCaptureOutputBuffer.trim() || + `Native capture helper exited before recording started (code ${code ?? "unknown"})`, + ), + ); + }; + + const cleanup = () => { + clearTimeout(timer); + process.stdout.off("data", onStdout); + process.off("error", onError); + process.off("exit", onExit); + }; + + process.stdout.on("data", onStdout); + process.once("error", onError); + process.once("exit", onExit); + }); } function waitForNativeCaptureStop(process: ChildProcessWithoutNullStreams) { - return new Promise((resolve, reject) => { - const onClose = (code: number | null) => { - cleanup() - const match = nativeCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/) - if (match?.[1]) { - resolve(match[1].trim()) - return - } - // Fallback: if exit code was 0 and we know the target path, try to use it - if (code === 0 && nativeCaptureTargetPath) { - resolve(nativeCaptureTargetPath) - return - } - reject(new Error(nativeCaptureOutputBuffer.trim() || `Native capture helper exited with code ${code ?? 'unknown'}`)) - } - - const onError = (error: Error) => { - cleanup() - reject(error) - } - - const cleanup = () => { - process.off('close', onClose) - process.off('error', onError) - } - - process.once('close', onClose) - process.once('error', onError) - }) + return new Promise((resolve, reject) => { + const onClose = (code: number | null) => { + cleanup(); + const match = nativeCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/); + if (match?.[1]) { + resolve(match[1].trim()); + return; + } + // Fallback: if exit code was 0 and we know the target path, try to use it + if (code === 0 && nativeCaptureTargetPath) { + resolve(nativeCaptureTargetPath); + return; + } + reject( + new Error( + nativeCaptureOutputBuffer.trim() || + `Native capture helper exited with code ${code ?? "unknown"}`, + ), + ); + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const cleanup = () => { + process.off("close", onClose); + process.off("error", onError); + }; + + process.once("close", onClose); + process.once("error", onError); + }); } async function muxNativeMacRecordingWithAudio( - videoPath: string, - systemAudioPath?: string | null, - microphonePath?: string | null, + videoPath: string, + systemAudioPath?: string | null, + microphonePath?: string | null, ) { - const ffmpegPath = getFfmpegBinaryPath() - const mixedOutputPath = `${videoPath}.mixed.mp4` - - const inputs = ['-i', videoPath] - const availableAudioInputs: string[] = [] - const audioFilePaths: string[] = [] - - for (const [label, audioPath] of [['system', systemAudioPath], ['microphone', microphonePath]] as const) { - if (!audioPath) continue - try { - const stat = await fs.stat(audioPath) - if (stat.size <= 0) { - console.warn(`[mux] Skipping ${label} audio: file is empty (${audioPath})`) - await fs.rm(audioPath, { force: true }).catch(() => {}) - continue - } - inputs.push('-i', audioPath) - availableAudioInputs.push(label) - audioFilePaths.push(audioPath) - } catch { - console.warn(`[mux] Skipping ${label} audio: file not accessible (${audioPath})`) - } - } - - if (availableAudioInputs.length === 0) { - console.warn('[mux] No valid audio files to mux') - return - } - - // Probe durations — if audio is shorter than video it means the audio device - // started late (e.g. iPhone mic over Continuity Camera). Add leading silence. - const videoDuration = await probeMediaDurationSeconds(videoPath) - const audioDelays: Map = new Map() - - if (videoDuration > 0) { - for (let i = 0; i < audioFilePaths.length; i++) { - const audioDuration = await probeMediaDurationSeconds(audioFilePaths[i]) - const delayMs = audioDuration > 0 ? Math.max(0, Math.round((videoDuration - audioDuration) * 1000)) : 0 - audioDelays.set(availableAudioInputs[i], delayMs) - if (delayMs > 0) { - console.log(`[mux] ${availableAudioInputs[i]} audio is ${(delayMs / 1000).toFixed(2)}s shorter than video — adding ${delayMs}ms delay`) - } - } - } - - const systemDelayMs = audioDelays.get('system') ?? 0 - const micDelayMs = audioDelays.get('microphone') ?? 0 - const needsFilter = systemDelayMs > 0 || micDelayMs > 0 - - let args: string[] - if (availableAudioInputs.length === 2) { - if (needsFilter) { - const filterParts: string[] = [] - if (systemDelayMs > 0) { - filterParts.push(`[1:a]adelay=${systemDelayMs}|${systemDelayMs},asetpts=PTS-STARTPTS[s]`) - } - if (micDelayMs > 0) { - filterParts.push(`[2:a]adelay=${micDelayMs}|${micDelayMs},asetpts=PTS-STARTPTS[m]`) - } - const sLabel = systemDelayMs > 0 ? '[s]' : '[1:a]' - const mLabel = micDelayMs > 0 ? '[m]' : '[2:a]' - filterParts.push(`${sLabel}${mLabel}amix=inputs=2:duration=longest:normalize=0[aout]`) - args = [ - '-y', - ...inputs, - '-filter_complex', filterParts.join(';'), - '-map', '0:v:0', - '-map', '[aout]', - '-c:v', 'copy', - '-c:a', 'aac', - '-b:a', '192k', - '-shortest', - mixedOutputPath, - ] - } else { - args = [ - '-y', - ...inputs, - '-filter_complex', '[1:a][2:a]amix=inputs=2:duration=longest:normalize=0[aout]', - '-map', '0:v:0', - '-map', '[aout]', - '-c:v', 'copy', - '-c:a', 'aac', - '-b:a', '192k', - '-shortest', - mixedOutputPath, - ] - } - } else { - if (needsFilter) { - const delayMs = systemDelayMs || micDelayMs - args = [ - '-y', - ...inputs, - '-filter_complex', `[1:a]adelay=${delayMs}|${delayMs},asetpts=PTS-STARTPTS[aout]`, - '-map', '0:v:0', - '-map', '[aout]', - '-c:v', 'copy', - '-c:a', 'aac', - '-b:a', '192k', - '-shortest', - mixedOutputPath, - ] - } else { - args = [ - '-y', - ...inputs, - '-map', '0:v:0', - '-map', '1:a:0', - '-c:v', 'copy', - '-c:a', 'aac', - '-b:a', '192k', - '-shortest', - mixedOutputPath, - ] - } - } - - console.log('[mux] Running ffmpeg:', ffmpegPath, args.join(' ')) - - try { - await execFileAsync( - ffmpegPath, - args, - { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, - ) - } catch (error) { - const execError = error as NodeJS.ErrnoException & { stderr?: string } - console.error('[mux] ffmpeg failed:', execError.stderr || execError.message) - throw error - } - - await moveFileWithOverwrite(mixedOutputPath, videoPath) - console.log('[mux] Successfully muxed audio into video:', videoPath) - - for (const audioPath of [systemAudioPath, microphonePath]) { - if (audioPath) { - await fs.rm(audioPath, { force: true }).catch(() => {}) - } - } + const ffmpegPath = getFfmpegBinaryPath(); + const mixedOutputPath = `${videoPath}.mixed.mp4`; + + const inputs = ["-i", videoPath]; + const availableAudioInputs: string[] = []; + const audioFilePaths: string[] = []; + + for (const [label, audioPath] of [ + ["system", systemAudioPath], + ["microphone", microphonePath], + ] as const) { + if (!audioPath) continue; + try { + const stat = await fs.stat(audioPath); + if (stat.size <= 0) { + console.warn(`[mux] Skipping ${label} audio: file is empty (${audioPath})`); + await fs.rm(audioPath, { force: true }).catch(() => {}); + continue; + } + inputs.push("-i", audioPath); + availableAudioInputs.push(label); + audioFilePaths.push(audioPath); + } catch { + console.warn(`[mux] Skipping ${label} audio: file not accessible (${audioPath})`); + } + } + + if (availableAudioInputs.length === 0) { + console.warn("[mux] No valid audio files to mux"); + return; + } + + // Probe durations — if audio is shorter than video it means the audio device + // started late (e.g. iPhone mic over Continuity Camera). Add leading silence. + const videoDuration = await probeMediaDurationSeconds(videoPath); + const audioDelays: Map = new Map(); + + if (videoDuration > 0) { + for (let i = 0; i < audioFilePaths.length; i++) { + const audioDuration = await probeMediaDurationSeconds(audioFilePaths[i]); + const delayMs = + audioDuration > 0 ? Math.max(0, Math.round((videoDuration - audioDuration) * 1000)) : 0; + audioDelays.set(availableAudioInputs[i], delayMs); + if (delayMs > 0) { + console.log( + `[mux] ${availableAudioInputs[i]} audio is ${(delayMs / 1000).toFixed(2)}s shorter than video — adding ${delayMs}ms delay`, + ); + } + } + } + + const systemDelayMs = audioDelays.get("system") ?? 0; + const micDelayMs = audioDelays.get("microphone") ?? 0; + const needsFilter = systemDelayMs > 0 || micDelayMs > 0; + + let args: string[]; + if (availableAudioInputs.length === 2) { + if (needsFilter) { + const filterParts: string[] = []; + if (systemDelayMs > 0) { + filterParts.push(`[1:a]adelay=${systemDelayMs}|${systemDelayMs},asetpts=PTS-STARTPTS[s]`); + } + if (micDelayMs > 0) { + filterParts.push(`[2:a]adelay=${micDelayMs}|${micDelayMs},asetpts=PTS-STARTPTS[m]`); + } + const sLabel = systemDelayMs > 0 ? "[s]" : "[1:a]"; + const mLabel = micDelayMs > 0 ? "[m]" : "[2:a]"; + filterParts.push(`${sLabel}${mLabel}amix=inputs=2:duration=longest:normalize=0[aout]`); + args = [ + "-y", + ...inputs, + "-filter_complex", + filterParts.join(";"), + "-map", + "0:v:0", + "-map", + "[aout]", + "-c:v", + "copy", + "-c:a", + "aac", + "-b:a", + "192k", + "-shortest", + mixedOutputPath, + ]; + } else { + args = [ + "-y", + ...inputs, + "-filter_complex", + "[1:a][2:a]amix=inputs=2:duration=longest:normalize=0[aout]", + "-map", + "0:v:0", + "-map", + "[aout]", + "-c:v", + "copy", + "-c:a", + "aac", + "-b:a", + "192k", + "-shortest", + mixedOutputPath, + ]; + } + } else { + if (needsFilter) { + const delayMs = systemDelayMs || micDelayMs; + args = [ + "-y", + ...inputs, + "-filter_complex", + `[1:a]adelay=${delayMs}|${delayMs},asetpts=PTS-STARTPTS[aout]`, + "-map", + "0:v:0", + "-map", + "[aout]", + "-c:v", + "copy", + "-c:a", + "aac", + "-b:a", + "192k", + "-shortest", + mixedOutputPath, + ]; + } else { + args = [ + "-y", + ...inputs, + "-map", + "0:v:0", + "-map", + "1:a:0", + "-c:v", + "copy", + "-c:a", + "aac", + "-b:a", + "192k", + "-shortest", + mixedOutputPath, + ]; + } + } + + console.log("[mux] Running ffmpeg:", ffmpegPath, args.join(" ")); + + try { + await execFileAsync(ffmpegPath, args, { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }); + } catch (error) { + const execError = error as NodeJS.ErrnoException & { stderr?: string }; + console.error("[mux] ffmpeg failed:", execError.stderr || execError.message); + throw error; + } + + await moveFileWithOverwrite(mixedOutputPath, videoPath); + console.log("[mux] Successfully muxed audio into video:", videoPath); + + for (const audioPath of [systemAudioPath, microphonePath]) { + if (audioPath) { + await fs.rm(audioPath, { force: true }).catch(() => {}); + } + } } function emitRecordingInterrupted(reason: string, message: string) { - BrowserWindow.getAllWindows().forEach((window) => { - if (!window.isDestroyed()) { - window.webContents.send('recording-interrupted', { reason, message }) - } - }) + BrowserWindow.getAllWindows().forEach((window) => { + if (!window.isDestroyed()) { + window.webContents.send("recording-interrupted", { reason, message }); + } + }); } function emitCursorStateChanged(cursorType: CursorVisualType) { - BrowserWindow.getAllWindows().forEach((window) => { - if (!window.isDestroyed()) { - window.webContents.send('cursor-state-changed', { cursorType }) - } - }) + BrowserWindow.getAllWindows().forEach((window) => { + if (!window.isDestroyed()) { + window.webContents.send("cursor-state-changed", { cursorType }); + } + }); } function sampleCursorStateChange(cursorType: CursorVisualType) { - if (!isCursorCaptureActive) { - return - } + if (!isCursorCaptureActive) { + return; + } - const point = getNormalizedCursorPoint() - if (!point) { - return - } + const point = getNormalizedCursorPoint(); + if (!point) { + return; + } - pushCursorSample(point.cx, point.cy, Date.now() - cursorCaptureStartTimeMs, 'move', cursorType) + pushCursorSample(point.cx, point.cy, Date.now() - cursorCaptureStartTimeMs, "move", cursorType); } function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStreams) { - process.once('close', () => { - const wasActive = nativeScreenRecordingActive - nativeCaptureProcess = null - - if (!wasActive || nativeCaptureStopRequested) { - return - } - - nativeScreenRecordingActive = false - nativeCaptureTargetPath = null - nativeCaptureStopRequested = false - nativeCaptureSystemAudioPath = null - nativeCaptureMicrophonePath = null - - const sourceName = selectedSource?.name ?? 'Screen' - BrowserWindow.getAllWindows().forEach((window) => { - if (!window.isDestroyed()) { - window.webContents.send('recording-state-changed', { - recording: false, - sourceName, - }) - } - }) - - const reason = nativeCaptureOutputBuffer.includes('WINDOW_UNAVAILABLE') - ? 'window-unavailable' - : 'capture-stopped' - const message = reason === 'window-unavailable' - ? 'The selected window is no longer capturable. Please reselect a window.' - : 'Recording stopped unexpectedly.' - - emitRecordingInterrupted(reason, message) - }) + process.once("close", () => { + const wasActive = nativeScreenRecordingActive; + nativeCaptureProcess = null; + + if (!wasActive || nativeCaptureStopRequested) { + return; + } + + nativeScreenRecordingActive = false; + nativeCaptureTargetPath = null; + nativeCaptureStopRequested = false; + nativeCaptureSystemAudioPath = null; + nativeCaptureMicrophonePath = null; + + const sourceName = selectedSource?.name ?? "Screen"; + BrowserWindow.getAllWindows().forEach((window) => { + if (!window.isDestroyed()) { + window.webContents.send("recording-state-changed", { + recording: false, + sourceName, + }); + } + }); + + const reason = nativeCaptureOutputBuffer.includes("WINDOW_UNAVAILABLE") + ? "window-unavailable" + : "capture-stopped"; + const message = + reason === "window-unavailable" + ? "The selected window is no longer capturable. Please reselect a window." + : "Recording stopped unexpectedly."; + + emitRecordingInterrupted(reason, message); + }); } async function ensureNativeCursorMonitorBinary() { - return ensureSwiftHelperBinary( - getNativeCursorMonitorSourcePath(), - getNativeCursorMonitorBinaryPath(), - 'native cursor monitor helper', - 'openscreen-native-cursor-monitor' - ) + return ensureSwiftHelperBinary( + getNativeCursorMonitorSourcePath(), + getNativeCursorMonitorBinaryPath(), + "native cursor monitor helper", + "openscreen-native-cursor-monitor", + ); } function handleCursorMonitorStdout(chunk: Buffer) { - nativeCursorMonitorOutputBuffer += chunk.toString() - const lines = nativeCursorMonitorOutputBuffer.split(/\r?\n/) - nativeCursorMonitorOutputBuffer = lines.pop() ?? '' - - for (const line of lines) { - const match = line.match(/^STATE:(.+)$/) - if (!match) continue - const next = match[1].trim() as CursorVisualType - if ( - next === 'arrow' - || next === 'text' - || next === 'pointer' - || next === 'crosshair' - || next === 'open-hand' - || next === 'closed-hand' - || next === 'resize-ew' - || next === 'resize-ns' - || next === 'not-allowed' - ) { - if (currentCursorVisualType !== next) { - currentCursorVisualType = next - sampleCursorStateChange(next) - emitCursorStateChanged(next) - } - } - } + nativeCursorMonitorOutputBuffer += chunk.toString(); + const lines = nativeCursorMonitorOutputBuffer.split(/\r?\n/); + nativeCursorMonitorOutputBuffer = lines.pop() ?? ""; + + for (const line of lines) { + const match = line.match(/^STATE:(.+)$/); + if (!match) continue; + const next = match[1].trim() as CursorVisualType; + if ( + next === "arrow" || + next === "text" || + next === "pointer" || + next === "crosshair" || + next === "open-hand" || + next === "closed-hand" || + next === "resize-ew" || + next === "resize-ns" || + next === "not-allowed" + ) { + if (currentCursorVisualType !== next) { + currentCursorVisualType = next; + sampleCursorStateChange(next); + emitCursorStateChanged(next); + } + } + } } async function startNativeCursorMonitor() { - stopNativeCursorMonitor() - - if (process.platform !== 'darwin' && process.platform !== 'win32') { - currentCursorVisualType = 'arrow' - return - } - - try { - let helperPath: string - if (process.platform === 'win32') { - helperPath = getCursorMonitorExePath() - try { - await fs.access(helperPath, fsConstants.X_OK) - } catch { - console.warn('Windows cursor monitor helper missing or not executable:', helperPath) - currentCursorVisualType = 'arrow' - return - } - } else { - helperPath = await ensureNativeCursorMonitorBinary() - } - - nativeCursorMonitorOutputBuffer = '' - currentCursorVisualType = 'arrow' - nativeCursorMonitorProcess = spawn(helperPath, [], { - stdio: ['pipe', 'pipe', 'pipe'], - }) - - nativeCursorMonitorProcess.once('error', (error) => { - console.warn('Native cursor monitor process error:', error) - nativeCursorMonitorProcess = null - nativeCursorMonitorOutputBuffer = '' - currentCursorVisualType = 'arrow' - }) - - nativeCursorMonitorProcess.stdout.on('data', handleCursorMonitorStdout) - - nativeCursorMonitorProcess.once('close', () => { - nativeCursorMonitorProcess = null - nativeCursorMonitorOutputBuffer = '' - currentCursorVisualType = 'arrow' - }) - } catch (error) { - console.warn('Failed to start native cursor monitor:', error) - nativeCursorMonitorProcess = null - nativeCursorMonitorOutputBuffer = '' - currentCursorVisualType = 'arrow' - } + stopNativeCursorMonitor(); + + if (process.platform !== "darwin" && process.platform !== "win32") { + currentCursorVisualType = "arrow"; + return; + } + + try { + let helperPath: string; + if (process.platform === "win32") { + helperPath = getCursorMonitorExePath(); + try { + await fs.access(helperPath, fsConstants.X_OK); + } catch { + console.warn("Windows cursor monitor helper missing or not executable:", helperPath); + currentCursorVisualType = "arrow"; + return; + } + } else { + helperPath = await ensureNativeCursorMonitorBinary(); + } + + nativeCursorMonitorOutputBuffer = ""; + currentCursorVisualType = "arrow"; + nativeCursorMonitorProcess = spawn(helperPath, [], { + stdio: ["pipe", "pipe", "pipe"], + }); + + nativeCursorMonitorProcess.once("error", (error) => { + console.warn("Native cursor monitor process error:", error); + nativeCursorMonitorProcess = null; + nativeCursorMonitorOutputBuffer = ""; + currentCursorVisualType = "arrow"; + }); + + nativeCursorMonitorProcess.stdout.on("data", handleCursorMonitorStdout); + + nativeCursorMonitorProcess.once("close", () => { + nativeCursorMonitorProcess = null; + nativeCursorMonitorOutputBuffer = ""; + currentCursorVisualType = "arrow"; + }); + } catch (error) { + console.warn("Failed to start native cursor monitor:", error); + nativeCursorMonitorProcess = null; + nativeCursorMonitorOutputBuffer = ""; + currentCursorVisualType = "arrow"; + } } function stopNativeCursorMonitor() { - currentCursorVisualType = 'arrow' + currentCursorVisualType = "arrow"; - if (!nativeCursorMonitorProcess) { - return - } + if (!nativeCursorMonitorProcess) { + return; + } - try { - nativeCursorMonitorProcess.stdin.write('stop\n') - } catch { - // ignore stop signal issues - } - try { - nativeCursorMonitorProcess.kill() - } catch { - // ignore kill issues - } + try { + nativeCursorMonitorProcess.stdin.write("stop\n"); + } catch { + // ignore stop signal issues + } + try { + nativeCursorMonitorProcess.kill(); + } catch { + // ignore kill issues + } - nativeCursorMonitorProcess = null - nativeCursorMonitorOutputBuffer = '' + nativeCursorMonitorProcess = null; + nativeCursorMonitorOutputBuffer = ""; } async function moveFileWithOverwrite(sourcePath: string, destinationPath: string) { - await fs.mkdir(path.dirname(destinationPath), { recursive: true }) - await fs.rm(destinationPath, { force: true }) + await fs.mkdir(path.dirname(destinationPath), { recursive: true }); + await fs.rm(destinationPath, { force: true }); - try { - await fs.rename(sourcePath, destinationPath) - } catch (error) { - const nodeError = error as NodeJS.ErrnoException - if (nodeError.code !== 'EXDEV') { - throw error - } + try { + await fs.rename(sourcePath, destinationPath); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== "EXDEV") { + throw error; + } - await fs.copyFile(sourcePath, destinationPath) - await fs.unlink(sourcePath) - } + await fs.copyFile(sourcePath, destinationPath); + await fs.unlink(sourcePath); + } } function isTrustedProjectPath(filePath?: string | null) { - if (!filePath || !currentProjectPath) { - return false - } - return normalizePath(filePath) === normalizePath(currentProjectPath) + if (!filePath || !currentProjectPath) { + return false; + } + return normalizePath(filePath) === normalizePath(currentProjectPath); } -const CURSOR_TELEMETRY_VERSION = 2 -const CURSOR_SAMPLE_INTERVAL_MS = 33 -const MAX_CURSOR_SAMPLES = 60 * 60 * 30 // 1 hour @ 30Hz +const CURSOR_TELEMETRY_VERSION = 2; +const CURSOR_SAMPLE_INTERVAL_MS = 33; +const MAX_CURSOR_SAMPLES = 60 * 60 * 30; // 1 hour @ 30Hz -type CursorInteractionType = 'move' | 'click' | 'double-click' | 'right-click' | 'middle-click' | 'mouseup' +type CursorInteractionType = + | "move" + | "click" + | "double-click" + | "right-click" + | "middle-click" + | "mouseup"; interface CursorTelemetryPoint { - timeMs: number - cx: number - cy: number - interactionType?: CursorInteractionType - cursorType?: CursorVisualType -} - -let cursorCaptureInterval: NodeJS.Timeout | null = null -let cursorCaptureStartTimeMs = 0 -let activeCursorSamples: CursorTelemetryPoint[] = [] -let pendingCursorSamples: CursorTelemetryPoint[] = [] -let isCursorCaptureActive = false -let interactionCaptureCleanup: (() => void) | null = null -let hasLoggedInteractionHookFailure = false -let lastLeftClick: { timeMs: number; cx: number; cy: number } | null = null -let linuxCursorScreenPoint: { x: number; y: number; updatedAt: number } | null = null -let selectedWindowBounds: WindowBounds | null = null -let windowBoundsCaptureInterval: NodeJS.Timeout | null = null + timeMs: number; + cx: number; + cy: number; + interactionType?: CursorInteractionType; + cursorType?: CursorVisualType; +} + +let cursorCaptureInterval: NodeJS.Timeout | null = null; +let cursorCaptureStartTimeMs = 0; +let activeCursorSamples: CursorTelemetryPoint[] = []; +let pendingCursorSamples: CursorTelemetryPoint[] = []; +let isCursorCaptureActive = false; +let interactionCaptureCleanup: (() => void) | null = null; +let hasLoggedInteractionHookFailure = false; +let lastLeftClick: { timeMs: number; cx: number; cy: number } | null = null; +let linuxCursorScreenPoint: { x: number; y: number; updatedAt: number } | null = null; +let selectedWindowBounds: WindowBounds | null = null; +let windowBoundsCaptureInterval: NodeJS.Timeout | null = null; function normalizeHookMouseButton(rawButton: unknown): 1 | 2 | 3 { - if (typeof rawButton !== 'number' || !Number.isFinite(rawButton)) { - return 1 - } + if (typeof rawButton !== "number" || !Number.isFinite(rawButton)) { + return 1; + } - // uiohook/libuiohook button codes are typically 1/2/3. Some wrappers may - // expose alternate constants depending on platform/runtime. - if (rawButton === 2 || rawButton === 39) { - return 2 - } + // uiohook/libuiohook button codes are typically 1/2/3. Some wrappers may + // expose alternate constants depending on platform/runtime. + if (rawButton === 2 || rawButton === 39) { + return 2; + } - if (rawButton === 3 || rawButton === 38) { - return 3 - } + if (rawButton === 3 || rawButton === 38) { + return 3; + } - return 1 + return 1; } function getHookMouseButton(event: any): 1 | 2 | 3 { - return normalizeHookMouseButton( - event?.button - ?? event?.mouseButton - ?? event?.data?.button - ?? event?.data?.mouseButton - ) + return normalizeHookMouseButton( + event?.button ?? event?.mouseButton ?? event?.data?.button ?? event?.data?.mouseButton, + ); } function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)) + return Math.min(max, Math.max(min, value)); } function stopCursorCapture() { - if (cursorCaptureInterval) { - clearInterval(cursorCaptureInterval) - cursorCaptureInterval = null - } + if (cursorCaptureInterval) { + clearInterval(cursorCaptureInterval); + cursorCaptureInterval = null; + } } function stopInteractionCapture() { - if (interactionCaptureCleanup) { - interactionCaptureCleanup() - interactionCaptureCleanup = null - } + if (interactionCaptureCleanup) { + interactionCaptureCleanup(); + interactionCaptureCleanup = null; + } } function stopWindowBoundsCapture() { - if (windowBoundsCaptureInterval) { - clearInterval(windowBoundsCaptureInterval) - windowBoundsCaptureInterval = null - } - selectedWindowBounds = null -} - -function getWindowBoundsFromNativeSource(source?: NativeMacWindowSource | null): WindowBounds | null { - if (!source) { - return null - } - - const { x, y, width, height } = source - if ( - typeof x !== 'number' || !Number.isFinite(x) - || typeof y !== 'number' || !Number.isFinite(y) - || typeof width !== 'number' || !Number.isFinite(width) - || typeof height !== 'number' || !Number.isFinite(height) - ) { - return null - } - - if (width <= 0 || height <= 0) { - return null - } - - return { x, y, width, height } + if (windowBoundsCaptureInterval) { + clearInterval(windowBoundsCaptureInterval); + windowBoundsCaptureInterval = null; + } + selectedWindowBounds = null; +} + +function getWindowBoundsFromNativeSource( + source?: NativeMacWindowSource | null, +): WindowBounds | null { + if (!source) { + return null; + } + + const { x, y, width, height } = source; + if ( + typeof x !== "number" || + !Number.isFinite(x) || + typeof y !== "number" || + !Number.isFinite(y) || + typeof width !== "number" || + !Number.isFinite(width) || + typeof height !== "number" || + !Number.isFinite(height) + ) { + return null; + } + + if (width <= 0 || height <= 0) { + return null; + } + + return { x, y, width, height }; } async function resolveMacWindowBounds(source: SelectedSource): Promise { - const windowId = parseWindowId(source.id) - if (!windowId) { - return null - } + const windowId = parseWindowId(source.id); + if (!windowId) { + return null; + } - try { - const nativeSources = await getNativeMacWindowSources({ maxAgeMs: 250 }) - const matchedSource = nativeSources.find((entry) => parseWindowId(entry.id) === windowId) - return getWindowBoundsFromNativeSource(matchedSource) - } catch { - return null - } + try { + const nativeSources = await getNativeMacWindowSources({ maxAgeMs: 250 }); + const matchedSource = nativeSources.find((entry) => parseWindowId(entry.id) === windowId); + return getWindowBoundsFromNativeSource(matchedSource); + } catch { + return null; + } } async function refreshSelectedWindowBounds() { - if (!selectedSource?.id?.startsWith('window:')) { - selectedWindowBounds = null - return - } + if (!selectedSource?.id?.startsWith("window:")) { + selectedWindowBounds = null; + return; + } - let bounds: WindowBounds | null = null + let bounds: WindowBounds | null = null; - if (process.platform === 'darwin') { - bounds = await resolveMacWindowBounds(selectedSource) - } else if (process.platform === 'win32') { - bounds = await resolveWindowsWindowBounds(selectedSource) - } else if (process.platform === 'linux') { - bounds = await resolveLinuxWindowBounds(selectedSource) - } + if (process.platform === "darwin") { + bounds = await resolveMacWindowBounds(selectedSource); + } else if (process.platform === "win32") { + bounds = await resolveWindowsWindowBounds(selectedSource); + } else if (process.platform === "linux") { + bounds = await resolveLinuxWindowBounds(selectedSource); + } - selectedWindowBounds = bounds + selectedWindowBounds = bounds; } function startWindowBoundsCapture() { - stopWindowBoundsCapture() + stopWindowBoundsCapture(); - if (!['darwin', 'win32', 'linux'].includes(process.platform) || !selectedSource?.id?.startsWith('window:')) { - return - } + if ( + !["darwin", "win32", "linux"].includes(process.platform) || + !selectedSource?.id?.startsWith("window:") + ) { + return; + } - void refreshSelectedWindowBounds() - windowBoundsCaptureInterval = setInterval(() => { - void refreshSelectedWindowBounds() - }, 250) + void refreshSelectedWindowBounds(); + windowBoundsCaptureInterval = setInterval(() => { + void refreshSelectedWindowBounds(); + }, 250); } function getNormalizedCursorPoint() { - const fallbackCursor = getScreen().getCursorScreenPoint() - const linuxCursorCache = process.platform === 'linux' ? linuxCursorScreenPoint : null - const isLinuxCacheFresh = !!linuxCursorCache - && Date.now() - linuxCursorCache.updatedAt <= 1000 - - const cursor = isLinuxCacheFresh - ? { x: linuxCursorCache.x, y: linuxCursorCache.y } - : fallbackCursor - - const windowBounds = selectedSource?.id?.startsWith('window:') ? selectedWindowBounds : null - if (windowBounds) { - const width = Math.max(1, windowBounds.width) - const height = Math.max(1, windowBounds.height) - - return { - cx: clamp((cursor.x - windowBounds.x) / width, 0, 1), - cy: clamp((cursor.y - windowBounds.y) / height, 0, 1), - } - } - - const sourceDisplayId = Number(selectedSource?.display_id) - const sourceDisplay = Number.isFinite(sourceDisplayId) - ? getScreen().getAllDisplays().find((display) => display.id === sourceDisplayId) ?? null - : null - const display = sourceDisplay ?? getScreen().getDisplayNearestPoint(cursor) - const bounds = display.bounds - const width = Math.max(1, bounds.width) - const height = Math.max(1, bounds.height) - - const cx = clamp((cursor.x - bounds.x) / width, 0, 1) - const cy = clamp((cursor.y - bounds.y) / height, 0, 1) - return { cx, cy } + const fallbackCursor = getScreen().getCursorScreenPoint(); + const linuxCursorCache = process.platform === "linux" ? linuxCursorScreenPoint : null; + const isLinuxCacheFresh = !!linuxCursorCache && Date.now() - linuxCursorCache.updatedAt <= 1000; + + const cursor = isLinuxCacheFresh + ? { x: linuxCursorCache.x, y: linuxCursorCache.y } + : fallbackCursor; + + const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null; + if (windowBounds) { + const width = Math.max(1, windowBounds.width); + const height = Math.max(1, windowBounds.height); + + return { + cx: clamp((cursor.x - windowBounds.x) / width, 0, 1), + cy: clamp((cursor.y - windowBounds.y) / height, 0, 1), + }; + } + + const sourceDisplayId = Number(selectedSource?.display_id); + const sourceDisplay = Number.isFinite(sourceDisplayId) + ? (getScreen() + .getAllDisplays() + .find((display) => display.id === sourceDisplayId) ?? null) + : null; + const display = sourceDisplay ?? getScreen().getDisplayNearestPoint(cursor); + const bounds = display.bounds; + const width = Math.max(1, bounds.width); + const height = Math.max(1, bounds.height); + + const cx = clamp((cursor.x - bounds.x) / width, 0, 1); + const cy = clamp((cursor.y - bounds.y) / height, 0, 1); + return { cx, cy }; } function getHookCursorScreenPoint(event: any): { 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 + const rawX = event?.x ?? event?.data?.x ?? event?.screenX ?? event?.data?.screenX; + const rawY = event?.y ?? event?.data?.y ?? event?.screenY ?? event?.data?.screenY; - if (typeof rawX !== 'number' || !Number.isFinite(rawX) || typeof rawY !== 'number' || !Number.isFinite(rawY)) { - return null - } + if ( + typeof rawX !== "number" || + !Number.isFinite(rawX) || + typeof rawY !== "number" || + !Number.isFinite(rawY) + ) { + return null; + } - return { x: rawX, y: rawY } + return { x: rawX, y: rawY }; } function pushCursorSample( - cx: number, - cy: number, - timeMs: number, - interactionType: CursorInteractionType = 'move', - cursorType?: CursorVisualType, + cx: number, + cy: number, + timeMs: number, + interactionType: CursorInteractionType = "move", + cursorType?: CursorVisualType, ) { - activeCursorSamples.push({ - timeMs: Math.max(0, timeMs), - cx, - cy, - interactionType, - cursorType: cursorType ?? currentCursorVisualType, - }) + activeCursorSamples.push({ + timeMs: Math.max(0, timeMs), + cx, + cy, + interactionType, + cursorType: cursorType ?? currentCursorVisualType, + }); - if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) { - activeCursorSamples.shift() - } + if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) { + activeCursorSamples.shift(); + } } function sampleCursorPoint() { - const point = getNormalizedCursorPoint() - if (!point) { - return - } + const point = getNormalizedCursorPoint(); + if (!point) { + return; + } - pushCursorSample(point.cx, point.cy, Date.now() - cursorCaptureStartTimeMs, 'move') + pushCursorSample(point.cx, point.cy, Date.now() - cursorCaptureStartTimeMs, "move"); } async function persistPendingCursorTelemetry(videoPath: string) { - const telemetryPath = getTelemetryPathForVideo(videoPath) - if (pendingCursorSamples.length > 0) { - await fs.writeFile( - telemetryPath, - JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2), - 'utf-8' - ) - } - pendingCursorSamples = [] + const telemetryPath = getTelemetryPathForVideo(videoPath); + if (pendingCursorSamples.length > 0) { + await fs.writeFile( + telemetryPath, + JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2), + "utf-8", + ); + } + pendingCursorSamples = []; } function snapshotCursorTelemetryForPersistence() { - if (activeCursorSamples.length === 0) { - return - } + if (activeCursorSamples.length === 0) { + return; + } - if (pendingCursorSamples.length === 0) { - pendingCursorSamples = [...activeCursorSamples] - return - } + if (pendingCursorSamples.length === 0) { + pendingCursorSamples = [...activeCursorSamples]; + return; + } - const lastPendingTimeMs = pendingCursorSamples[pendingCursorSamples.length - 1]?.timeMs ?? -1 - pendingCursorSamples = [ - ...pendingCursorSamples, - ...activeCursorSamples.filter((sample) => sample.timeMs > lastPendingTimeMs), - ] + const lastPendingTimeMs = pendingCursorSamples[pendingCursorSamples.length - 1]?.timeMs ?? -1; + pendingCursorSamples = [ + ...pendingCursorSamples, + ...activeCursorSamples.filter((sample) => sample.timeMs > lastPendingTimeMs), + ]; } async function finalizeStoredVideo(videoPath: string) { - // Safety net: if companion audio files still exist, the mux was skipped — attempt it now - if (videoPath.endsWith('.mp4')) { - const companionCandidates = await getUsableCompanionAudioCandidates(videoPath) - for (const { systemPath, micPath, platform } of companionCandidates) { - if (platform === 'mac' || platform === 'win') { - console.log(`[finalize] Detected un-muxed ${platform} audio files alongside video — attempting safety-net mux`) - try { - if (platform === 'win') { - await muxNativeWindowsVideoWithAudio(videoPath, systemPath, micPath) - } else { - await muxNativeMacRecordingWithAudio(videoPath, systemPath, micPath) - } - console.log('[finalize] Safety-net mux completed successfully') - } catch (error) { - console.warn('[finalize] Safety-net mux failed:', error) - } - break - } - } - } - - let validation: { fileSizeBytes: number; durationSeconds: number | null } | null = null - try { - validation = await validateRecordedVideo(videoPath) - } catch (error) { - console.warn('Video validation failed (proceeding anyway):', error) - } - - snapshotCursorTelemetryForPersistence() - currentVideoPath = videoPath - currentProjectPath = null - await persistPendingCursorTelemetry(videoPath) - if (isAutoRecordingPath(videoPath)) { - await pruneAutoRecordings([videoPath]) - } - - if (lastNativeCaptureDiagnostics?.backend === 'mac-screencapturekit') { - 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: videoPath, - systemAudioPath: lastNativeCaptureDiagnostics.systemAudioPath ?? null, - microphonePath: lastNativeCaptureDiagnostics.microphonePath ?? null, - osRelease: lastNativeCaptureDiagnostics.osRelease, - supported: lastNativeCaptureDiagnostics.supported, - helperExists: lastNativeCaptureDiagnostics.helperExists, - processOutput: lastNativeCaptureDiagnostics.processOutput, - fileSizeBytes: validation?.fileSizeBytes ?? null, - }) - } - - return { - success: true, - path: videoPath, - message: validation?.durationSeconds !== null && validation !== null - ? `Video stored successfully (${validation.fileSizeBytes} bytes, ${validation.durationSeconds.toFixed(2)}s)` - : `Video stored successfully` - } + // Safety net: if companion audio files still exist, the mux was skipped — attempt it now + if (videoPath.endsWith(".mp4")) { + const companionCandidates = await getUsableCompanionAudioCandidates(videoPath); + for (const { systemPath, micPath, platform } of companionCandidates) { + if (platform === "mac" || platform === "win") { + console.log( + `[finalize] Detected un-muxed ${platform} audio files alongside video — attempting safety-net mux`, + ); + try { + if (platform === "win") { + await muxNativeWindowsVideoWithAudio(videoPath, systemPath, micPath); + } else { + await muxNativeMacRecordingWithAudio(videoPath, systemPath, micPath); + } + console.log("[finalize] Safety-net mux completed successfully"); + } catch (error) { + console.warn("[finalize] Safety-net mux failed:", error); + } + break; + } + } + } + + let validation: { fileSizeBytes: number; durationSeconds: number | null } | null = null; + try { + validation = await validateRecordedVideo(videoPath); + } catch (error) { + console.warn("Video validation failed (proceeding anyway):", error); + } + + snapshotCursorTelemetryForPersistence(); + currentVideoPath = videoPath; + currentProjectPath = null; + await persistPendingCursorTelemetry(videoPath); + if (isAutoRecordingPath(videoPath)) { + await pruneAutoRecordings([videoPath]); + } + + if (lastNativeCaptureDiagnostics?.backend === "mac-screencapturekit") { + 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: videoPath, + systemAudioPath: lastNativeCaptureDiagnostics.systemAudioPath ?? null, + microphonePath: lastNativeCaptureDiagnostics.microphonePath ?? null, + osRelease: lastNativeCaptureDiagnostics.osRelease, + supported: lastNativeCaptureDiagnostics.supported, + helperExists: lastNativeCaptureDiagnostics.helperExists, + processOutput: lastNativeCaptureDiagnostics.processOutput, + fileSizeBytes: validation?.fileSizeBytes ?? null, + }); + } + + return { + success: true, + path: videoPath, + message: + validation?.durationSeconds !== null && validation !== null + ? `Video stored successfully (${validation.fileSizeBytes} bytes, ${validation.durationSeconds.toFixed(2)}s)` + : `Video stored successfully`, + }; } async function recoverNativeMacCaptureOutput() { - const macDiagnostics = - lastNativeCaptureDiagnostics?.backend === 'mac-screencapturekit' - ? lastNativeCaptureDiagnostics - : null - const diagnosticsPath = macDiagnostics?.outputPath ?? null - const candidatePath = nativeCaptureTargetPath ?? diagnosticsPath - const systemAudioPath = nativeCaptureSystemAudioPath ?? macDiagnostics?.systemAudioPath ?? null - const microphonePath = nativeCaptureMicrophonePath ?? macDiagnostics?.microphonePath ?? null - - if (!candidatePath) { - return null - } - - try { - if (systemAudioPath || microphonePath) { - try { - await muxNativeMacRecordingWithAudio(candidatePath, systemAudioPath, microphonePath) - } catch (muxError) { - console.warn('Failed to mux audio during recovery:', muxError) - } - } - - return await finalizeStoredVideo(candidatePath) - } catch (error) { - recordNativeCaptureDiagnostics({ - backend: 'mac-screencapturekit', - phase: 'stop', - outputPath: candidatePath, - systemAudioPath, - microphonePath, - processOutput: nativeCaptureOutputBuffer.trim() || undefined, - fileSizeBytes: await getFileSizeIfPresent(candidatePath), - error: String(error), - }) - return null - } + const macDiagnostics = + lastNativeCaptureDiagnostics?.backend === "mac-screencapturekit" + ? lastNativeCaptureDiagnostics + : null; + const diagnosticsPath = macDiagnostics?.outputPath ?? null; + const candidatePath = nativeCaptureTargetPath ?? diagnosticsPath; + const systemAudioPath = nativeCaptureSystemAudioPath ?? macDiagnostics?.systemAudioPath ?? null; + const microphonePath = nativeCaptureMicrophonePath ?? macDiagnostics?.microphonePath ?? null; + + if (!candidatePath) { + return null; + } + + try { + if (systemAudioPath || microphonePath) { + try { + await muxNativeMacRecordingWithAudio(candidatePath, systemAudioPath, microphonePath); + } catch (muxError) { + console.warn("Failed to mux audio during recovery:", muxError); + } + } + + return await finalizeStoredVideo(candidatePath); + } catch (error) { + recordNativeCaptureDiagnostics({ + backend: "mac-screencapturekit", + phase: "stop", + outputPath: candidatePath, + systemAudioPath, + microphonePath, + processOutput: nativeCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: await getFileSizeIfPresent(candidatePath), + error: String(error), + }); + return null; + } } async function startInteractionCapture() { - if (!isCursorCaptureActive) { - return - } - - if (!['darwin', 'win32', 'linux'].includes(process.platform)) { - return - } - - try { - const hook = loadUiohookModule() - console.log('[CursorTelemetry] hook loaded:', !!hook, 'has.on:', typeof hook?.on, 'has.start:', typeof hook?.start) - if (!isCursorCaptureActive) { - return - } - - if (!hook || typeof hook.on !== 'function' || typeof hook.start !== 'function') { - console.log('[CursorTelemetry] hook unusable — aborting interaction capture') - return - } - - const onMouseDown = (event: any) => { - if (!isCursorCaptureActive) { - return - } - - const point = getNormalizedCursorPoint() - if (!point) { - return - } - - const timeMs = Date.now() - cursorCaptureStartTimeMs - const button = getHookMouseButton(event) - let interactionType: CursorInteractionType = 'click' - - if (button === 2) { - interactionType = 'right-click' - } else if (button === 3) { - interactionType = 'middle-click' - } else { - const thresholdMs = 350 - const distance = lastLeftClick - ? Math.hypot(point.cx - lastLeftClick.cx, point.cy - lastLeftClick.cy) - : Number.POSITIVE_INFINITY - - if (lastLeftClick && timeMs - lastLeftClick.timeMs <= thresholdMs && distance <= 0.04) { - interactionType = 'double-click' - } - - lastLeftClick = { timeMs, cx: point.cx, cy: point.cy } - } - - pushCursorSample(point.cx, point.cy, timeMs, interactionType) - } - - const onMouseUp = (_event: any) => { - if (!isCursorCaptureActive) { - return - } - - const point = getNormalizedCursorPoint() - if (!point) { - return - } - - const timeMs = Date.now() - cursorCaptureStartTimeMs - pushCursorSample(point.cx, point.cy, timeMs, 'mouseup') - } - - const onMouseMove = (event: any) => { - if (process.platform !== 'linux' || !isCursorCaptureActive) { - return - } - - const point = getHookCursorScreenPoint(event) - if (!point) { - return - } - - linuxCursorScreenPoint = { x: point.x, y: point.y, updatedAt: Date.now() } - } - - hook.on('mousedown', onMouseDown) - hook.on('mouseup', onMouseUp) - hook.on('mousemove', onMouseMove) - - hook.start() - - interactionCaptureCleanup = () => { - try { - if (typeof hook.off === 'function') { - hook.off('mousedown', onMouseDown) - hook.off('mouseup', onMouseUp) - hook.off('mousemove', onMouseMove) - } else if (typeof hook.removeListener === 'function') { - hook.removeListener('mousedown', onMouseDown) - hook.removeListener('mouseup', onMouseUp) - hook.removeListener('mousemove', onMouseMove) - } - } catch { - // ignore listener cleanup errors - } - - try { - if (typeof hook.stop === 'function') { - hook.stop() - } - } catch { - // ignore hook shutdown errors - } - } - } catch (error) { - if (!hasLoggedInteractionHookFailure) { - hasLoggedInteractionHookFailure = true - console.warn('[CursorTelemetry] Global interaction capture unavailable:', error) - } - } + if (!isCursorCaptureActive) { + return; + } + + if (!["darwin", "win32", "linux"].includes(process.platform)) { + return; + } + + try { + const hook = loadUiohookModule(); + console.log( + "[CursorTelemetry] hook loaded:", + !!hook, + "has.on:", + typeof hook?.on, + "has.start:", + typeof hook?.start, + ); + if (!isCursorCaptureActive) { + return; + } + + if (!hook || typeof hook.on !== "function" || typeof hook.start !== "function") { + console.log("[CursorTelemetry] hook unusable — aborting interaction capture"); + return; + } + + const onMouseDown = (event: any) => { + if (!isCursorCaptureActive) { + return; + } + + const point = getNormalizedCursorPoint(); + if (!point) { + return; + } + + const timeMs = Date.now() - cursorCaptureStartTimeMs; + const button = getHookMouseButton(event); + let interactionType: CursorInteractionType = "click"; + + if (button === 2) { + interactionType = "right-click"; + } else if (button === 3) { + interactionType = "middle-click"; + } else { + const thresholdMs = 350; + const distance = lastLeftClick + ? Math.hypot(point.cx - lastLeftClick.cx, point.cy - lastLeftClick.cy) + : Number.POSITIVE_INFINITY; + + if (lastLeftClick && timeMs - lastLeftClick.timeMs <= thresholdMs && distance <= 0.04) { + interactionType = "double-click"; + } + + lastLeftClick = { timeMs, cx: point.cx, cy: point.cy }; + } + + pushCursorSample(point.cx, point.cy, timeMs, interactionType); + }; + + const onMouseUp = (_event: any) => { + if (!isCursorCaptureActive) { + return; + } + + const point = getNormalizedCursorPoint(); + if (!point) { + return; + } + + const timeMs = Date.now() - cursorCaptureStartTimeMs; + pushCursorSample(point.cx, point.cy, timeMs, "mouseup"); + }; + + const onMouseMove = (event: any) => { + if (process.platform !== "linux" || !isCursorCaptureActive) { + return; + } + + const point = getHookCursorScreenPoint(event); + if (!point) { + return; + } + + linuxCursorScreenPoint = { x: point.x, y: point.y, updatedAt: Date.now() }; + }; + + hook.on("mousedown", onMouseDown); + hook.on("mouseup", onMouseUp); + hook.on("mousemove", onMouseMove); + + hook.start(); + + interactionCaptureCleanup = () => { + try { + if (typeof hook.off === "function") { + hook.off("mousedown", onMouseDown); + hook.off("mouseup", onMouseUp); + hook.off("mousemove", onMouseMove); + } else if (typeof hook.removeListener === "function") { + hook.removeListener("mousedown", onMouseDown); + hook.removeListener("mouseup", onMouseUp); + hook.removeListener("mousemove", onMouseMove); + } + } catch { + // ignore listener cleanup errors + } + + try { + if (typeof hook.stop === "function") { + hook.stop(); + } + } catch { + // ignore hook shutdown errors + } + }; + } catch (error) { + if (!hasLoggedInteractionHookFailure) { + hasLoggedInteractionHookFailure = true; + console.warn("[CursorTelemetry] Global interaction capture unavailable:", error); + } + } } export function registerIpcHandlers( - createEditorWindow: () => void, - createSourceSelectorWindow: () => BrowserWindow, - _getMainWindow: () => BrowserWindow | null, - getSourceSelectorWindow: () => BrowserWindow | null, - onRecordingStateChange?: (recording: boolean, sourceName: string) => void + createEditorWindow: () => void, + createSourceSelectorWindow: () => BrowserWindow, + _getMainWindow: () => BrowserWindow | null, + getSourceSelectorWindow: () => BrowserWindow | null, + onRecordingStateChange?: (recording: boolean, sourceName: string) => void, ) { - ipcMain.handle('get-sources', async (_, opts) => { - const includeScreens = Array.isArray(opts?.types) ? opts.types.includes('screen') : true - const includeWindows = Array.isArray(opts?.types) ? opts.types.includes('window') : true - const electronTypes = [ - ...(includeScreens ? ['screen' as const] : []), - ...(includeWindows ? ['window' as const] : []), - ] - const electronSources = electronTypes.length > 0 - ? await desktopCapturer.getSources({ - ...opts, - types: electronTypes, - }).catch((error) => { - console.warn('desktopCapturer.getSources failed (screen recording permission may be missing):', error) - return [] - }) - : [] - const ownWindowNames = new Set( - [ - app.getName(), - 'Recordly', - ...BrowserWindow.getAllWindows().flatMap((win) => { - const title = win.getTitle().trim() - return title ? [title] : [] - }), - ] - .map((name) => normalizeDesktopSourceName(name)) - .filter(Boolean) - ) - const ownAppName = normalizeDesktopSourceName(app.getName()) - - const displays = includeScreens - ? [...getScreen().getAllDisplays()].sort((left, right) => ( - left.bounds.x - right.bounds.x - || left.bounds.y - right.bounds.y - || left.id - right.id - )) - : [] - const primaryDisplayId = includeScreens ? String(getScreen().getPrimaryDisplay().id) : '' - const electronScreenSourcesByDisplayId = new Map( - electronSources - .filter((source) => source.id.startsWith('screen:')) - .map((source) => [String(source.display_id ?? ''), source] as const) - ) - - const screenSources = displays.map((display, index) => { - const displayId = String(display.id) - const matchedSource = electronScreenSourcesByDisplayId.get(displayId) - const displayName = displayId === primaryDisplayId - ? `Screen ${index + 1} (Primary)` - : `Screen ${index + 1}` - - return { - id: matchedSource?.id ?? `screen:fallback:${displayId}`, - name: displayName, - originalName: matchedSource?.name ?? displayName, - display_id: displayId, - thumbnail: matchedSource?.thumbnail ? matchedSource.thumbnail.toDataURL() : null, - appIcon: matchedSource?.appIcon ? matchedSource.appIcon.toDataURL() : null, - sourceType: 'screen' as const, - } - }) - - if (process.platform !== 'darwin' || !includeWindows) { - const windowSources = electronSources - .filter((source) => source.id.startsWith('window:')) - .filter((source) => hasUsableSourceThumbnail(source.thumbnail)) - .filter((source) => { - const normalizedName = normalizeDesktopSourceName(source.name) - if (!normalizedName) { - return true - } - - if (ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedName.includes('recordly')) { - return true - } - - for (const ownName of ownWindowNames) { - if (!ownName) continue - if (normalizedName === ownName) { - return false - } - } - - return true - }) - .map((source) => ({ - id: source.id, - name: source.name, - originalName: source.name, - display_id: source.display_id, - thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, - appIcon: source.appIcon ? source.appIcon.toDataURL() : null, - sourceType: 'window' as const, - })) - - return [...screenSources, ...windowSources] - } - - try { - const nativeWindowSources = await getNativeMacWindowSources() - const electronWindowSourceMap = new Map( - electronSources - .filter((source) => source.id.startsWith('window:')) - .map((source) => [source.id, source] as const) - ) - - const mergedWindowSources = nativeWindowSources - .filter((source) => { - const normalizedWindowName = normalizeDesktopSourceName(source.windowTitle ?? source.name) - const normalizedAppName = normalizeDesktopSourceName(source.appName ?? '') - - if (!ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedAppName && normalizedAppName === ownAppName) { - return false - } - - if (ALLOW_RECORDLY_WINDOW_CAPTURE && (normalizedAppName === 'recordly' || normalizedWindowName?.includes('recordly'))) { - return true - } - - if (!normalizedWindowName) { - return true - } - - for (const ownName of ownWindowNames) { - if (!ownName) continue - if (normalizedWindowName === ownName) { - return false - } - } - - return true - }) - .map((source) => { - const electronWindowSource = electronWindowSourceMap.get(source.id) - return { - id: source.id, - name: source.name, - originalName: source.name, - display_id: source.display_id ?? electronWindowSource?.display_id ?? '', - thumbnail: electronWindowSource?.thumbnail ? electronWindowSource.thumbnail.toDataURL() : null, - appIcon: source.appIcon ?? (electronWindowSource?.appIcon ? electronWindowSource.appIcon.toDataURL() : null), - appName: source.appName, - windowTitle: source.windowTitle, - sourceType: 'window' as const, - } - }) - - return [...screenSources, ...mergedWindowSources] - } catch (error) { - console.warn('Falling back to Electron window enumeration on macOS:', error) - - const windowSources = electronSources - .filter((source) => source.id.startsWith('window:')) - .filter((source) => { - const normalizedName = normalizeDesktopSourceName(source.name) - if (!normalizedName) { - return true - } - - if (ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedName.includes('recordly')) { - return true - } - - for (const ownName of ownWindowNames) { - if (!ownName) continue - if (normalizedName === ownName || normalizedName.includes(ownName) || ownName.includes(normalizedName)) { - return false - } - } - - return true - }) - .map((source) => ({ - id: source.id, - name: source.name, - originalName: source.name, - display_id: source.display_id, - thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, - appIcon: source.appIcon ? source.appIcon.toDataURL() : null, - sourceType: 'window' as const, - })) - - return [...screenSources, ...windowSources] - } - }) - - ipcMain.handle('select-source', (_, source: SelectedSource) => { - selectedSource = source - broadcastSelectedSourceChange() - stopWindowBoundsCapture() - const sourceSelectorWin = getSourceSelectorWindow() - if (sourceSelectorWin) { - sourceSelectorWin.close() - } - return selectedSource - }) - - ipcMain.handle('show-source-highlight', async (_, source: SelectedSource) => { - try { - const isWindow = source.id?.startsWith('window:') - const windowId = isWindow ? parseWindowId(source.id) : null - - // ── 1. Bring window to front ── - if (isWindow && process.platform === 'darwin') { - const appName = source.appName || source.name?.split(' — ')[0]?.trim() - if (appName) { - try { - await execFileAsync('osascript', ['-e', - `tell application "${appName}" to activate` - ], { timeout: 2000 }) - await new Promise((resolve) => setTimeout(resolve, 350)) - } catch { /* ignore */ } - } - } else if (windowId && process.platform === 'linux') { - try { - await execFileAsync('wmctrl', ['-i', '-a', `0x${windowId.toString(16)}`], { timeout: 1500 }) - } catch { - try { - await execFileAsync('xdotool', ['windowactivate', String(windowId)], { timeout: 1500 }) - } catch { /* not available */ } - } - await new Promise((resolve) => setTimeout(resolve, 250)) - } - - // ── 2. Resolve bounds ── - let bounds: { x: number; y: number; width: number; height: number } | null = null - - if (source.id?.startsWith('screen:')) { - bounds = getDisplayBoundsForSource(source) - } else if (isWindow) { - if (process.platform === 'darwin') { - bounds = await resolveMacWindowBounds(source) - } else if (process.platform === 'win32') { - bounds = await resolveWindowsWindowBounds(source) - } else if (process.platform === 'linux') { - bounds = await resolveLinuxWindowBounds(source) - } - } - - if (!bounds || bounds.width <= 0 || bounds.height <= 0) { - bounds = getDisplayBoundsForSource(source) - } - - // ── 3. Show traveling wave highlight ── - const pad = 6 - const highlightWin = new BrowserWindow({ - x: bounds.x - pad, - y: bounds.y - pad, - width: bounds.width + pad * 2, - height: bounds.height + pad * 2, - frame: false, - transparent: true, - alwaysOnTop: true, - skipTaskbar: true, - hasShadow: false, - resizable: false, - focusable: false, - webPreferences: { nodeIntegration: false, contextIsolation: true }, - }) - - highlightWin.setIgnoreMouseEvents(true) - - const html = ` + ipcMain.handle("get-sources", async (_, opts) => { + const includeScreens = Array.isArray(opts?.types) ? opts.types.includes("screen") : true; + const includeWindows = Array.isArray(opts?.types) ? opts.types.includes("window") : true; + const electronTypes = [ + ...(includeScreens ? ["screen" as const] : []), + ...(includeWindows ? ["window" as const] : []), + ]; + const electronSources = + electronTypes.length > 0 + ? await desktopCapturer + .getSources({ + ...opts, + types: electronTypes, + }) + .catch((error) => { + console.warn( + "desktopCapturer.getSources failed (screen recording permission may be missing):", + error, + ); + return []; + }) + : []; + const ownWindowNames = new Set( + [ + app.getName(), + "Recordly", + ...BrowserWindow.getAllWindows().flatMap((win) => { + const title = win.getTitle().trim(); + return title ? [title] : []; + }), + ] + .map((name) => normalizeDesktopSourceName(name)) + .filter(Boolean), + ); + const ownAppName = normalizeDesktopSourceName(app.getName()); + + const displays = includeScreens + ? [...getScreen().getAllDisplays()].sort( + (left, right) => + left.bounds.x - right.bounds.x || left.bounds.y - right.bounds.y || left.id - right.id, + ) + : []; + const primaryDisplayId = includeScreens ? String(getScreen().getPrimaryDisplay().id) : ""; + const electronScreenSourcesByDisplayId = new Map( + electronSources + .filter((source) => source.id.startsWith("screen:")) + .map((source) => [String(source.display_id ?? ""), source] as const), + ); + + const screenSources = displays.map((display, index) => { + const displayId = String(display.id); + const matchedSource = electronScreenSourcesByDisplayId.get(displayId); + const displayName = + displayId === primaryDisplayId ? `Screen ${index + 1} (Primary)` : `Screen ${index + 1}`; + + return { + id: matchedSource?.id ?? `screen:fallback:${displayId}`, + name: displayName, + originalName: matchedSource?.name ?? displayName, + display_id: displayId, + thumbnail: matchedSource?.thumbnail ? matchedSource.thumbnail.toDataURL() : null, + appIcon: matchedSource?.appIcon ? matchedSource.appIcon.toDataURL() : null, + sourceType: "screen" as const, + }; + }); + + if (process.platform !== "darwin" || !includeWindows) { + const windowSources = electronSources + .filter((source) => source.id.startsWith("window:")) + .filter((source) => hasUsableSourceThumbnail(source.thumbnail)) + .filter((source) => { + const normalizedName = normalizeDesktopSourceName(source.name); + if (!normalizedName) { + return true; + } + + if (ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedName.includes("recordly")) { + return true; + } + + for (const ownName of ownWindowNames) { + if (!ownName) continue; + if (normalizedName === ownName) { + return false; + } + } + + return true; + }) + .map((source) => ({ + id: source.id, + name: source.name, + originalName: source.name, + display_id: source.display_id, + thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, + appIcon: source.appIcon ? source.appIcon.toDataURL() : null, + sourceType: "window" as const, + })); + + return [...screenSources, ...windowSources]; + } + + try { + const nativeWindowSources = await getNativeMacWindowSources(); + const electronWindowSourceMap = new Map( + electronSources + .filter((source) => source.id.startsWith("window:")) + .map((source) => [source.id, source] as const), + ); + + const mergedWindowSources = nativeWindowSources + .filter((source) => { + const normalizedWindowName = normalizeDesktopSourceName( + source.windowTitle ?? source.name, + ); + const normalizedAppName = normalizeDesktopSourceName(source.appName ?? ""); + + if ( + !ALLOW_RECORDLY_WINDOW_CAPTURE && + normalizedAppName && + normalizedAppName === ownAppName + ) { + return false; + } + + if ( + ALLOW_RECORDLY_WINDOW_CAPTURE && + (normalizedAppName === "recordly" || normalizedWindowName?.includes("recordly")) + ) { + return true; + } + + if (!normalizedWindowName) { + return true; + } + + for (const ownName of ownWindowNames) { + if (!ownName) continue; + if (normalizedWindowName === ownName) { + return false; + } + } + + return true; + }) + .map((source) => { + const electronWindowSource = electronWindowSourceMap.get(source.id); + return { + id: source.id, + name: source.name, + originalName: source.name, + display_id: source.display_id ?? electronWindowSource?.display_id ?? "", + thumbnail: electronWindowSource?.thumbnail + ? electronWindowSource.thumbnail.toDataURL() + : null, + appIcon: + source.appIcon ?? + (electronWindowSource?.appIcon ? electronWindowSource.appIcon.toDataURL() : null), + appName: source.appName, + windowTitle: source.windowTitle, + sourceType: "window" as const, + }; + }); + + return [...screenSources, ...mergedWindowSources]; + } catch (error) { + console.warn("Falling back to Electron window enumeration on macOS:", error); + + const windowSources = electronSources + .filter((source) => source.id.startsWith("window:")) + .filter((source) => { + const normalizedName = normalizeDesktopSourceName(source.name); + if (!normalizedName) { + return true; + } + + if (ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedName.includes("recordly")) { + return true; + } + + for (const ownName of ownWindowNames) { + if (!ownName) continue; + if ( + normalizedName === ownName || + normalizedName.includes(ownName) || + ownName.includes(normalizedName) + ) { + return false; + } + } + + return true; + }) + .map((source) => ({ + id: source.id, + name: source.name, + originalName: source.name, + display_id: source.display_id, + thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, + appIcon: source.appIcon ? source.appIcon.toDataURL() : null, + sourceType: "window" as const, + })); + + return [...screenSources, ...windowSources]; + } + }); + + ipcMain.handle("select-source", (_, source: SelectedSource) => { + selectedSource = source; + broadcastSelectedSourceChange(); + stopWindowBoundsCapture(); + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin) { + sourceSelectorWin.close(); + } + return selectedSource; + }); + + ipcMain.handle("show-source-highlight", async (_, source: SelectedSource) => { + try { + const isWindow = source.id?.startsWith("window:"); + const windowId = isWindow ? parseWindowId(source.id) : null; + + // ── 1. Bring window to front ── + if (isWindow && process.platform === "darwin") { + const appName = source.appName || source.name?.split(" — ")[0]?.trim(); + if (appName) { + try { + await execFileAsync("osascript", ["-e", `tell application "${appName}" to activate`], { + timeout: 2000, + }); + await new Promise((resolve) => setTimeout(resolve, 350)); + } catch { + /* ignore */ + } + } + } else if (windowId && process.platform === "linux") { + try { + await execFileAsync("wmctrl", ["-i", "-a", `0x${windowId.toString(16)}`], { + timeout: 1500, + }); + } catch { + try { + await execFileAsync("xdotool", ["windowactivate", String(windowId)], { timeout: 1500 }); + } catch { + /* not available */ + } + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + // ── 2. Resolve bounds ── + let bounds: { x: number; y: number; width: number; height: number } | null = null; + + if (source.id?.startsWith("screen:")) { + bounds = getDisplayBoundsForSource(source); + } else if (isWindow) { + if (process.platform === "darwin") { + bounds = await resolveMacWindowBounds(source); + } else if (process.platform === "win32") { + bounds = await resolveWindowsWindowBounds(source); + } else if (process.platform === "linux") { + bounds = await resolveLinuxWindowBounds(source); + } + } + + if (!bounds || bounds.width <= 0 || bounds.height <= 0) { + bounds = getDisplayBoundsForSource(source); + } + + // ── 3. Show traveling wave highlight ── + const pad = 6; + const highlightWin = new BrowserWindow({ + x: bounds.x - pad, + y: bounds.y - pad, + width: bounds.width + pad * 2, + height: bounds.height + pad * 2, + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + resizable: false, + focusable: false, + webPreferences: { nodeIntegration: false, contextIsolation: true }, + }); + + highlightWin.setIgnoreMouseEvents(true); + + const html = `
-` - - await highlightWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) - - setTimeout(() => { - if (!highlightWin.isDestroyed()) highlightWin.close() - }, 1700) - - 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') - createEditorWindow() - }) - - ipcMain.handle('start-native-screen-recording', async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => { - // Windows native capture path - if (process.platform === 'win32') { - const windowsCaptureAvailable = await isNativeWindowsCaptureAvailable() - if (!windowsCaptureAvailable) { - return { success: false, message: 'Native Windows capture is not available on this system.' } - } - - if (windowsCaptureProcess && !windowsNativeCaptureActive) { - try { windowsCaptureProcess.kill() } catch { /* ignore */ } - windowsCaptureProcess = null - windowsCaptureTargetPath = null - windowsCaptureStopRequested = false - } - - if (windowsCaptureProcess) { - return { success: false, message: 'A native Windows screen recording is already active.' } - } - - try { - const exePath = getWindowsCaptureExePath() - const recordingsDir = await getRecordingsDir() - const timestamp = Date.now() - const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`) - const displayBounds = source?.id?.startsWith('window:') ? null : getDisplayBoundsForSource(source) - - const config: Record = { - outputPath, - fps: 60, - } - - if (options?.capturesSystemAudio) { - const audioPath = path.join(recordingsDir, `recording-${timestamp}.system.wav`) - config.captureSystemAudio = true - config.audioOutputPath = audioPath - windowsSystemAudioPath = audioPath - } - - if (options?.capturesMicrophone) { - const micPath = path.join(recordingsDir, `recording-${timestamp}.mic.wav`) - config.captureMic = true - config.micOutputPath = micPath - if (options.microphoneLabel) { - config.micDeviceName = options.microphoneLabel - } - windowsMicAudioPath = micPath - } - - const windowId = parseWindowId(source?.id) - if (windowId && source?.id?.startsWith('window:')) { - config.windowHandle = windowId - } else { - const resolvedDisplay = resolveWindowsCaptureDisplay( - source, - getScreen().getAllDisplays(), - getScreen().getPrimaryDisplay(), - ) - config.displayId = resolvedDisplay.displayId - - // Monitor handle IDs can drift across Electron/Windows capture boundaries, - // so also provide display bounds for a coordinate-based native fallback. - config.displayX = Math.round(resolvedDisplay.bounds.x) - config.displayY = Math.round(resolvedDisplay.bounds.y) - config.displayW = Math.round(resolvedDisplay.bounds.width) - config.displayH = Math.round(resolvedDisplay.bounds.height) - } - - recordNativeCaptureDiagnostics({ - backend: 'windows-wgc', - phase: 'start', - sourceId: source?.id ?? null, - sourceType: source?.sourceType ?? 'unknown', - displayId: typeof config.displayId === 'number' ? config.displayId : null, - displayBounds, - windowHandle: typeof config.windowHandle === 'number' ? config.windowHandle : null, - helperPath: exePath, - outputPath, - systemAudioPath: windowsSystemAudioPath, - microphonePath: windowsMicAudioPath, - }) - - windowsCaptureOutputBuffer = '' - windowsCaptureTargetPath = outputPath - windowsCaptureStopRequested = false - windowsCapturePaused = false - windowsCaptureProcess = spawn(exePath, [JSON.stringify(config)], { - cwd: recordingsDir, - stdio: ['pipe', 'pipe', 'pipe'], - }) - attachWindowsCaptureLifecycle(windowsCaptureProcess) - - windowsCaptureProcess.stdout.on('data', (chunk: Buffer) => { - windowsCaptureOutputBuffer += chunk.toString() - }) - windowsCaptureProcess.stderr.on('data', (chunk: Buffer) => { - windowsCaptureOutputBuffer += chunk.toString() - }) - - await waitForWindowsCaptureStart(windowsCaptureProcess) - windowsNativeCaptureActive = true - nativeScreenRecordingActive = true - recordNativeCaptureDiagnostics({ - backend: 'windows-wgc', - phase: 'start', - sourceId: source?.id ?? null, - sourceType: source?.sourceType ?? 'unknown', - displayId: typeof config.displayId === 'number' ? config.displayId : null, - displayBounds, - windowHandle: typeof config.windowHandle === 'number' ? config.windowHandle : null, - helperPath: exePath, - outputPath, - systemAudioPath: windowsSystemAudioPath, - microphonePath: windowsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - }) - return { success: true } - } catch (error) { - recordNativeCaptureDiagnostics({ - backend: 'windows-wgc', - phase: 'start', - sourceId: source?.id ?? null, - sourceType: source?.sourceType ?? 'unknown', - helperPath: windowsCaptureTargetPath ? getWindowsCaptureExePath() : null, - outputPath: windowsCaptureTargetPath, - systemAudioPath: windowsSystemAudioPath, - microphonePath: windowsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - error: String(error), - }) - console.error('Failed to start native Windows capture:', error) - try { windowsCaptureProcess?.kill() } catch { /* ignore */ } - windowsNativeCaptureActive = false - nativeScreenRecordingActive = false - windowsCaptureProcess = null - windowsCaptureTargetPath = null - windowsCaptureStopRequested = false - windowsCapturePaused = false - return { - success: false, - message: 'Failed to start native Windows capture', - error: String(error), - } - } - } - - if (process.platform !== 'darwin') { - return { success: false, message: 'Native screen recording is only available on macOS.' } - } - - if (nativeCaptureProcess && !nativeScreenRecordingActive) { - try { - nativeCaptureProcess.kill() - } catch { - // ignore stale helper cleanup failures - } - nativeCaptureProcess = null - nativeCaptureTargetPath = null - nativeCaptureStopRequested = false - } - - if (nativeCaptureProcess) { - return { success: false, message: 'A native screen recording is already active.' } - } - - try { - const recordingsDir = await getRecordingsDir() - - // Warm up TCC: trigger an Electron-level screen capture API call so macOS - // activates the screen-recording grant for this process tree before the - // native helper binary spawns and calls SCStream.startCapture(). - try { - await desktopCapturer.getSources({ types: ['screen'], thumbnailSize: { width: 1, height: 1 } }) - } catch { - // non-fatal – the helper will report its own TCC status - } - - // Ensure microphone TCC is granted for this process tree when mic capture - // is requested, so the child helper inherits the grant. - if (options?.capturesMicrophone) { - const micStatus = systemPreferences.getMediaAccessStatus('microphone') - if (micStatus !== 'granted') { - await systemPreferences.askForMediaAccess('microphone') - } - } - - const appName = normalizeDesktopSourceName(String(source?.appName ?? '')) - const ownAppName = normalizeDesktopSourceName(app.getName()) - if ( - !ALLOW_RECORDLY_WINDOW_CAPTURE - && - source?.id?.startsWith('window:') - && appName - && (appName === ownAppName || appName === 'recordly') - ) { - return { success: false, message: 'Cannot record Recordly windows. Please select another app window.' } - } - - const helperPath = await ensureNativeCaptureHelperBinary() - const timestamp = Date.now() - const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`) - const capturesSystemAudio = Boolean(options?.capturesSystemAudio) - const capturesMicrophone = Boolean(options?.capturesMicrophone) - const systemAudioOutputPath = capturesSystemAudio - ? path.join(recordingsDir, `recording-${timestamp}.system.m4a`) - : null - const microphoneOutputPath = capturesMicrophone - ? path.join(recordingsDir, `recording-${timestamp}.mic.m4a`) - : null - const config: Record = { - fps: 60, - outputPath, - capturesSystemAudio, - capturesMicrophone, - } - - if (options?.microphoneDeviceId) { - config.microphoneDeviceId = options.microphoneDeviceId - } - - if (options?.microphoneLabel) { - config.microphoneLabel = options.microphoneLabel - } - - if (systemAudioOutputPath) { - config.systemAudioOutputPath = systemAudioOutputPath - } - - if (microphoneOutputPath) { - config.microphoneOutputPath = microphoneOutputPath - } - - const windowId = parseWindowId(source?.id) - const screenId = Number(source?.display_id) - - if (Number.isFinite(windowId) && windowId && source?.id?.startsWith('window:')) { - config.windowId = windowId - } else if (Number.isFinite(screenId) && screenId > 0) { - config.displayId = screenId - } else { - config.displayId = Number(getScreen().getPrimaryDisplay().id) - } - - nativeCaptureOutputBuffer = '' - nativeCaptureTargetPath = outputPath - nativeCaptureSystemAudioPath = systemAudioOutputPath - nativeCaptureMicrophonePath = microphoneOutputPath - nativeCaptureStopRequested = false - nativeCapturePaused = false - nativeCaptureProcess = spawn(helperPath, [JSON.stringify(config)], { - cwd: recordingsDir, - stdio: ['pipe', 'pipe', 'pipe'], - }) - attachNativeCaptureLifecycle(nativeCaptureProcess) - - nativeCaptureProcess.stdout.on('data', (chunk: Buffer) => { - nativeCaptureOutputBuffer += chunk.toString() - }) - nativeCaptureProcess.stderr.on('data', (chunk: Buffer) => { - nativeCaptureOutputBuffer += chunk.toString() - }) - - await waitForNativeCaptureStart(nativeCaptureProcess) - nativeScreenRecordingActive = true - recordNativeCaptureDiagnostics({ - backend: 'mac-screencapturekit', - phase: 'start', - sourceId: source?.id ?? null, - sourceType: source?.sourceType ?? 'unknown', - displayId: typeof config.displayId === 'number' ? config.displayId : null, - helperPath, - outputPath, - systemAudioPath: systemAudioOutputPath, - microphonePath: microphoneOutputPath, - processOutput: nativeCaptureOutputBuffer.trim() || undefined, - }) - return { success: true } - } catch (error) { - console.error('Failed to start native ScreenCaptureKit recording:', error) - const errorStr = String(error) - - // Detect TCC (screen recording permission) errors and show a helpful dialog - if (errorStr.includes('declined TCC') || errorStr.includes('declined TCCs') || errorStr.includes('SCREEN_RECORDING_PERMISSION_DENIED')) { - const { response } = await dialog.showMessageBox({ - type: 'warning', - title: 'Screen Recording Permission Required', - message: 'Recordly needs screen recording permission to capture your screen.', - detail: 'Please open System Settings > Privacy & Security > Screen Recording, make sure Recordly is toggled ON, then try recording again.', - buttons: ['Open System Settings', 'Cancel'], - defaultId: 0, - cancelId: 1, - }) - if (response === 0) { - await shell.openExternal(getMacPrivacySettingsUrl('screen')) - } - try { nativeCaptureProcess?.kill() } catch { /* ignore */ } - nativeScreenRecordingActive = false - nativeCaptureProcess = null - nativeCaptureTargetPath = null - nativeCaptureSystemAudioPath = null - nativeCaptureMicrophonePath = null - nativeCaptureStopRequested = false - nativeCapturePaused = false - return { - success: false, - message: 'Screen recording permission not granted. Please allow access in System Settings and restart the app.', - userNotified: true, - } - } - - if (errorStr.includes('MICROPHONE_PERMISSION_DENIED')) { - const { response } = await dialog.showMessageBox({ - type: 'warning', - title: 'Microphone Permission Required', - message: 'Recordly needs microphone permission to record audio.', - detail: 'Please open System Settings > Privacy & Security > Microphone, make sure Recordly is toggled ON, then try recording again.', - buttons: ['Open System Settings', 'Cancel'], - defaultId: 0, - cancelId: 1, - }) - if (response === 0) { - await shell.openExternal(getMacPrivacySettingsUrl('microphone')) - } - try { nativeCaptureProcess?.kill() } catch { /* ignore */ } - nativeScreenRecordingActive = false - nativeCaptureProcess = null - nativeCaptureTargetPath = null - nativeCaptureSystemAudioPath = null - nativeCaptureMicrophonePath = null - nativeCaptureStopRequested = false - nativeCapturePaused = false - return { - success: false, - message: 'Microphone permission not granted. Please allow access in System Settings.', - userNotified: true, - } - } - - recordNativeCaptureDiagnostics({ - backend: 'mac-screencapturekit', - phase: 'start', - sourceId: source?.id ?? null, - sourceType: source?.sourceType ?? 'unknown', - helperPath: getNativeCaptureHelperBinaryPath(), - outputPath: nativeCaptureTargetPath, - systemAudioPath: nativeCaptureSystemAudioPath, - microphonePath: nativeCaptureMicrophonePath, - processOutput: nativeCaptureOutputBuffer.trim() || undefined, - fileSizeBytes: await getFileSizeIfPresent(nativeCaptureTargetPath), - error: String(error), - }) - try { - nativeCaptureProcess?.kill() - } catch { - // ignore cleanup failures - } - nativeScreenRecordingActive = false - nativeCaptureProcess = null - nativeCaptureTargetPath = null - nativeCaptureSystemAudioPath = null - nativeCaptureMicrophonePath = null - nativeCaptureStopRequested = false - nativeCapturePaused = false - return { - success: false, - message: 'Failed to start native ScreenCaptureKit recording', - error: String(error), - } - } - }) - - ipcMain.handle('stop-native-screen-recording', async () => { - // Windows native capture stop path - if (process.platform === 'win32' && windowsNativeCaptureActive) { - try { - if (!windowsCaptureProcess) { - throw new Error('Native Windows capture process is not running') - } - - const proc = windowsCaptureProcess - const preferredVideoPath = windowsCaptureTargetPath - windowsCaptureStopRequested = true - proc.stdin.write('stop\n') - const tempVideoPath = await waitForWindowsCaptureStop(proc) - windowsCaptureProcess = null - windowsNativeCaptureActive = false - nativeScreenRecordingActive = false - windowsCaptureTargetPath = null - windowsCaptureStopRequested = false - windowsCapturePaused = false - - const finalVideoPath = preferredVideoPath ?? tempVideoPath - if (tempVideoPath !== finalVideoPath) { - await moveFileWithOverwrite(tempVideoPath, finalVideoPath) - } - - windowsPendingVideoPath = finalVideoPath - recordNativeCaptureDiagnostics({ - backend: 'windows-wgc', - phase: 'stop', - outputPath: finalVideoPath, - systemAudioPath: windowsSystemAudioPath, - microphonePath: windowsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - fileSizeBytes: await getFileSizeIfPresent(finalVideoPath), - }) - return { success: true, path: finalVideoPath } - } catch (error) { - console.error('Failed to stop native Windows capture:', error) - const fallbackPath = windowsCaptureTargetPath - windowsNativeCaptureActive = false - nativeScreenRecordingActive = false - windowsCaptureProcess = null - windowsCaptureTargetPath = null - windowsCaptureStopRequested = false - windowsCapturePaused = false - windowsSystemAudioPath = null - windowsMicAudioPath = null - windowsPendingVideoPath = null - - if (fallbackPath) { - try { - await fs.access(fallbackPath) - windowsPendingVideoPath = fallbackPath - recordNativeCaptureDiagnostics({ - backend: 'windows-wgc', - phase: 'stop', - outputPath: fallbackPath, - systemAudioPath: windowsSystemAudioPath, - microphonePath: windowsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - fileSizeBytes: await getFileSizeIfPresent(fallbackPath), - error: String(error), - }) - return { success: true, path: fallbackPath } - } catch { - // File doesn't exist - } - } - - recordNativeCaptureDiagnostics({ - backend: 'windows-wgc', - phase: 'stop', - outputPath: fallbackPath, - systemAudioPath: windowsSystemAudioPath, - microphonePath: windowsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - error: String(error), - }) - - 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) - nativeCaptureStopRequested = true - process.stdin.write('stop\n') - const tempVideoPath = await waitForNativeCaptureStop(process) - console.log('[stop-native] Helper stopped, tempVideoPath:', tempVideoPath) - nativeCaptureProcess = null - nativeScreenRecordingActive = false - nativeCaptureTargetPath = null - nativeCaptureSystemAudioPath = null - nativeCaptureMicrophonePath = null - nativeCaptureStopRequested = false - nativeCapturePaused = 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) - nativeScreenRecordingActive = false - nativeCaptureProcess = null - nativeCaptureTargetPath = null - nativeCaptureSystemAudioPath = null - nativeCaptureMicrophonePath = null - nativeCaptureStopRequested = false - nativeCapturePaused = 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) - } - } - return await finalizeStoredVideo(fallbackPath) - } catch { - // File doesn't exist or isn't accessible - } - } - - const recovered = await recoverNativeMacCaptureOutput() - if (recovered) { - return recovered - } - - return { - success: false, - message: 'Failed to stop native ScreenCaptureKit recording', - error: String(error), - } - } - }) - - ipcMain.handle('recover-native-screen-recording', async () => { - if (process.platform !== 'darwin') { - return { success: false, message: 'Native screen recording recovery is only available on macOS.' } - } - - const recovered = await recoverNativeMacCaptureOutput() - if (recovered) { - return recovered - } - - return { - success: false, - message: 'No recoverable native macOS recording output was found.', - } - }) - - ipcMain.handle('pause-native-screen-recording', async () => { - if (process.platform === 'win32') { - if (!windowsNativeCaptureActive || !windowsCaptureProcess) { - return { success: false, message: 'No native Windows screen recording is active.' } - } - - if (windowsCapturePaused) { - return { success: true } - } - - try { - windowsCaptureProcess.stdin.write('pause\n') - windowsCapturePaused = true - return { success: true } - } catch (error) { - return { success: false, message: 'Failed to pause native Windows capture', error: String(error) } - } - } - - if (process.platform !== 'darwin') { - return { success: false, message: 'Native screen recording is only available on macOS.' } - } - - if (!nativeScreenRecordingActive || !nativeCaptureProcess) { - return { success: false, message: 'No native screen recording is active.' } - } - - if (nativeCapturePaused) { - return { success: true } - } - - try { - nativeCaptureProcess.stdin.write('pause\n') - nativeCapturePaused = true - return { success: true } - } catch (error) { - return { success: false, message: 'Failed to pause native screen recording', error: String(error) } - } - }) - - ipcMain.handle('resume-native-screen-recording', async () => { - if (process.platform === 'win32') { - if (!windowsNativeCaptureActive || !windowsCaptureProcess) { - return { success: false, message: 'No native Windows screen recording is active.' } - } - - if (!windowsCapturePaused) { - return { success: true } - } - - try { - windowsCaptureProcess.stdin.write('resume\n') - windowsCapturePaused = false - return { success: true } - } catch (error) { - return { success: false, message: 'Failed to resume native Windows capture', error: String(error) } - } - } - - if (process.platform !== 'darwin') { - return { success: false, message: 'Native screen recording is only available on macOS.' } - } - - if (!nativeScreenRecordingActive || !nativeCaptureProcess) { - return { success: false, message: 'No native screen recording is active.' } - } - - if (!nativeCapturePaused) { - return { success: true } - } - - try { - nativeCaptureProcess.stdin.write('resume\n') - nativeCapturePaused = false - return { success: true } - } catch (error) { - return { success: false, message: 'Failed to resume native screen recording', error: String(error) } - } - }) - - ipcMain.handle('get-system-cursor-assets', async () => { - try { - return { success: true, cursors: await getSystemCursorAssets() } - } catch (error) { - console.error('Failed to load system cursor assets:', error) - return { success: false, cursors: {}, error: String(error) } - } - }) - - ipcMain.handle('is-native-windows-capture-available', async () => { - return { available: await isNativeWindowsCaptureAvailable() } - }) - - ipcMain.handle('get-last-native-capture-diagnostics', async () => { - return { success: true, diagnostics: lastNativeCaptureDiagnostics } - }) - - ipcMain.handle('get-video-audio-fallback-paths', async (_event, videoPath: string) => { - if (!videoPath) { - return { success: true, paths: [] } - } - - try { - return { success: true, paths: await getCompanionAudioFallbackPaths(videoPath) } - } catch (error) { - console.error('Failed to resolve companion audio fallback paths:', error) - return { success: false, paths: [], error: String(error) } - } - }) - - ipcMain.handle('mux-native-windows-recording', async (_event, pauseSegments?: PauseSegment[]) => { - const videoPath = windowsPendingVideoPath - windowsPendingVideoPath = null - - if (!videoPath) { - return { success: false, message: 'No native Windows video pending for mux' } - } - - try { - if (windowsSystemAudioPath || windowsMicAudioPath) { - await muxNativeWindowsVideoWithAudio(videoPath, windowsSystemAudioPath, windowsMicAudioPath, pauseSegments ?? []) - windowsSystemAudioPath = null - windowsMicAudioPath = null - } - - recordNativeCaptureDiagnostics({ - backend: 'windows-wgc', - phase: 'mux', - outputPath: videoPath, - fileSizeBytes: await getFileSizeIfPresent(videoPath), - }) - return await finalizeStoredVideo(videoPath) - } catch (error) { - console.error('Failed to mux native Windows recording:', error) - recordNativeCaptureDiagnostics({ - backend: 'windows-wgc', - phase: 'mux', - outputPath: videoPath, - systemAudioPath: windowsSystemAudioPath, - microphonePath: windowsMicAudioPath, - fileSizeBytes: await getFileSizeIfPresent(videoPath), - error: String(error), - }) - windowsSystemAudioPath = null - windowsMicAudioPath = null - try { - return await finalizeStoredVideo(videoPath) - } catch { - return { success: false, message: 'Failed to mux native Windows recording', error: String(error) } - } - } - }) - - ipcMain.handle('start-ffmpeg-recording', async (_, source: SelectedSource) => { - if (ffmpegCaptureProcess) { - return { success: false, message: 'An FFmpeg recording is already active.' } - } - - try { - const recordingsDir = await getRecordingsDir() - const ffmpegPath = getFfmpegBinaryPath() - const outputPath = path.join(recordingsDir, `recording-${Date.now()}.mp4`) - const args = await buildFfmpegCaptureArgs(source, outputPath) - - ffmpegCaptureOutputBuffer = '' - ffmpegCaptureTargetPath = outputPath - ffmpegCaptureProcess = spawn(ffmpegPath, args, { - cwd: recordingsDir, - stdio: ['pipe', 'pipe', 'pipe'], - }) - - ffmpegCaptureProcess.stdout.on('data', (chunk: Buffer) => { - ffmpegCaptureOutputBuffer += chunk.toString() - }) - ffmpegCaptureProcess.stderr.on('data', (chunk: Buffer) => { - ffmpegCaptureOutputBuffer += chunk.toString() - }) - - await waitForFfmpegCaptureStart(ffmpegCaptureProcess) - ffmpegScreenRecordingActive = true - return { success: true } - } catch (error) { - console.error('Failed to start FFmpeg recording:', error) - ffmpegScreenRecordingActive = false - ffmpegCaptureProcess = null - ffmpegCaptureTargetPath = null - return { - success: false, - message: 'Failed to start FFmpeg recording', - error: String(error), - } - } - }) - - ipcMain.handle('stop-ffmpeg-recording', async () => { - if (!ffmpegScreenRecordingActive) { - return { success: false, message: 'No FFmpeg recording is active.' } - } - - try { - if (!ffmpegCaptureProcess || !ffmpegCaptureTargetPath) { - throw new Error('FFmpeg process is not running') - } - - const process = ffmpegCaptureProcess - const outputPath = ffmpegCaptureTargetPath - process.stdin.write('q\n') - const finalVideoPath = await waitForFfmpegCaptureStop(process, outputPath) - - ffmpegCaptureProcess = null - ffmpegCaptureTargetPath = null - ffmpegScreenRecordingActive = false - - return await finalizeStoredVideo(finalVideoPath) - } catch (error) { - console.error('Failed to stop FFmpeg recording:', error) - ffmpegCaptureProcess = null - ffmpegCaptureTargetPath = null - ffmpegScreenRecordingActive = false - return { - success: false, - message: 'Failed to stop FFmpeg recording', - error: String(error), - } - } - }) - - - - ipcMain.handle('store-recorded-video', async (_, videoData: ArrayBuffer, fileName: string) => { - try { - const recordingsDir = await getRecordingsDir() - const videoPath = path.join(recordingsDir, fileName) - await fs.writeFile(videoPath, Buffer.from(videoData)) - return await finalizeStoredVideo(videoPath) - } catch (error) { - console.error('Failed to store video:', error) - return { - success: false, - message: 'Failed to store video', - error: String(error) - } - } - }) - - - - ipcMain.handle('get-recorded-video-path', async () => { - try { - const recordingsDir = await getRecordingsDir() - const files = await fs.readdir(recordingsDir) - const videoFiles = files.filter(file => /\.(webm|mov|mp4)$/i.test(file)) - - if (videoFiles.length === 0) { - return { success: false, message: 'No recorded video found' } - } - - const latestVideo = videoFiles.sort().reverse()[0] - const videoPath = path.join(recordingsDir, latestVideo) - - return { success: true, path: videoPath } - } catch (error) { - console.error('Failed to get video path:', error) - return { success: false, message: 'Failed to get video path', error: String(error) } - } - }) - - ipcMain.handle('set-recording-state', (_, recording: boolean) => { - if (recording) { - stopCursorCapture() - stopInteractionCapture() - startWindowBoundsCapture() - void startNativeCursorMonitor() - isCursorCaptureActive = true - activeCursorSamples = [] - pendingCursorSamples = [] - cursorCaptureStartTimeMs = Date.now() - linuxCursorScreenPoint = null - lastLeftClick = null - sampleCursorPoint() - cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS) - void startInteractionCapture() - } else { - isCursorCaptureActive = false - stopCursorCapture() - stopInteractionCapture() - stopWindowBoundsCapture() - stopNativeCursorMonitor() - showCursor() - linuxCursorScreenPoint = null - snapshotCursorTelemetryForPersistence() - activeCursorSamples = [] - } - - const source = selectedSource || { name: 'Screen' } - BrowserWindow.getAllWindows().forEach((window) => { - if (!window.isDestroyed()) { - window.webContents.send('recording-state-changed', { - recording, - sourceName: source.name, - }) - } - }) - - if (onRecordingStateChange) { - onRecordingStateChange(recording, source.name) - } - }) - - ipcMain.handle('get-cursor-telemetry', async (_, videoPath?: string) => { - const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath) - if (!targetVideoPath) { - return { success: true, samples: [] } - } - - const telemetryPath = getTelemetryPathForVideo(targetVideoPath) - try { - const content = await fs.readFile(telemetryPath, 'utf-8') - const parsed = JSON.parse(content) - const rawSamples = Array.isArray(parsed) - ? parsed - : (Array.isArray(parsed?.samples) ? parsed.samples : []) - - const samples: CursorTelemetryPoint[] = rawSamples - .filter((sample: unknown) => Boolean(sample && typeof sample === 'object')) - .map((sample: unknown) => { - const point = sample as Partial - return { - timeMs: typeof point.timeMs === 'number' && Number.isFinite(point.timeMs) ? Math.max(0, point.timeMs) : 0, - cx: typeof point.cx === 'number' && Number.isFinite(point.cx) ? clamp(point.cx, 0, 1) : 0.5, - cy: typeof point.cy === 'number' && Number.isFinite(point.cy) ? clamp(point.cy, 0, 1) : 0.5, - interactionType: point.interactionType === 'click' - || point.interactionType === 'double-click' - || point.interactionType === 'right-click' - || point.interactionType === 'middle-click' - || point.interactionType === 'move' - || point.interactionType === 'mouseup' - ? point.interactionType - : undefined, - cursorType: point.cursorType === 'arrow' - || point.cursorType === 'text' - || point.cursorType === 'pointer' - || point.cursorType === 'crosshair' - || point.cursorType === 'open-hand' - || point.cursorType === 'closed-hand' - || point.cursorType === 'resize-ew' - || point.cursorType === 'resize-ns' - || point.cursorType === 'not-allowed' - ? point.cursorType - : undefined, - } - }) - .sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs) - - return { success: true, samples } - } catch (error) { - const nodeError = error as NodeJS.ErrnoException - if (nodeError.code === 'ENOENT') { - return { success: true, samples: [] } - } - console.error('Failed to load cursor telemetry:', error) - return { success: false, message: 'Failed to load cursor telemetry', error: String(error), samples: [] } - } - }) - - - ipcMain.handle('open-external-url', async (_, url: string) => { - try { - 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 } - } - - 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 } - } - - 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' } - } - - 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 } - } - - 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 } - } - - 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) } - } - }) - - // 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(/^\/+/, '') - - 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) - - 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 { - const data = await fs.readFile(filePath) - return { success: true, data } - } catch (error) { - console.error('Failed to read local file:', error) - return { success: false, error: String(error) } - } - }) - - ipcMain.handle('save-exported-video', async (event, videoData: ArrayBuffer, fileName: string) => { - try { - // Determine file type from extension - const isGif = fileName.toLowerCase().endsWith('.gif'); - const filters = isGif - ? [{ name: 'GIF Image', extensions: ['gif'] }] - : [{ name: 'MP4 Video', extensions: ['mp4'] }]; - const parentWindow = BrowserWindow.fromWebContents(event.sender) - const saveDialogOptions: SaveDialogOptions = { - title: isGif ? 'Save Exported GIF' : 'Save Exported Video', - defaultPath: path.join(app.getPath('downloads'), fileName), - filters, - properties: ['createDirectory', 'showOverwriteConfirmation'], - } - - const result = parentWindow - ? await dialog.showSaveDialog(parentWindow, saveDialogOptions) - : await dialog.showSaveDialog(saveDialogOptions) - - if (result.canceled || !result.filePath) { - return { - success: false, - canceled: true, - message: 'Export canceled' - }; - } - - await fs.writeFile(result.filePath, Buffer.from(videoData)); - - return { - success: true, - path: result.filePath, - message: 'Video exported successfully' - }; - } catch (error) { - console.error('Failed to save exported video:', error) - return { - success: false, - message: 'Failed to save exported video', - error: String(error) - } - } - }) - - ipcMain.handle('open-video-file-picker', async () => { - try { - const recordingsDir = await getRecordingsDir() - const result = await dialog.showOpenDialog({ - title: 'Select Video File', - defaultPath: recordingsDir, - filters: [ - { name: 'Video Files', extensions: ['webm', 'mp4', 'mov', 'avi', 'mkv'] }, - { name: 'All Files', extensions: ['*'] } - ], - properties: ['openFile'] - }); - - if (result.canceled || result.filePaths.length === 0) { - return { success: false, canceled: true }; - } - - currentProjectPath = null - return { - success: true, - path: result.filePaths[0] - }; - } catch (error) { - console.error('Failed to open file picker:', error); - return { - success: false, - message: 'Failed to open file picker', - error: String(error) - }; - } - }); - - ipcMain.handle('open-audio-file-picker', async () => { - try { - const result = await dialog.showOpenDialog({ - title: 'Select Audio File', - filters: [ - { name: 'Audio Files', extensions: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg'] }, - { name: 'All Files', extensions: ['*'] } - ], - properties: ['openFile'] - }); - - if (result.canceled || result.filePaths.length === 0) { - return { success: false, canceled: true }; - } - - return { - success: true, - path: result.filePaths[0] - }; - } catch (error) { - console.error('Failed to open audio file picker:', error); - return { - success: false, - message: 'Failed to open audio file picker', - error: String(error) - }; - } - }); - - ipcMain.handle('open-whisper-executable-picker', async () => { - try { - const result = await dialog.showOpenDialog({ - title: 'Select Whisper Executable', - filters: [ - { name: 'Executables', extensions: process.platform === 'win32' ? ['exe', 'cmd', 'bat'] : ['*'] }, - { name: 'All Files', extensions: ['*'] }, - ], - properties: ['openFile'], - }) - - if (result.canceled || result.filePaths.length === 0) { - return { success: false, canceled: true } - } - - return { success: true, path: result.filePaths[0] } - } catch (error) { - console.error('Failed to open Whisper executable picker:', error) - return { success: false, error: String(error) } - } - }) - - ipcMain.handle('open-whisper-model-picker', async () => { - try { - const result = await dialog.showOpenDialog({ - title: 'Select Whisper Model', - filters: [ - { name: 'Whisper Models', extensions: ['bin'] }, - { name: 'All Files', extensions: ['*'] }, - ], - properties: ['openFile'], - }) - - if (result.canceled || result.filePaths.length === 0) { - return { success: false, canceled: true } - } - - return { success: true, path: result.filePaths[0] } - } catch (error) { - console.error('Failed to open Whisper model picker:', error) - return { success: false, error: String(error) } - } - }) - - ipcMain.handle('get-whisper-small-model-status', async () => { - try { - return await getWhisperSmallModelStatus() - } catch (error) { - return { success: false, exists: false, path: null, error: String(error) } - } - }) - - ipcMain.handle('download-whisper-small-model', async (event) => { - try { - const existing = await getWhisperSmallModelStatus() - if (existing.exists) { - sendWhisperModelDownloadProgress(event.sender, { - status: 'downloaded', - progress: 100, - path: existing.path, - }) - return { success: true, path: existing.path, alreadyDownloaded: true } - } - - const modelPath = await downloadWhisperSmallModel(event.sender) - return { success: true, path: modelPath } - } catch (error) { - console.error('Failed to download Whisper small model:', error) - return { success: false, error: String(error) } - } - }) - - ipcMain.handle('delete-whisper-small-model', async (event) => { - try { - await deleteWhisperSmallModel() - sendWhisperModelDownloadProgress(event.sender, { - status: 'idle', - progress: 0, - path: null, - }) - return { success: true } - } catch (error) { - console.error('Failed to delete Whisper small model:', error) - return { success: false, error: String(error) } - } - }) - - ipcMain.handle('generate-auto-captions', async (_, options: { - videoPath: string - whisperExecutablePath: string - whisperModelPath: string - language?: string - }) => { - try { - const result = await generateAutoCaptionsFromVideo(options) - return { - success: true, - cues: result.cues, - message: result.audioSourceLabel === 'recording' - ? `Generated ${result.cues.length} caption cues.` - : `Generated ${result.cues.length} caption cues from the ${result.audioSourceLabel}.`, - } - } catch (error) { - console.error('Failed to generate auto captions:', error) - return { - success: false, - error: String(error), - message: 'Failed to generate auto captions', - } - } - }) - - 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.' }; - } - - 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('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 } - } - - 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' } - } - }) - - ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string, thumbnailDataUrl?: string | null) => { - try { - const projectsDir = await getProjectsDir() - const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath) - ? existingProjectPath - : null - - if (trustedExistingProjectPath) { - await fs.writeFile(trustedExistingProjectPath, JSON.stringify(projectData, null, 2), 'utf-8') - currentProjectPath = trustedExistingProjectPath - await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl) - await rememberRecentProject(trustedExistingProjectPath) - return { - success: true, - path: trustedExistingProjectPath, - message: 'Project saved successfully' - } - } - - const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, '_') - const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`) - ? safeName - : `${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'] - }) - - if (result.canceled || !result.filePath) { - return { - success: false, - canceled: true, - message: 'Save project canceled' - } - } - - await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), 'utf-8') - currentProjectPath = result.filePath - await saveProjectThumbnail(result.filePath, thumbnailDataUrl) - await rememberRecentProject(result.filePath) - - return { - success: true, - path: result.filePath, - 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('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' } - } - - 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' } - } - - 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('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-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) => { - currentVideoPath = normalizeVideoSourcePath(path) ?? path - const resolvedSession = await resolveRecordingSession(currentVideoPath) - ?? { - videoPath: currentVideoPath, - webcamPath: null, - timeOffsetMs: 0, - } - - currentRecordingSession = resolvedSession - - if (resolvedSession.webcamPath) { - await persistRecordingSessionManifest(resolvedSession) - } - - currentProjectPath = null - return { success: true, webcamPath: resolvedSession.webcamPath ?? null } - }) - - ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }) => { - const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath - currentVideoPath = normalizedVideoPath - currentRecordingSession = { - videoPath: normalizedVideoPath, - webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null), - timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), - } - currentProjectPath = null - await persistRecordingSessionManifest(currentRecordingSession) - return { success: true } - }) - - ipcMain.handle('get-current-recording-session', () => { - if (!currentRecordingSession) { - return { success: false } - } - - return { - success: true, - session: currentRecordingSession, - } - }) - - ipcMain.handle('get-current-video-path', () => { - return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false }; - }); - - ipcMain.handle('clear-current-video-path', () => { - currentVideoPath = null; - currentRecordingSession = null; - return { success: true }; - }); - - ipcMain.handle('delete-recording-file', async (_, filePath: string) => { - try { - if (!filePath || !isAutoRecordingPath(filePath)) { - return { success: false, error: 'Only auto-generated recordings can be deleted' }; - } - await fs.unlink(filePath); - // Also delete the cursor telemetry sidecar if it exists - const telemetryPath = getTelemetryPathForVideo(filePath); - await fs.unlink(telemetryPath).catch(() => {}); - if (currentVideoPath === filePath) { - currentVideoPath = null; - currentRecordingSession = null; - } - return { success: true }; - } catch (error) { - return { success: false, error: String(error) }; - } - }); - - ipcMain.handle('app:getVersion', () => { - return app.getVersion() - }) - - ipcMain.handle('get-platform', () => { - return process.platform; - }); - - // --------------------------------------------------------------------------- - // Cursor hiding for the browser-capture fallback. - // The IPC promise resolves only after the cursor hide attempt completes. - // --------------------------------------------------------------------------- - ipcMain.handle('hide-cursor', () => { - if (process.platform !== 'win32') { - return { success: true } - } - - return { success: hideCursor() } - }) - - ipcMain.handle('get-shortcuts', async () => { - try { - const data = await fs.readFile(SHORTCUTS_FILE, 'utf-8'); - return JSON.parse(data); - } catch { - return null; - } - }); - - ipcMain.handle('save-shortcuts', async (_, shortcuts: unknown) => { - try { - await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), 'utf-8'); - return { success: true }; - } catch (error) { - console.error('Failed to save shortcuts:', error); - return { success: false, error: String(error) }; - } - }); - - // --------------------------------------------------------------------------- - // Countdown timer before recording - // --------------------------------------------------------------------------- - ipcMain.handle('get-recording-preferences', async () => { - try { - const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, 'utf-8') - const parsed = JSON.parse(content) as Record - return { - success: true, - microphoneEnabled: parsed.microphoneEnabled === true, - microphoneDeviceId: typeof parsed.microphoneDeviceId === 'string' ? parsed.microphoneDeviceId : undefined, - systemAudioEnabled: parsed.systemAudioEnabled !== false, - } - } catch { - return { success: true, microphoneEnabled: false, microphoneDeviceId: undefined, systemAudioEnabled: true } - } - }) - - ipcMain.handle('set-recording-preferences', async (_, prefs: { microphoneEnabled?: boolean; microphoneDeviceId?: string; systemAudioEnabled?: boolean }) => { - try { - let existing: Record = {} - try { - const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, 'utf-8') - existing = JSON.parse(content) as Record - } catch { - // file doesn't exist yet - } - const merged = { ...existing, ...prefs } - await fs.writeFile(RECORDINGS_SETTINGS_FILE, JSON.stringify(merged, null, 2), 'utf-8') - return { success: true } - } catch (error) { - console.error('Failed to save recording preferences:', error) - return { success: false, error: String(error) } - } - }) - - ipcMain.handle('get-countdown-delay', async () => { - try { - const content = await fs.readFile(COUNTDOWN_SETTINGS_FILE, 'utf-8') - const parsed = JSON.parse(content) as { delay?: number } - return { success: true, delay: parsed.delay ?? 3 } - } catch { - return { success: true, delay: 3 } - } - }) - - ipcMain.handle('set-countdown-delay', async (_, delay: number) => { - try { - await fs.writeFile(COUNTDOWN_SETTINGS_FILE, JSON.stringify({ delay }, null, 2), 'utf-8') - return { success: true } - } catch (error) { - console.error('Failed to save countdown delay:', error) - return { success: false, error: String(error) } - } - }) - - ipcMain.handle('start-countdown', async (_, seconds: number) => { - if (countdownInProgress) { - return { success: false, error: 'Countdown already in progress' } - } - - countdownInProgress = true - countdownCancelled = false - countdownRemaining = seconds - - const countdownWin = createCountdownWindow() - - if (countdownWin.webContents.isLoadingMainFrame()) { - await new Promise((resolve) => { - countdownWin.webContents.once('did-finish-load', () => { - resolve() - }) - }) - } - - return new Promise<{ success: boolean; cancelled?: boolean }>((resolve) => { - let remaining = seconds - countdownRemaining = remaining - - countdownWin.webContents.send('countdown-tick', remaining) - - countdownTimer = setInterval(() => { - if (countdownCancelled) { - if (countdownTimer) { - clearInterval(countdownTimer) - countdownTimer = null - } - closeCountdownWindow() - countdownInProgress = false - countdownRemaining = null - resolve({ success: false, cancelled: true }) - return - } - - remaining-- - countdownRemaining = remaining - - if (remaining <= 0) { - if (countdownTimer) { - clearInterval(countdownTimer) - countdownTimer = null - } - closeCountdownWindow() - countdownInProgress = false - countdownRemaining = null - resolve({ success: true }) - } else { - const win = getCountdownWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('countdown-tick', remaining) - } - } - }, 1000) - }) - }) - - ipcMain.handle('cancel-countdown', () => { - countdownCancelled = true - countdownInProgress = false - countdownRemaining = null - if (countdownTimer) { - clearInterval(countdownTimer) - countdownTimer = null - } - closeCountdownWindow() - return { success: true } - }) - - ipcMain.handle('get-active-countdown', () => { - return { - success: true, - seconds: countdownInProgress ? countdownRemaining : null, - } - }) +`; + + await highlightWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + + setTimeout(() => { + if (!highlightWin.isDestroyed()) highlightWin.close(); + }, 1700); + + 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"); + createEditorWindow(); + }); + + ipcMain.handle( + "start-native-screen-recording", + async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => { + // Windows native capture path + if (process.platform === "win32") { + const windowsCaptureAvailable = await isNativeWindowsCaptureAvailable(); + if (!windowsCaptureAvailable) { + return { + success: false, + message: "Native Windows capture is not available on this system.", + }; + } + + if (windowsCaptureProcess && !windowsNativeCaptureActive) { + try { + windowsCaptureProcess.kill(); + } catch { + /* ignore */ + } + windowsCaptureProcess = null; + windowsCaptureTargetPath = null; + windowsCaptureStopRequested = false; + } + + if (windowsCaptureProcess) { + return { + success: false, + message: "A native Windows screen recording is already active.", + }; + } + + try { + const exePath = getWindowsCaptureExePath(); + const recordingsDir = await getRecordingsDir(); + const timestamp = Date.now(); + const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`); + const displayBounds = source?.id?.startsWith("window:") + ? null + : getDisplayBoundsForSource(source); + + const config: Record = { + outputPath, + fps: 60, + }; + + if (options?.capturesSystemAudio) { + const audioPath = path.join(recordingsDir, `recording-${timestamp}.system.wav`); + config.captureSystemAudio = true; + config.audioOutputPath = audioPath; + windowsSystemAudioPath = audioPath; + } + + if (options?.capturesMicrophone) { + const micPath = path.join(recordingsDir, `recording-${timestamp}.mic.wav`); + config.captureMic = true; + config.micOutputPath = micPath; + if (options.microphoneLabel) { + config.micDeviceName = options.microphoneLabel; + } + windowsMicAudioPath = micPath; + } + + const windowId = parseWindowId(source?.id); + if (windowId && source?.id?.startsWith("window:")) { + config.windowHandle = windowId; + } else { + const resolvedDisplay = resolveWindowsCaptureDisplay( + source, + getScreen().getAllDisplays(), + getScreen().getPrimaryDisplay(), + ); + config.displayId = resolvedDisplay.displayId; + + // Monitor handle IDs can drift across Electron/Windows capture boundaries, + // so also provide display bounds for a coordinate-based native fallback. + config.displayX = Math.round(resolvedDisplay.bounds.x); + config.displayY = Math.round(resolvedDisplay.bounds.y); + config.displayW = Math.round(resolvedDisplay.bounds.width); + config.displayH = Math.round(resolvedDisplay.bounds.height); + } + + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "start", + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? "unknown", + displayId: typeof config.displayId === "number" ? config.displayId : null, + displayBounds, + windowHandle: typeof config.windowHandle === "number" ? config.windowHandle : null, + helperPath: exePath, + outputPath, + systemAudioPath: windowsSystemAudioPath, + microphonePath: windowsMicAudioPath, + }); + + windowsCaptureOutputBuffer = ""; + windowsCaptureTargetPath = outputPath; + windowsCaptureStopRequested = false; + windowsCapturePaused = false; + windowsCaptureProcess = spawn(exePath, [JSON.stringify(config)], { + cwd: recordingsDir, + stdio: ["pipe", "pipe", "pipe"], + }); + attachWindowsCaptureLifecycle(windowsCaptureProcess); + + windowsCaptureProcess.stdout.on("data", (chunk: Buffer) => { + windowsCaptureOutputBuffer += chunk.toString(); + }); + windowsCaptureProcess.stderr.on("data", (chunk: Buffer) => { + windowsCaptureOutputBuffer += chunk.toString(); + }); + + await waitForWindowsCaptureStart(windowsCaptureProcess); + windowsNativeCaptureActive = true; + nativeScreenRecordingActive = true; + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "start", + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? "unknown", + displayId: typeof config.displayId === "number" ? config.displayId : null, + displayBounds, + windowHandle: typeof config.windowHandle === "number" ? config.windowHandle : null, + helperPath: exePath, + outputPath, + systemAudioPath: windowsSystemAudioPath, + microphonePath: windowsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + }); + return { success: true }; + } catch (error) { + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "start", + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? "unknown", + helperPath: windowsCaptureTargetPath ? getWindowsCaptureExePath() : null, + outputPath: windowsCaptureTargetPath, + systemAudioPath: windowsSystemAudioPath, + microphonePath: windowsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + error: String(error), + }); + console.error("Failed to start native Windows capture:", error); + try { + windowsCaptureProcess?.kill(); + } catch { + /* ignore */ + } + windowsNativeCaptureActive = false; + nativeScreenRecordingActive = false; + windowsCaptureProcess = null; + windowsCaptureTargetPath = null; + windowsCaptureStopRequested = false; + windowsCapturePaused = false; + return { + success: false, + message: "Failed to start native Windows capture", + error: String(error), + }; + } + } + + if (process.platform !== "darwin") { + return { success: false, message: "Native screen recording is only available on macOS." }; + } + + if (nativeCaptureProcess && !nativeScreenRecordingActive) { + try { + nativeCaptureProcess.kill(); + } catch { + // ignore stale helper cleanup failures + } + nativeCaptureProcess = null; + nativeCaptureTargetPath = null; + nativeCaptureStopRequested = false; + } + + if (nativeCaptureProcess) { + return { success: false, message: "A native screen recording is already active." }; + } + + try { + const recordingsDir = await getRecordingsDir(); + + // Warm up TCC: trigger an Electron-level screen capture API call so macOS + // activates the screen-recording grant for this process tree before the + // native helper binary spawns and calls SCStream.startCapture(). + try { + await desktopCapturer.getSources({ + types: ["screen"], + thumbnailSize: { width: 1, height: 1 }, + }); + } catch { + // non-fatal – the helper will report its own TCC status + } + + // Ensure microphone TCC is granted for this process tree when mic capture + // is requested, so the child helper inherits the grant. + if (options?.capturesMicrophone) { + const micStatus = systemPreferences.getMediaAccessStatus("microphone"); + if (micStatus !== "granted") { + await systemPreferences.askForMediaAccess("microphone"); + } + } + + const appName = normalizeDesktopSourceName(String(source?.appName ?? "")); + const ownAppName = normalizeDesktopSourceName(app.getName()); + if ( + !ALLOW_RECORDLY_WINDOW_CAPTURE && + source?.id?.startsWith("window:") && + appName && + (appName === ownAppName || appName === "recordly") + ) { + return { + success: false, + message: "Cannot record Recordly windows. Please select another app window.", + }; + } + + const helperPath = await ensureNativeCaptureHelperBinary(); + const timestamp = Date.now(); + const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`); + const capturesSystemAudio = Boolean(options?.capturesSystemAudio); + const capturesMicrophone = Boolean(options?.capturesMicrophone); + const systemAudioOutputPath = capturesSystemAudio + ? path.join(recordingsDir, `recording-${timestamp}.system.m4a`) + : null; + const microphoneOutputPath = capturesMicrophone + ? path.join(recordingsDir, `recording-${timestamp}.mic.m4a`) + : null; + const config: Record = { + fps: 60, + outputPath, + capturesSystemAudio, + capturesMicrophone, + }; + + if (options?.microphoneDeviceId) { + config.microphoneDeviceId = options.microphoneDeviceId; + } + + if (options?.microphoneLabel) { + config.microphoneLabel = options.microphoneLabel; + } + + if (systemAudioOutputPath) { + config.systemAudioOutputPath = systemAudioOutputPath; + } + + if (microphoneOutputPath) { + config.microphoneOutputPath = microphoneOutputPath; + } + + const windowId = parseWindowId(source?.id); + const screenId = Number(source?.display_id); + + if (Number.isFinite(windowId) && windowId && source?.id?.startsWith("window:")) { + config.windowId = windowId; + } else if (Number.isFinite(screenId) && screenId > 0) { + config.displayId = screenId; + } else { + config.displayId = Number(getScreen().getPrimaryDisplay().id); + } + + nativeCaptureOutputBuffer = ""; + nativeCaptureTargetPath = outputPath; + nativeCaptureSystemAudioPath = systemAudioOutputPath; + nativeCaptureMicrophonePath = microphoneOutputPath; + nativeCaptureStopRequested = false; + nativeCapturePaused = false; + nativeCaptureProcess = spawn(helperPath, [JSON.stringify(config)], { + cwd: recordingsDir, + stdio: ["pipe", "pipe", "pipe"], + }); + attachNativeCaptureLifecycle(nativeCaptureProcess); + + nativeCaptureProcess.stdout.on("data", (chunk: Buffer) => { + nativeCaptureOutputBuffer += chunk.toString(); + }); + nativeCaptureProcess.stderr.on("data", (chunk: Buffer) => { + nativeCaptureOutputBuffer += chunk.toString(); + }); + + await waitForNativeCaptureStart(nativeCaptureProcess); + nativeScreenRecordingActive = true; + recordNativeCaptureDiagnostics({ + backend: "mac-screencapturekit", + phase: "start", + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? "unknown", + displayId: typeof config.displayId === "number" ? config.displayId : null, + helperPath, + outputPath, + systemAudioPath: systemAudioOutputPath, + microphonePath: microphoneOutputPath, + processOutput: nativeCaptureOutputBuffer.trim() || undefined, + }); + return { success: true }; + } catch (error) { + console.error("Failed to start native ScreenCaptureKit recording:", error); + const errorStr = String(error); + + // Detect TCC (screen recording permission) errors and show a helpful dialog + if ( + errorStr.includes("declined TCC") || + errorStr.includes("declined TCCs") || + errorStr.includes("SCREEN_RECORDING_PERMISSION_DENIED") + ) { + const { response } = await dialog.showMessageBox({ + type: "warning", + title: "Screen Recording Permission Required", + message: "Recordly needs screen recording permission to capture your screen.", + detail: + "Please open System Settings > Privacy & Security > Screen Recording, make sure Recordly is toggled ON, then try recording again.", + buttons: ["Open System Settings", "Cancel"], + defaultId: 0, + cancelId: 1, + }); + if (response === 0) { + await shell.openExternal(getMacPrivacySettingsUrl("screen")); + } + try { + nativeCaptureProcess?.kill(); + } catch { + /* ignore */ + } + nativeScreenRecordingActive = false; + nativeCaptureProcess = null; + nativeCaptureTargetPath = null; + nativeCaptureSystemAudioPath = null; + nativeCaptureMicrophonePath = null; + nativeCaptureStopRequested = false; + nativeCapturePaused = false; + return { + success: false, + message: + "Screen recording permission not granted. Please allow access in System Settings and restart the app.", + userNotified: true, + }; + } + + if (errorStr.includes("MICROPHONE_PERMISSION_DENIED")) { + const { response } = await dialog.showMessageBox({ + type: "warning", + title: "Microphone Permission Required", + message: "Recordly needs microphone permission to record audio.", + detail: + "Please open System Settings > Privacy & Security > Microphone, make sure Recordly is toggled ON, then try recording again.", + buttons: ["Open System Settings", "Cancel"], + defaultId: 0, + cancelId: 1, + }); + if (response === 0) { + await shell.openExternal(getMacPrivacySettingsUrl("microphone")); + } + try { + nativeCaptureProcess?.kill(); + } catch { + /* ignore */ + } + nativeScreenRecordingActive = false; + nativeCaptureProcess = null; + nativeCaptureTargetPath = null; + nativeCaptureSystemAudioPath = null; + nativeCaptureMicrophonePath = null; + nativeCaptureStopRequested = false; + nativeCapturePaused = false; + return { + success: false, + message: "Microphone permission not granted. Please allow access in System Settings.", + userNotified: true, + }; + } + + recordNativeCaptureDiagnostics({ + backend: "mac-screencapturekit", + phase: "start", + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? "unknown", + helperPath: getNativeCaptureHelperBinaryPath(), + outputPath: nativeCaptureTargetPath, + systemAudioPath: nativeCaptureSystemAudioPath, + microphonePath: nativeCaptureMicrophonePath, + processOutput: nativeCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: await getFileSizeIfPresent(nativeCaptureTargetPath), + error: String(error), + }); + try { + nativeCaptureProcess?.kill(); + } catch { + // ignore cleanup failures + } + nativeScreenRecordingActive = false; + nativeCaptureProcess = null; + nativeCaptureTargetPath = null; + nativeCaptureSystemAudioPath = null; + nativeCaptureMicrophonePath = null; + nativeCaptureStopRequested = false; + nativeCapturePaused = false; + return { + success: false, + message: "Failed to start native ScreenCaptureKit recording", + error: String(error), + }; + } + }, + ); + + ipcMain.handle("stop-native-screen-recording", async () => { + // Windows native capture stop path + if (process.platform === "win32" && windowsNativeCaptureActive) { + try { + if (!windowsCaptureProcess) { + throw new Error("Native Windows capture process is not running"); + } + + const proc = windowsCaptureProcess; + const preferredVideoPath = windowsCaptureTargetPath; + windowsCaptureStopRequested = true; + proc.stdin.write("stop\n"); + const tempVideoPath = await waitForWindowsCaptureStop(proc); + windowsCaptureProcess = null; + windowsNativeCaptureActive = false; + nativeScreenRecordingActive = false; + windowsCaptureTargetPath = null; + windowsCaptureStopRequested = false; + windowsCapturePaused = false; + + const finalVideoPath = preferredVideoPath ?? tempVideoPath; + if (tempVideoPath !== finalVideoPath) { + await moveFileWithOverwrite(tempVideoPath, finalVideoPath); + } + + windowsPendingVideoPath = finalVideoPath; + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "stop", + outputPath: finalVideoPath, + systemAudioPath: windowsSystemAudioPath, + microphonePath: windowsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: await getFileSizeIfPresent(finalVideoPath), + }); + return { success: true, path: finalVideoPath }; + } catch (error) { + console.error("Failed to stop native Windows capture:", error); + const fallbackPath = windowsCaptureTargetPath; + windowsNativeCaptureActive = false; + nativeScreenRecordingActive = false; + windowsCaptureProcess = null; + windowsCaptureTargetPath = null; + windowsCaptureStopRequested = false; + windowsCapturePaused = false; + windowsSystemAudioPath = null; + windowsMicAudioPath = null; + windowsPendingVideoPath = null; + + if (fallbackPath) { + try { + await fs.access(fallbackPath); + windowsPendingVideoPath = fallbackPath; + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: windowsSystemAudioPath, + microphonePath: windowsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: await getFileSizeIfPresent(fallbackPath), + error: String(error), + }); + return { success: true, path: fallbackPath }; + } catch { + // File doesn't exist + } + } + + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: windowsSystemAudioPath, + microphonePath: windowsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + error: String(error), + }); + + 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, + ); + nativeCaptureStopRequested = true; + process.stdin.write("stop\n"); + const tempVideoPath = await waitForNativeCaptureStop(process); + console.log("[stop-native] Helper stopped, tempVideoPath:", tempVideoPath); + nativeCaptureProcess = null; + nativeScreenRecordingActive = false; + nativeCaptureTargetPath = null; + nativeCaptureSystemAudioPath = null; + nativeCaptureMicrophonePath = null; + nativeCaptureStopRequested = false; + nativeCapturePaused = 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); + nativeScreenRecordingActive = false; + nativeCaptureProcess = null; + nativeCaptureTargetPath = null; + nativeCaptureSystemAudioPath = null; + nativeCaptureMicrophonePath = null; + nativeCaptureStopRequested = false; + nativeCapturePaused = 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); + } + } + return await finalizeStoredVideo(fallbackPath); + } catch { + // File doesn't exist or isn't accessible + } + } + + const recovered = await recoverNativeMacCaptureOutput(); + if (recovered) { + return recovered; + } + + return { + success: false, + message: "Failed to stop native ScreenCaptureKit recording", + error: String(error), + }; + } + }); + + ipcMain.handle("recover-native-screen-recording", async () => { + if (process.platform !== "darwin") { + return { + success: false, + message: "Native screen recording recovery is only available on macOS.", + }; + } + + const recovered = await recoverNativeMacCaptureOutput(); + if (recovered) { + return recovered; + } + + return { + success: false, + message: "No recoverable native macOS recording output was found.", + }; + }); + + ipcMain.handle("pause-native-screen-recording", async () => { + if (process.platform === "win32") { + if (!windowsNativeCaptureActive || !windowsCaptureProcess) { + return { success: false, message: "No native Windows screen recording is active." }; + } + + if (windowsCapturePaused) { + return { success: true }; + } + + try { + windowsCaptureProcess.stdin.write("pause\n"); + windowsCapturePaused = true; + return { success: true }; + } catch (error) { + return { + success: false, + message: "Failed to pause native Windows capture", + error: String(error), + }; + } + } + + if (process.platform !== "darwin") { + return { success: false, message: "Native screen recording is only available on macOS." }; + } + + if (!nativeScreenRecordingActive || !nativeCaptureProcess) { + return { success: false, message: "No native screen recording is active." }; + } + + if (nativeCapturePaused) { + return { success: true }; + } + + try { + nativeCaptureProcess.stdin.write("pause\n"); + nativeCapturePaused = true; + return { success: true }; + } catch (error) { + return { + success: false, + message: "Failed to pause native screen recording", + error: String(error), + }; + } + }); + + ipcMain.handle("resume-native-screen-recording", async () => { + if (process.platform === "win32") { + if (!windowsNativeCaptureActive || !windowsCaptureProcess) { + return { success: false, message: "No native Windows screen recording is active." }; + } + + if (!windowsCapturePaused) { + return { success: true }; + } + + try { + windowsCaptureProcess.stdin.write("resume\n"); + windowsCapturePaused = false; + return { success: true }; + } catch (error) { + return { + success: false, + message: "Failed to resume native Windows capture", + error: String(error), + }; + } + } + + if (process.platform !== "darwin") { + return { success: false, message: "Native screen recording is only available on macOS." }; + } + + if (!nativeScreenRecordingActive || !nativeCaptureProcess) { + return { success: false, message: "No native screen recording is active." }; + } + + if (!nativeCapturePaused) { + return { success: true }; + } + + try { + nativeCaptureProcess.stdin.write("resume\n"); + nativeCapturePaused = false; + return { success: true }; + } catch (error) { + return { + success: false, + message: "Failed to resume native screen recording", + error: String(error), + }; + } + }); + + ipcMain.handle("get-system-cursor-assets", async () => { + try { + return { success: true, cursors: await getSystemCursorAssets() }; + } catch (error) { + console.error("Failed to load system cursor assets:", error); + return { success: false, cursors: {}, error: String(error) }; + } + }); + + ipcMain.handle("is-native-windows-capture-available", async () => { + return { available: await isNativeWindowsCaptureAvailable() }; + }); + + ipcMain.handle("get-last-native-capture-diagnostics", async () => { + return { success: true, diagnostics: lastNativeCaptureDiagnostics }; + }); + + ipcMain.handle("get-video-audio-fallback-paths", async (_event, videoPath: string) => { + if (!videoPath) { + return { success: true, paths: [] }; + } + + try { + return { success: true, paths: await getCompanionAudioFallbackPaths(videoPath) }; + } catch (error) { + console.error("Failed to resolve companion audio fallback paths:", error); + return { success: false, paths: [], error: String(error) }; + } + }); + + ipcMain.handle("mux-native-windows-recording", async (_event, pauseSegments?: PauseSegment[]) => { + const videoPath = windowsPendingVideoPath; + windowsPendingVideoPath = null; + + if (!videoPath) { + return { success: false, message: "No native Windows video pending for mux" }; + } + + try { + if (windowsSystemAudioPath || windowsMicAudioPath) { + await muxNativeWindowsVideoWithAudio( + videoPath, + windowsSystemAudioPath, + windowsMicAudioPath, + pauseSegments ?? [], + ); + windowsSystemAudioPath = null; + windowsMicAudioPath = null; + } + + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "mux", + outputPath: videoPath, + fileSizeBytes: await getFileSizeIfPresent(videoPath), + }); + return await finalizeStoredVideo(videoPath); + } catch (error) { + console.error("Failed to mux native Windows recording:", error); + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "mux", + outputPath: videoPath, + systemAudioPath: windowsSystemAudioPath, + microphonePath: windowsMicAudioPath, + fileSizeBytes: await getFileSizeIfPresent(videoPath), + error: String(error), + }); + windowsSystemAudioPath = null; + windowsMicAudioPath = null; + try { + return await finalizeStoredVideo(videoPath); + } catch { + return { + success: false, + message: "Failed to mux native Windows recording", + error: String(error), + }; + } + } + }); + + ipcMain.handle("start-ffmpeg-recording", async (_, source: SelectedSource) => { + if (ffmpegCaptureProcess) { + return { success: false, message: "An FFmpeg recording is already active." }; + } + + try { + const recordingsDir = await getRecordingsDir(); + const ffmpegPath = getFfmpegBinaryPath(); + const outputPath = path.join(recordingsDir, `recording-${Date.now()}.mp4`); + const args = await buildFfmpegCaptureArgs(source, outputPath); + + ffmpegCaptureOutputBuffer = ""; + ffmpegCaptureTargetPath = outputPath; + ffmpegCaptureProcess = spawn(ffmpegPath, args, { + cwd: recordingsDir, + stdio: ["pipe", "pipe", "pipe"], + }); + + ffmpegCaptureProcess.stdout.on("data", (chunk: Buffer) => { + ffmpegCaptureOutputBuffer += chunk.toString(); + }); + ffmpegCaptureProcess.stderr.on("data", (chunk: Buffer) => { + ffmpegCaptureOutputBuffer += chunk.toString(); + }); + + await waitForFfmpegCaptureStart(ffmpegCaptureProcess); + ffmpegScreenRecordingActive = true; + return { success: true }; + } catch (error) { + console.error("Failed to start FFmpeg recording:", error); + ffmpegScreenRecordingActive = false; + ffmpegCaptureProcess = null; + ffmpegCaptureTargetPath = null; + return { + success: false, + message: "Failed to start FFmpeg recording", + error: String(error), + }; + } + }); + + ipcMain.handle("stop-ffmpeg-recording", async () => { + if (!ffmpegScreenRecordingActive) { + return { success: false, message: "No FFmpeg recording is active." }; + } + + try { + if (!ffmpegCaptureProcess || !ffmpegCaptureTargetPath) { + throw new Error("FFmpeg process is not running"); + } + + const process = ffmpegCaptureProcess; + const outputPath = ffmpegCaptureTargetPath; + process.stdin.write("q\n"); + const finalVideoPath = await waitForFfmpegCaptureStop(process, outputPath); + + ffmpegCaptureProcess = null; + ffmpegCaptureTargetPath = null; + ffmpegScreenRecordingActive = false; + + return await finalizeStoredVideo(finalVideoPath); + } catch (error) { + console.error("Failed to stop FFmpeg recording:", error); + ffmpegCaptureProcess = null; + ffmpegCaptureTargetPath = null; + ffmpegScreenRecordingActive = false; + return { + success: false, + message: "Failed to stop FFmpeg recording", + error: String(error), + }; + } + }); + + ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => { + try { + const recordingsDir = await getRecordingsDir(); + const videoPath = path.join(recordingsDir, fileName); + await fs.writeFile(videoPath, Buffer.from(videoData)); + return await finalizeStoredVideo(videoPath); + } catch (error) { + console.error("Failed to store video:", error); + return { + success: false, + message: "Failed to store video", + error: String(error), + }; + } + }); + + ipcMain.handle("get-recorded-video-path", async () => { + try { + const recordingsDir = await getRecordingsDir(); + const files = await fs.readdir(recordingsDir); + const videoFiles = files.filter((file) => /\.(webm|mov|mp4)$/i.test(file)); + + if (videoFiles.length === 0) { + return { success: false, message: "No recorded video found" }; + } + + const latestVideo = videoFiles.sort().reverse()[0]; + const videoPath = path.join(recordingsDir, latestVideo); + + return { success: true, path: videoPath }; + } catch (error) { + console.error("Failed to get video path:", error); + return { success: false, message: "Failed to get video path", error: String(error) }; + } + }); + + ipcMain.handle("set-recording-state", (_, recording: boolean) => { + if (recording) { + stopCursorCapture(); + stopInteractionCapture(); + startWindowBoundsCapture(); + void startNativeCursorMonitor(); + isCursorCaptureActive = true; + activeCursorSamples = []; + pendingCursorSamples = []; + cursorCaptureStartTimeMs = Date.now(); + linuxCursorScreenPoint = null; + lastLeftClick = null; + sampleCursorPoint(); + cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS); + void startInteractionCapture(); + } else { + isCursorCaptureActive = false; + stopCursorCapture(); + stopInteractionCapture(); + stopWindowBoundsCapture(); + stopNativeCursorMonitor(); + showCursor(); + linuxCursorScreenPoint = null; + snapshotCursorTelemetryForPersistence(); + activeCursorSamples = []; + } + + const source = selectedSource || { name: "Screen" }; + BrowserWindow.getAllWindows().forEach((window) => { + if (!window.isDestroyed()) { + window.webContents.send("recording-state-changed", { + recording, + sourceName: source.name, + }); + } + }); + + if (onRecordingStateChange) { + onRecordingStateChange(recording, source.name); + } + }); + + ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => { + const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath); + if (!targetVideoPath) { + return { success: true, samples: [] }; + } + + const telemetryPath = getTelemetryPathForVideo(targetVideoPath); + try { + const content = await fs.readFile(telemetryPath, "utf-8"); + const parsed = JSON.parse(content); + const rawSamples = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.samples) + ? parsed.samples + : []; + + const samples: CursorTelemetryPoint[] = rawSamples + .filter((sample: unknown) => Boolean(sample && typeof sample === "object")) + .map((sample: unknown) => { + const point = sample as Partial; + return { + timeMs: + typeof point.timeMs === "number" && Number.isFinite(point.timeMs) + ? Math.max(0, point.timeMs) + : 0, + cx: + typeof point.cx === "number" && Number.isFinite(point.cx) + ? clamp(point.cx, 0, 1) + : 0.5, + cy: + typeof point.cy === "number" && Number.isFinite(point.cy) + ? clamp(point.cy, 0, 1) + : 0.5, + interactionType: + point.interactionType === "click" || + point.interactionType === "double-click" || + point.interactionType === "right-click" || + point.interactionType === "middle-click" || + point.interactionType === "move" || + point.interactionType === "mouseup" + ? point.interactionType + : undefined, + cursorType: + point.cursorType === "arrow" || + point.cursorType === "text" || + point.cursorType === "pointer" || + point.cursorType === "crosshair" || + point.cursorType === "open-hand" || + point.cursorType === "closed-hand" || + point.cursorType === "resize-ew" || + point.cursorType === "resize-ns" || + point.cursorType === "not-allowed" + ? point.cursorType + : undefined, + }; + }) + .sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs); + + return { success: true, samples }; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "ENOENT") { + return { success: true, samples: [] }; + } + console.error("Failed to load cursor telemetry:", error); + return { + success: false, + message: "Failed to load cursor telemetry", + error: String(error), + samples: [], + }; + } + }); + + ipcMain.handle("open-external-url", async (_, url: string) => { + try { + 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 }; + } + + 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 }; + } + + 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" }; + } + + 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 }; + } + + 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 }; + } + + 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) }; + } + }); + + // 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(/^\/+/, ""); + + 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); + + 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 { + const data = await fs.readFile(filePath); + return { success: true, data }; + } catch (error) { + console.error("Failed to read local file:", error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("save-exported-video", async (event, videoData: ArrayBuffer, fileName: string) => { + try { + // Determine file type from extension + const isGif = fileName.toLowerCase().endsWith(".gif"); + const filters = isGif + ? [{ name: "GIF Image", extensions: ["gif"] }] + : [{ name: "MP4 Video", extensions: ["mp4"] }]; + const parentWindow = BrowserWindow.fromWebContents(event.sender); + const saveDialogOptions: SaveDialogOptions = { + title: isGif ? "Save Exported GIF" : "Save Exported Video", + defaultPath: path.join(app.getPath("downloads"), fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }; + + const result = parentWindow + ? await dialog.showSaveDialog(parentWindow, saveDialogOptions) + : await dialog.showSaveDialog(saveDialogOptions); + + if (result.canceled || !result.filePath) { + return { + success: false, + canceled: true, + message: "Export canceled", + }; + } + + await fs.writeFile(result.filePath, Buffer.from(videoData)); + + return { + success: true, + path: result.filePath, + message: "Video exported successfully", + }; + } catch (error) { + console.error("Failed to save exported video:", error); + return { + success: false, + message: "Failed to save exported video", + error: String(error), + }; + } + }); + + ipcMain.handle("open-video-file-picker", async () => { + try { + const recordingsDir = await getRecordingsDir(); + const result = await dialog.showOpenDialog({ + title: "Select Video File", + defaultPath: recordingsDir, + filters: [ + { name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] }, + { name: "All Files", extensions: ["*"] }, + ], + properties: ["openFile"], + }); + + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true }; + } + + currentProjectPath = null; + return { + success: true, + path: result.filePaths[0], + }; + } catch (error) { + console.error("Failed to open file picker:", error); + return { + success: false, + message: "Failed to open file picker", + error: String(error), + }; + } + }); + + ipcMain.handle("open-audio-file-picker", async () => { + try { + const result = await dialog.showOpenDialog({ + title: "Select Audio File", + filters: [ + { name: "Audio Files", extensions: ["mp3", "wav", "aac", "m4a", "flac", "ogg"] }, + { name: "All Files", extensions: ["*"] }, + ], + properties: ["openFile"], + }); + + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true }; + } + + return { + success: true, + path: result.filePaths[0], + }; + } catch (error) { + console.error("Failed to open audio file picker:", error); + return { + success: false, + message: "Failed to open audio file picker", + error: String(error), + }; + } + }); + + ipcMain.handle("open-whisper-executable-picker", async () => { + try { + const result = await dialog.showOpenDialog({ + title: "Select Whisper Executable", + filters: [ + { + name: "Executables", + extensions: process.platform === "win32" ? ["exe", "cmd", "bat"] : ["*"], + }, + { name: "All Files", extensions: ["*"] }, + ], + properties: ["openFile"], + }); + + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true }; + } + + return { success: true, path: result.filePaths[0] }; + } catch (error) { + console.error("Failed to open Whisper executable picker:", error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("open-whisper-model-picker", async () => { + try { + const result = await dialog.showOpenDialog({ + title: "Select Whisper Model", + filters: [ + { name: "Whisper Models", extensions: ["bin"] }, + { name: "All Files", extensions: ["*"] }, + ], + properties: ["openFile"], + }); + + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true }; + } + + return { success: true, path: result.filePaths[0] }; + } catch (error) { + console.error("Failed to open Whisper model picker:", error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("get-whisper-small-model-status", async () => { + try { + return await getWhisperSmallModelStatus(); + } catch (error) { + return { success: false, exists: false, path: null, error: String(error) }; + } + }); + + ipcMain.handle("download-whisper-small-model", async (event) => { + try { + const existing = await getWhisperSmallModelStatus(); + if (existing.exists) { + sendWhisperModelDownloadProgress(event.sender, { + status: "downloaded", + progress: 100, + path: existing.path, + }); + return { success: true, path: existing.path, alreadyDownloaded: true }; + } + + const modelPath = await downloadWhisperSmallModel(event.sender); + return { success: true, path: modelPath }; + } catch (error) { + console.error("Failed to download Whisper small model:", error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("delete-whisper-small-model", async (event) => { + try { + await deleteWhisperSmallModel(); + sendWhisperModelDownloadProgress(event.sender, { + status: "idle", + progress: 0, + path: null, + }); + return { success: true }; + } catch (error) { + console.error("Failed to delete Whisper small model:", error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle( + "generate-auto-captions", + async ( + _, + options: { + videoPath: string; + whisperExecutablePath: string; + whisperModelPath: string; + language?: string; + }, + ) => { + try { + const result = await generateAutoCaptionsFromVideo(options); + return { + success: true, + cues: result.cues, + message: + result.audioSourceLabel === "recording" + ? `Generated ${result.cues.length} caption cues.` + : `Generated ${result.cues.length} caption cues from the ${result.audioSourceLabel}.`, + }; + } catch (error) { + console.error("Failed to generate auto captions:", error); + return { + success: false, + error: String(error), + message: "Failed to generate auto captions", + }; + } + }, + ); + + 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.", + }; + } + + 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("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 }; + } + + 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" }; + } + }); + + ipcMain.handle( + "save-project-file", + async ( + _, + projectData: unknown, + suggestedName?: string, + existingProjectPath?: string, + thumbnailDataUrl?: string | null, + ) => { + try { + const projectsDir = await getProjectsDir(); + const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath) + ? existingProjectPath + : null; + + if (trustedExistingProjectPath) { + await fs.writeFile( + trustedExistingProjectPath, + JSON.stringify(projectData, null, 2), + "utf-8", + ); + currentProjectPath = trustedExistingProjectPath; + await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl); + await rememberRecentProject(trustedExistingProjectPath); + return { + success: true, + path: trustedExistingProjectPath, + message: "Project saved successfully", + }; + } + + const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, "_"); + const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`) + ? safeName + : `${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"], + }); + + if (result.canceled || !result.filePath) { + return { + success: false, + canceled: true, + message: "Save project canceled", + }; + } + + await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), "utf-8"); + currentProjectPath = result.filePath; + await saveProjectThumbnail(result.filePath, thumbnailDataUrl); + await rememberRecentProject(result.filePath); + + return { + success: true, + path: result.filePath, + 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("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" }; + } + + 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" }; + } + + 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("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-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) => { + currentVideoPath = normalizeVideoSourcePath(path) ?? path; + const resolvedSession = (await resolveRecordingSession(currentVideoPath)) ?? { + videoPath: currentVideoPath, + webcamPath: null, + timeOffsetMs: 0, + }; + + currentRecordingSession = resolvedSession; + + if (resolvedSession.webcamPath) { + await persistRecordingSessionManifest(resolvedSession); + } + + currentProjectPath = null; + return { success: true, webcamPath: resolvedSession.webcamPath ?? null }; + }); + + ipcMain.handle( + "set-current-recording-session", + async ( + _, + session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }, + ) => { + const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath; + currentVideoPath = normalizedVideoPath; + currentRecordingSession = { + videoPath: normalizedVideoPath, + webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null), + timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), + }; + currentProjectPath = null; + await persistRecordingSessionManifest(currentRecordingSession); + return { success: true }; + }, + ); + + ipcMain.handle("get-current-recording-session", () => { + if (!currentRecordingSession) { + return { success: false }; + } + + return { + success: true, + session: currentRecordingSession, + }; + }); + + ipcMain.handle("get-current-video-path", () => { + return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false }; + }); + + ipcMain.handle("clear-current-video-path", () => { + currentVideoPath = null; + currentRecordingSession = null; + return { success: true }; + }); + + ipcMain.handle("delete-recording-file", async (_, filePath: string) => { + try { + if (!filePath || !isAutoRecordingPath(filePath)) { + return { success: false, error: "Only auto-generated recordings can be deleted" }; + } + await fs.unlink(filePath); + // Also delete the cursor telemetry sidecar if it exists + const telemetryPath = getTelemetryPathForVideo(filePath); + await fs.unlink(telemetryPath).catch(() => {}); + if (currentVideoPath === filePath) { + currentVideoPath = null; + currentRecordingSession = null; + } + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("app:getVersion", () => { + return app.getVersion(); + }); + + ipcMain.handle("get-platform", () => { + return process.platform; + }); + + // --------------------------------------------------------------------------- + // Cursor hiding for the browser-capture fallback. + // The IPC promise resolves only after the cursor hide attempt completes. + // --------------------------------------------------------------------------- + ipcMain.handle("hide-cursor", () => { + if (process.platform !== "win32") { + return { success: true }; + } + + return { success: hideCursor() }; + }); + + ipcMain.handle("get-shortcuts", async () => { + try { + const data = await fs.readFile(SHORTCUTS_FILE, "utf-8"); + return JSON.parse(data); + } catch { + return null; + } + }); + + ipcMain.handle("save-shortcuts", async (_, shortcuts: unknown) => { + try { + await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8"); + return { success: true }; + } catch (error) { + console.error("Failed to save shortcuts:", error); + return { success: false, error: String(error) }; + } + }); + + // --------------------------------------------------------------------------- + // Countdown timer before recording + // --------------------------------------------------------------------------- + ipcMain.handle("get-recording-preferences", async () => { + try { + const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, "utf-8"); + const parsed = JSON.parse(content) as Record; + return { + success: true, + microphoneEnabled: parsed.microphoneEnabled === true, + microphoneDeviceId: + typeof parsed.microphoneDeviceId === "string" ? parsed.microphoneDeviceId : undefined, + systemAudioEnabled: parsed.systemAudioEnabled !== false, + }; + } catch { + return { + success: true, + microphoneEnabled: false, + microphoneDeviceId: undefined, + systemAudioEnabled: true, + }; + } + }); + + ipcMain.handle( + "set-recording-preferences", + async ( + _, + prefs: { + microphoneEnabled?: boolean; + microphoneDeviceId?: string; + systemAudioEnabled?: boolean; + }, + ) => { + try { + let existing: Record = {}; + try { + const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, "utf-8"); + existing = JSON.parse(content) as Record; + } catch { + // file doesn't exist yet + } + const merged = { ...existing, ...prefs }; + await fs.writeFile(RECORDINGS_SETTINGS_FILE, JSON.stringify(merged, null, 2), "utf-8"); + return { success: true }; + } catch (error) { + console.error("Failed to save recording preferences:", error); + return { success: false, error: String(error) }; + } + }, + ); + + ipcMain.handle("get-countdown-delay", async () => { + try { + const content = await fs.readFile(COUNTDOWN_SETTINGS_FILE, "utf-8"); + const parsed = JSON.parse(content) as { delay?: number }; + return { success: true, delay: parsed.delay ?? 3 }; + } catch { + return { success: true, delay: 3 }; + } + }); + + ipcMain.handle("set-countdown-delay", async (_, delay: number) => { + try { + await fs.writeFile(COUNTDOWN_SETTINGS_FILE, JSON.stringify({ delay }, null, 2), "utf-8"); + return { success: true }; + } catch (error) { + console.error("Failed to save countdown delay:", error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("start-countdown", async (_, seconds: number) => { + if (countdownInProgress) { + return { success: false, error: "Countdown already in progress" }; + } + + countdownInProgress = true; + countdownCancelled = false; + countdownRemaining = seconds; + + const countdownWin = createCountdownWindow(); + + if (countdownWin.webContents.isLoadingMainFrame()) { + await new Promise((resolve) => { + countdownWin.webContents.once("did-finish-load", () => { + resolve(); + }); + }); + } + + return new Promise<{ success: boolean; cancelled?: boolean }>((resolve) => { + let remaining = seconds; + countdownRemaining = remaining; + + countdownWin.webContents.send("countdown-tick", remaining); + + countdownTimer = setInterval(() => { + if (countdownCancelled) { + if (countdownTimer) { + clearInterval(countdownTimer); + countdownTimer = null; + } + closeCountdownWindow(); + countdownInProgress = false; + countdownRemaining = null; + resolve({ success: false, cancelled: true }); + return; + } + + remaining--; + countdownRemaining = remaining; + + if (remaining <= 0) { + if (countdownTimer) { + clearInterval(countdownTimer); + countdownTimer = null; + } + closeCountdownWindow(); + countdownInProgress = false; + countdownRemaining = null; + resolve({ success: true }); + } else { + const win = getCountdownWindow(); + if (win && !win.isDestroyed()) { + win.webContents.send("countdown-tick", remaining); + } + } + }, 1000); + }); + }); + + ipcMain.handle("cancel-countdown", () => { + countdownCancelled = true; + countdownInProgress = false; + countdownRemaining = null; + if (countdownTimer) { + clearInterval(countdownTimer); + countdownTimer = null; + } + closeCountdownWindow(); + return { success: true }; + }); + + ipcMain.handle("get-active-countdown", () => { + return { + success: true, + seconds: countdownInProgress ? countdownRemaining : null, + }; + }); } diff --git a/electron/ipc/windowsCaptureSelection.test.ts b/electron/ipc/windowsCaptureSelection.test.ts index 870ebbd2..de6a36cf 100644 --- a/electron/ipc/windowsCaptureSelection.test.ts +++ b/electron/ipc/windowsCaptureSelection.test.ts @@ -1,60 +1,64 @@ -import { describe, expect, it } from 'vitest' - -import { resolveWindowsCaptureDisplay } from './windowsCaptureSelection' - -describe('resolveWindowsCaptureDisplay', () => { - const primaryDisplay = { - id: 101, - bounds: { - x: 0, - y: 0, - width: 1920, - height: 1080, - }, - } - - const secondaryDisplay = { - id: 202, - bounds: { - x: 1920, - y: -40, - width: 2560, - height: 1440, - }, - } - - it('uses the requested secondary display bounds for WGC fallback metadata', () => { - const resolved = resolveWindowsCaptureDisplay( - { display_id: String(secondaryDisplay.id) }, - [primaryDisplay, secondaryDisplay], - primaryDisplay, - ) - - expect(resolved).toEqual({ - displayId: secondaryDisplay.id, - bounds: secondaryDisplay.bounds, - }) - }) - - it('falls back to the primary display when the source has no display id', () => { - const resolved = resolveWindowsCaptureDisplay(undefined, [primaryDisplay, secondaryDisplay], primaryDisplay) - - expect(resolved).toEqual({ - displayId: primaryDisplay.id, - bounds: primaryDisplay.bounds, - }) - }) - - it('keeps the requested display id even if Electron cannot rematch it, while using primary bounds', () => { - const resolved = resolveWindowsCaptureDisplay( - { display_id: '303' }, - [primaryDisplay, secondaryDisplay], - primaryDisplay, - ) - - expect(resolved).toEqual({ - displayId: 303, - bounds: primaryDisplay.bounds, - }) - }) -}) \ No newline at end of file +import { describe, expect, it } from "vitest"; + +import { resolveWindowsCaptureDisplay } from "./windowsCaptureSelection"; + +describe("resolveWindowsCaptureDisplay", () => { + const primaryDisplay = { + id: 101, + bounds: { + x: 0, + y: 0, + width: 1920, + height: 1080, + }, + }; + + const secondaryDisplay = { + id: 202, + bounds: { + x: 1920, + y: -40, + width: 2560, + height: 1440, + }, + }; + + it("uses the requested secondary display bounds for WGC fallback metadata", () => { + const resolved = resolveWindowsCaptureDisplay( + { display_id: String(secondaryDisplay.id) }, + [primaryDisplay, secondaryDisplay], + primaryDisplay, + ); + + expect(resolved).toEqual({ + displayId: secondaryDisplay.id, + bounds: secondaryDisplay.bounds, + }); + }); + + it("falls back to the primary display when the source has no display id", () => { + const resolved = resolveWindowsCaptureDisplay( + undefined, + [primaryDisplay, secondaryDisplay], + primaryDisplay, + ); + + expect(resolved).toEqual({ + displayId: primaryDisplay.id, + bounds: primaryDisplay.bounds, + }); + }); + + it("keeps the requested display id even if Electron cannot rematch it, while using primary bounds", () => { + const resolved = resolveWindowsCaptureDisplay( + { display_id: "303" }, + [primaryDisplay, secondaryDisplay], + primaryDisplay, + ); + + expect(resolved).toEqual({ + displayId: 303, + bounds: primaryDisplay.bounds, + }); + }); +}); diff --git a/electron/ipc/windowsCaptureSelection.ts b/electron/ipc/windowsCaptureSelection.ts index ee7ad1c7..6b68c77e 100644 --- a/electron/ipc/windowsCaptureSelection.ts +++ b/electron/ipc/windowsCaptureSelection.ts @@ -1,40 +1,41 @@ export type WindowsCaptureSourceLike = { - display_id?: string -} + display_id?: string; +}; export type WindowsCaptureDisplayBounds = { - x: number - y: number - width: number - height: number -} + x: number; + y: number; + width: number; + height: number; +}; export type WindowsCaptureDisplayLike = { - id: number - bounds: WindowsCaptureDisplayBounds -} + id: number; + bounds: WindowsCaptureDisplayBounds; +}; export type ResolvedWindowsCaptureDisplay = { - displayId: number - bounds: WindowsCaptureDisplayBounds -} + displayId: number; + bounds: WindowsCaptureDisplayBounds; +}; export function resolveWindowsCaptureDisplay( - source: WindowsCaptureSourceLike | null | undefined, - allDisplays: WindowsCaptureDisplayLike[], - primaryDisplay: WindowsCaptureDisplayLike, + source: WindowsCaptureSourceLike | null | undefined, + allDisplays: WindowsCaptureDisplayLike[], + primaryDisplay: WindowsCaptureDisplayLike, ): ResolvedWindowsCaptureDisplay { - const requestedDisplayId = Number(source?.display_id) - const primaryDisplayId = Number(primaryDisplay.id) - const displayId = Number.isFinite(requestedDisplayId) && requestedDisplayId > 0 - ? requestedDisplayId - : primaryDisplayId + const requestedDisplayId = Number(source?.display_id); + const primaryDisplayId = Number(primaryDisplay.id); + const displayId = + Number.isFinite(requestedDisplayId) && requestedDisplayId > 0 + ? requestedDisplayId + : primaryDisplayId; - const matchedDisplay = allDisplays.find((display) => String(display.id) === String(displayId)) - ?? primaryDisplay + const matchedDisplay = + allDisplays.find((display) => String(display.id) === String(displayId)) ?? primaryDisplay; - return { - displayId, - bounds: matchedDisplay.bounds, - } -} \ No newline at end of file + return { + displayId, + bounds: matchedDisplay.bounds, + }; +} diff --git a/electron/main.ts b/electron/main.ts index c273daac..bb07ca5d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -8,8 +8,8 @@ import { dialog, ipcMain, Menu, - nativeImage, Notification, + nativeImage, session, systemPreferences, Tray, @@ -21,26 +21,26 @@ import { killWindowsCaptureProcess, registerIpcHandlers, } from "./ipc/handlers"; +import type { UpdateToastPayload } from "./updater"; import { checkForAppUpdates, + deferUpdateReminder, dismissUpdateToast, downloadAvailableUpdate, - deferUpdateReminder, getCurrentUpdateToastPayload, getUpdaterLogPath, getUpdateStatusSummary, installDownloadedUpdateNow, previewUpdateToast, - skipAvailableUpdateVersion, setupAutoUpdates, + skipAvailableUpdateVersion, } from "./updater"; -import type { UpdateToastPayload } from "./updater"; import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow, - getUpdateToastWindow, getHudOverlayWindow, + getUpdateToastWindow, hideUpdateToastWindow, showUpdateToastWindow, } from "./windows"; @@ -408,7 +408,9 @@ function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknow return false; } - const notificationKey = [updatePayload.phase, updatePayload.version, updatePayload.detail].join(":"); + const notificationKey = [updatePayload.phase, updatePayload.version, updatePayload.detail].join( + ":", + ); if (activeUpdateNotificationKey === notificationKey) { return true; } @@ -682,10 +684,14 @@ app.whenReady().then(async () => { const cameraStatus = systemPreferences.getMediaAccessStatus("camera"); const micStatus = systemPreferences.getMediaAccessStatus("microphone"); if (cameraStatus !== "granted") { - console.warn(`[permissions] Camera access is "${cameraStatus}" — webcam may not work. Check Windows Settings > Privacy > Camera.`); + console.warn( + `[permissions] Camera access is "${cameraStatus}" — webcam may not work. Check Windows Settings > Privacy > Camera.`, + ); } if (micStatus !== "granted") { - console.warn(`[permissions] Microphone access is "${micStatus}" — mic recording may not work. Check Windows Settings > Privacy > Microphone.`); + console.warn( + `[permissions] Microphone access is "${micStatus}" — mic recording may not work. Check Windows Settings > Privacy > Microphone.`, + ); } } diff --git a/electron/native/bin/darwin-arm64/whisper-runtime.json b/electron/native/bin/darwin-arm64/whisper-runtime.json index 47e7440e..380d1adf 100644 --- a/electron/native/bin/darwin-arm64/whisper-runtime.json +++ b/electron/native/bin/darwin-arm64/whisper-runtime.json @@ -1,6 +1,6 @@ { - "version": "v1.8.4", - "platform": "darwin", - "arch": "arm64", - "binary": "whisper-cli" -} \ No newline at end of file + "version": "v1.8.4", + "platform": "darwin", + "arch": "arm64", + "binary": "whisper-cli" +} diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index 62c77822..dbae2bae 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -1,21 +1,21 @@ { - "version": 1, - "platform": "win32", - "arch": "x64", - "helpers": { - "wgc-capture": { - "binaryName": "wgc-capture.exe", - "binarySha256": "013164c0a1391d334e5aa2fe0ff2e47b507407e36c7e44e7333a70a84b944643", - "sourceDir": "electron/native/wgc-capture", - "sourceFingerprint": "9e9bce082266ca5968cf5f0b535b469d47c4ec3a775a93726171c0dfdbcdaa44", - "updatedAt": "2026-03-29T02:15:34.516Z" - }, - "cursor-monitor": { - "binaryName": "cursor-monitor.exe", - "binarySha256": "b0732abc06998a40c3e95078465ad750a6169901944571c39cfd7996effe39c0", - "sourceDir": "electron/native/cursor-monitor", - "sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e", - "updatedAt": "2026-03-29T02:15:38.286Z" - } - } + "version": 1, + "platform": "win32", + "arch": "x64", + "helpers": { + "wgc-capture": { + "binaryName": "wgc-capture.exe", + "binarySha256": "013164c0a1391d334e5aa2fe0ff2e47b507407e36c7e44e7333a70a84b944643", + "sourceDir": "electron/native/wgc-capture", + "sourceFingerprint": "9e9bce082266ca5968cf5f0b535b469d47c4ec3a775a93726171c0dfdbcdaa44", + "updatedAt": "2026-03-29T02:15:34.516Z" + }, + "cursor-monitor": { + "binaryName": "cursor-monitor.exe", + "binarySha256": "b0732abc06998a40c3e95078465ad750a6169901944571c39cfd7996effe39c0", + "sourceDir": "electron/native/cursor-monitor", + "sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e", + "updatedAt": "2026-03-29T02:15:38.286Z" + } + } } diff --git a/electron/native/bin/win32-x64/whisper-bench.exe b/electron/native/bin/win32-x64/whisper-bench.exe new file mode 100644 index 00000000..5ee642ff Binary files /dev/null and b/electron/native/bin/win32-x64/whisper-bench.exe differ diff --git a/electron/native/bin/win32-x64/whisper-cli.exe b/electron/native/bin/win32-x64/whisper-cli.exe new file mode 100644 index 00000000..bd0d80f7 Binary files /dev/null and b/electron/native/bin/win32-x64/whisper-cli.exe differ diff --git a/electron/native/bin/win32-x64/whisper-quantize.exe b/electron/native/bin/win32-x64/whisper-quantize.exe new file mode 100644 index 00000000..64d714a8 Binary files /dev/null and b/electron/native/bin/win32-x64/whisper-quantize.exe differ diff --git a/electron/native/bin/win32-x64/whisper-runtime.json b/electron/native/bin/win32-x64/whisper-runtime.json new file mode 100644 index 00000000..fc07b2c0 --- /dev/null +++ b/electron/native/bin/win32-x64/whisper-runtime.json @@ -0,0 +1,6 @@ +{ + "version": "v1.8.4", + "platform": "win32", + "arch": "x64", + "binary": "whisper-cli.exe" +} diff --git a/electron/native/bin/win32-x64/whisper-server.exe b/electron/native/bin/win32-x64/whisper-server.exe new file mode 100644 index 00000000..d1d75d0f Binary files /dev/null and b/electron/native/bin/win32-x64/whisper-server.exe differ diff --git a/electron/native/bin/win32-x64/whisper-vad-speech-segments.exe b/electron/native/bin/win32-x64/whisper-vad-speech-segments.exe new file mode 100644 index 00000000..334242dd Binary files /dev/null and b/electron/native/bin/win32-x64/whisper-vad-speech-segments.exe differ diff --git a/electron/preload.ts b/electron/preload.ts index 6ca95ab1..cc660040 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -189,11 +189,21 @@ contextBridge.exposeInMainWorld("electronAPI", { return ipcRenderer.invoke("delete-whisper-small-model"); }, onWhisperSmallModelDownloadProgress: ( - callback: (state: { status: "idle" | "downloading" | "downloaded" | "error"; progress: number; path?: string | null; error?: string }) => void, + callback: (state: { + status: "idle" | "downloading" | "downloaded" | "error"; + progress: number; + path?: string | null; + error?: string; + }) => void, ) => { const listener = ( _event: Electron.IpcRendererEvent, - payload: { status: "idle" | "downloading" | "downloaded" | "error"; progress: number; path?: string | null; error?: string }, + payload: { + status: "idle" | "downloading" | "downloaded" | "error"; + progress: number; + path?: string | null; + error?: string; + }, ) => callback(payload); ipcRenderer.on("whisper-small-model-download-progress", listener); return () => ipcRenderer.removeListener("whisper-small-model-download-progress", listener); @@ -209,7 +219,11 @@ contextBridge.exposeInMainWorld("electronAPI", { setCurrentVideoPath: (path: string) => { return ipcRenderer.invoke("set-current-video-path", path); }, - setCurrentRecordingSession: (session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }) => { + setCurrentRecordingSession: (session: { + videoPath: string; + webcamPath?: string | null; + timeOffsetMs?: number; + }) => { return ipcRenderer.invoke("set-current-recording-session", session); }, getCurrentRecordingSession: () => { @@ -284,29 +298,29 @@ contextBridge.exposeInMainWorld("electronAPI", { return ipcRenderer.invoke("check-for-app-updates"); }, onUpdateToastStateChanged: ( - callback: (payload: { - version: string; - detail: string; - phase: "available" | "downloading" | "ready" | "error"; - delayMs: number; - isPreview?: boolean; - progressPercent?: number; - primaryAction?: "download-update" | "install-update" | "retry-check"; - } | null) => void, + callback: ( + payload: { + version: string; + detail: string; + phase: "available" | "downloading" | "ready" | "error"; + delayMs: number; + isPreview?: boolean; + progressPercent?: number; + primaryAction?: "download-update" | "install-update" | "retry-check"; + } | null, + ) => void, ) => { const listener = ( _event: Electron.IpcRendererEvent, - payload: - | { - version: string; - detail: string; - phase: "available" | "downloading" | "ready" | "error"; - delayMs: number; - isPreview?: boolean; - progressPercent?: number; - primaryAction?: "download-update" | "install-update" | "retry-check"; - } - | null, + payload: { + version: string; + detail: string; + phase: "available" | "downloading" | "ready" | "error"; + delayMs: number; + isPreview?: boolean; + progressPercent?: number; + primaryAction?: "download-update" | "install-update" | "retry-check"; + } | null, ) => callback(payload); ipcRenderer.on("update-toast-state", listener); return () => ipcRenderer.removeListener("update-toast-state", listener); @@ -379,11 +393,16 @@ contextBridge.exposeInMainWorld("electronAPI", { return () => ipcRenderer.removeListener("request-save-before-close", listener); }, isNativeWindowsCaptureAvailable: () => ipcRenderer.invoke("is-native-windows-capture-available"), - muxNativeWindowsRecording: (pauseSegments?: Array<{ startMs: number; endMs: number }>) => ipcRenderer.invoke("mux-native-windows-recording", pauseSegments), + muxNativeWindowsRecording: (pauseSegments?: Array<{ startMs: number; endMs: number }>) => + ipcRenderer.invoke("mux-native-windows-recording", pauseSegments), hideOsCursor: () => ipcRenderer.invoke("hide-cursor"), getAppVersion: () => ipcRenderer.invoke("app:getVersion"), getRecordingPreferences: () => ipcRenderer.invoke("get-recording-preferences"), - setRecordingPreferences: (prefs: { microphoneEnabled?: boolean; microphoneDeviceId?: string; systemAudioEnabled?: boolean }) => ipcRenderer.invoke("set-recording-preferences", prefs), + setRecordingPreferences: (prefs: { + microphoneEnabled?: boolean; + microphoneDeviceId?: string; + systemAudioEnabled?: boolean; + }) => ipcRenderer.invoke("set-recording-preferences", prefs), getCountdownDelay: () => ipcRenderer.invoke("get-countdown-delay"), setCountdownDelay: (delay: number) => ipcRenderer.invoke("set-countdown-delay", delay), startCountdown: (seconds: number) => ipcRenderer.invoke("start-countdown", seconds), diff --git a/electron/uiohook-napi.d.ts b/electron/uiohook-napi.d.ts index ed1ff158..a6e65fcd 100644 --- a/electron/uiohook-napi.d.ts +++ b/electron/uiohook-napi.d.ts @@ -1,2 +1 @@ -declare module 'uiohook-napi'; - +declare module "uiohook-napi"; diff --git a/electron/updater.ts b/electron/updater.ts index bfb932dd..850e54ec 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; +import type { MessageBoxOptions, MessageBoxReturnValue } from "electron"; import { app, BrowserWindow, dialog } from "electron"; import { autoUpdater } from "electron-updater"; -import type { MessageBoxOptions, MessageBoxReturnValue } from "electron"; import { USER_DATA_PATH } from "./appPaths"; const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; @@ -181,7 +181,8 @@ function createAvailableUpdateToastPayload(version: string): UpdateToastPayload return { version, phase: "available", - detail: "A new version is available. Download it now, or wait and we will remind you again in 3 hours.", + detail: + "A new version is available. Download it now, or wait and we will remind you again in 3 hours.", delayMs: UPDATE_REMINDER_DELAY_MS, primaryAction: "download-update", }; @@ -208,7 +209,8 @@ function createDownloadedUpdateToastPayload(version: string): UpdateToastPayload return { version, phase: "ready", - detail: "Install now to restart into the new version, or wait and we will remind you again in 3 hours.", + detail: + "Install now to restart into the new version, or wait and we will remind you again in 3 hours.", delayMs: UPDATE_REMINDER_DELAY_MS, primaryAction: "install-update", }; @@ -289,13 +291,10 @@ function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) { clearDevPreviewProgressTimer(); let progressPercent = 0; - emitUpdateToastState( - sendToRenderer, - { - ...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, progressPercent), - isPreview: true, - }, - ); + emitUpdateToastState(sendToRenderer, { + ...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, progressPercent), + isPreview: true, + }); devPreviewProgressTimer = setInterval(() => { progressPercent = Math.min(100, progressPercent + DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT); @@ -307,7 +306,8 @@ function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) { emitUpdateToastState(sendToRenderer, { ...createDownloadedUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION), isPreview: true, - detail: "Development preview: the update is ready to install. No real update will be installed.", + detail: + "Development preview: the update is ready to install. No real update will be installed.", }); return; } @@ -316,19 +316,15 @@ function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) { return; } - emitUpdateToastState( - sendToRenderer, - { - ...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, progressPercent), - isPreview: true, - }, - ); + emitUpdateToastState(sendToRenderer, { + ...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, progressPercent), + isPreview: true, + }); }, DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS); return { success: true }; } - export function dismissUpdateToast( getMainWindow: () => BrowserWindow | null, sendToRenderer?: UpdateToastSender, @@ -345,11 +341,7 @@ export function dismissUpdateToast( } if (currentToastPayload?.phase === "ready") { - return deferUpdateReminder( - getMainWindow, - sendToRenderer, - DISMISSED_READY_REMINDER_DELAY_MS, - ); + return deferUpdateReminder(getMainWindow, sendToRenderer, DISMISSED_READY_REMINDER_DELAY_MS); } if (currentToastPayload?.phase === "available" || currentToastPayload?.phase === "error") { @@ -414,10 +406,7 @@ export async function downloadAvailableUpdate(sendToRenderer?: UpdateToastSender detail: String(error), }); writeUpdaterLog(`Update download failed for ${availableVersion}.`, error); - emitUpdateToastState( - sendToRenderer, - createUpdateErrorToastPayload(availableVersion, error), - ); + emitUpdateToastState(sendToRenderer, createUpdateErrorToastPayload(availableVersion, error)); return { success: false, message: String(error) }; } } @@ -524,7 +513,7 @@ async function showAvailableUpdateDialog( async function showDownloadedUpdateDialog( getMainWindow: () => BrowserWindow | null, version: string, - options?: { isPreview?: boolean }, + options?: { isPreview?: boolean }, ) { const isPreview = Boolean(options?.isPreview); const result = await showMessageBox(getMainWindow, { @@ -658,7 +647,11 @@ export function setupAutoUpdates( writeUpdaterLog(`Updater initialized. logPath=${UPDATER_LOG_PATH}`); autoUpdater.on("checking-for-update", () => { - setUpdateStatusSummary({ status: "checking", availableVersion: null, detail: "Checking for updates..." }); + setUpdateStatusSummary({ + status: "checking", + availableVersion: null, + detail: "Checking for updates...", + }); writeUpdaterLog("electron-updater emitted checking-for-update."); }); @@ -722,9 +715,7 @@ export function setupAutoUpdates( availableVersion, detail: `Downloading Recordly ${availableVersion}`, }); - writeUpdaterLog( - `Download progress for ${availableVersion}: ${progress.percent.toFixed(1)}%`, - ); + writeUpdaterLog(`Download progress for ${availableVersion}: ${progress.percent.toFixed(1)}%`); if (downloadToastDismissed) { return; } @@ -752,10 +743,7 @@ export function setupAutoUpdates( if (downloadInProgress && availableVersion) { downloadInProgress = false; downloadToastDismissed = false; - emitUpdateToastState( - sendToRenderer, - createUpdateErrorToastPayload(availableVersion, error), - ); + emitUpdateToastState(sendToRenderer, createUpdateErrorToastPayload(availableVersion, error)); } if (shouldReport) { void showUpdateErrorDialog(getMainWindow, error); @@ -802,4 +790,4 @@ export function setupAutoUpdates( periodicCheckTimer = null; } }); -} \ No newline at end of file +} diff --git a/electron/windows.ts b/electron/windows.ts index 1abb70e3..dabc5edd 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -87,7 +87,9 @@ function persistHudOverlayCaptureProtectionSetting(enabled: boolean): void { function getScreen() { if (!app.isReady()) { - throw new Error("getScreen() called before app is ready. Ensure all screen access happens after app.whenReady()."); + throw new Error( + "getScreen() called before app is ready. Ensure all screen access happens after app.whenReady().", + ); } return nodeRequire("electron").screen as typeof import("electron").screen; } diff --git a/postcss.config.cjs b/postcss.config.cjs index 33ad091d..e873f1a4 100644 --- a/postcss.config.cjs +++ b/postcss.config.cjs @@ -1,6 +1,6 @@ module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/scripts/build-windows-capture.mjs b/scripts/build-windows-capture.mjs index 18f5ca36..3ae981b2 100644 --- a/scripts/build-windows-capture.mjs +++ b/scripts/build-windows-capture.mjs @@ -1,11 +1,11 @@ import { execSync } from "node:child_process"; -import { copyFileSync, mkdirSync, existsSync, rmSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; import path from "node:path"; import { - formatNativeHelperManifestWarning, - updateNativeHelperManifest, - verifyNativeHelperManifest, + formatNativeHelperManifestWarning, + updateNativeHelperManifest, + verifyNativeHelperManifest, } from "./native-helper-manifest.mjs"; const projectRoot = process.cwd(); @@ -21,81 +21,83 @@ const bundledDir = path.join( const bundledExePath = path.join(bundledDir, "wgc-capture.exe"); const helperId = "wgc-capture"; -if (process.platform !== 'win32') { - console.log('[build-windows-capture] Skipping native Windows capture build: host platform is not Windows.'); - process.exit(0); +if (process.platform !== "win32") { + console.log( + "[build-windows-capture] Skipping native Windows capture build: host platform is not Windows.", + ); + process.exit(0); } -if (!existsSync(path.join(sourceDir, 'CMakeLists.txt'))) { - console.error('[build-windows-capture] CMakeLists.txt not found at', sourceDir); - process.exit(1); +if (!existsSync(path.join(sourceDir, "CMakeLists.txt"))) { + console.error("[build-windows-capture] CMakeLists.txt not found at", sourceDir); + process.exit(1); } function findCmake() { - // Check PATH first - try { - execSync('cmake --version', { stdio: 'pipe' }); - return 'cmake'; - } catch { - // not on PATH - } + // Check PATH first + try { + execSync("cmake --version", { stdio: "pipe" }); + return "cmake"; + } catch { + // not on PATH + } - const standaloneCmakePaths = [ - path.join('C:', 'Program Files', 'CMake', 'bin', 'cmake.exe'), - path.join('C:', 'Program Files (x86)', 'CMake', 'bin', 'cmake.exe'), - ]; - for (const cmakePath of standaloneCmakePaths) { - if (existsSync(cmakePath)) { - return `"${cmakePath}"`; - } - } + const standaloneCmakePaths = [ + path.join("C:", "Program Files", "CMake", "bin", "cmake.exe"), + path.join("C:", "Program Files (x86)", "CMake", "bin", "cmake.exe"), + ]; + for (const cmakePath of standaloneCmakePaths) { + if (existsSync(cmakePath)) { + return `"${cmakePath}"`; + } + } - // VS 2022 bundled CMake - const vsRoots = [ - path.join('C:', 'Program Files', 'Microsoft Visual Studio'), - path.join('C:', 'Program Files (x86)', 'Microsoft Visual Studio'), - ]; - const vsEditions = ['Community', 'Professional', 'Enterprise', 'BuildTools']; - const vsVersions = ['2022', '2019']; - for (const root of vsRoots) { - for (const version of vsVersions) { - for (const edition of vsEditions) { - const cmakePath = path.join( - root, - version, - edition, - 'Common7', - 'IDE', - 'CommonExtensions', - 'Microsoft', - 'CMake', - 'CMake', - 'bin', - 'cmake.exe' - ); - if (existsSync(cmakePath)) { - return `"${cmakePath}"`; - } - } - } - } + // VS 2022 bundled CMake + const vsRoots = [ + path.join("C:", "Program Files", "Microsoft Visual Studio"), + path.join("C:", "Program Files (x86)", "Microsoft Visual Studio"), + ]; + const vsEditions = ["Community", "Professional", "Enterprise", "BuildTools"]; + const vsVersions = ["2022", "2019"]; + for (const root of vsRoots) { + for (const version of vsVersions) { + for (const edition of vsEditions) { + const cmakePath = path.join( + root, + version, + edition, + "Common7", + "IDE", + "CommonExtensions", + "Microsoft", + "CMake", + "CMake", + "bin", + "cmake.exe", + ); + if (existsSync(cmakePath)) { + return `"${cmakePath}"`; + } + } + } + } - return null; + return null; } const cmake = findCmake(); if (!cmake) { if (existsSync(bundledExePath)) { - const verification = verifyNativeHelperManifest({ - projectRoot, - helperId, - sourceDir, - binaryPath: bundledExePath, - binaryName: "wgc-capture.exe", - }); - if (!verification.ok) { - console.warn(formatNativeHelperManifestWarning("build-windows-capture", verification)); - } + const verification = verifyNativeHelperManifest({ + projectRoot, + helperId, + sourceDir, + binaryPath: bundledExePath, + binaryName: "wgc-capture.exe", + }); + if (!verification.ok) { + console.warn(formatNativeHelperManifestWarning("build-windows-capture", verification)); + } console.log(`[build-windows-capture] Using bundled helper: ${bundledExePath}`); process.exit(0); } @@ -107,64 +109,64 @@ if (!cmake) { } mkdirSync(buildDir, { recursive: true }); -const cacheFile = path.join(buildDir, 'CMakeCache.txt'); -const cacheDir = path.join(buildDir, 'CMakeFiles'); +const cacheFile = path.join(buildDir, "CMakeCache.txt"); +const cacheDir = path.join(buildDir, "CMakeFiles"); function clearCmakeCache() { - rmSync(cacheFile, { force: true }); - rmSync(cacheDir, { recursive: true, force: true }); + rmSync(cacheFile, { force: true }); + rmSync(cacheDir, { recursive: true, force: true }); } -console.log('[build-windows-capture] Configuring CMake...'); +console.log("[build-windows-capture] Configuring CMake..."); try { - clearCmakeCache(); - execSync(`${cmake} .. -G "Visual Studio 17 2022" -A x64`, { - cwd: buildDir, - stdio: 'inherit', - timeout: 120000, - }); + clearCmakeCache(); + execSync(`${cmake} .. -G "Visual Studio 17 2022" -A x64`, { + cwd: buildDir, + stdio: "inherit", + timeout: 120000, + }); } catch { - console.log('[build-windows-capture] VS 2022 generator not found, trying VS 2019...'); - try { - clearCmakeCache(); - execSync(`${cmake} .. -G "Visual Studio 16 2019" -A x64`, { - cwd: buildDir, - stdio: 'inherit', - timeout: 120000, - }); - } catch (innerError) { - console.error('[build-windows-capture] CMake configure failed:', innerError.message); - process.exit(1); - } + console.log("[build-windows-capture] VS 2022 generator not found, trying VS 2019..."); + try { + clearCmakeCache(); + execSync(`${cmake} .. -G "Visual Studio 16 2019" -A x64`, { + cwd: buildDir, + stdio: "inherit", + timeout: 120000, + }); + } catch (innerError) { + console.error("[build-windows-capture] CMake configure failed:", innerError.message); + process.exit(1); + } } -console.log('[build-windows-capture] Building native Windows capture helper...'); +console.log("[build-windows-capture] Building native Windows capture helper..."); try { - execSync(`${cmake} --build . --config Release`, { - cwd: buildDir, - stdio: 'inherit', - timeout: 300000, - }); + execSync(`${cmake} --build . --config Release`, { + cwd: buildDir, + stdio: "inherit", + timeout: 300000, + }); } catch (error) { - console.error('[build-windows-capture] Build failed:', error.message); - process.exit(1); + console.error("[build-windows-capture] Build failed:", error.message); + process.exit(1); } -const exePath = path.join(buildDir, 'Release', 'wgc-capture.exe'); +const exePath = path.join(buildDir, "Release", "wgc-capture.exe"); if (existsSync(exePath)) { console.log(`[build-windows-capture] Built successfully: ${exePath}`); mkdirSync(bundledDir, { recursive: true }); copyFileSync(exePath, bundledExePath); console.log(`[build-windows-capture] Staged bundled helper: ${bundledExePath}`); - const manifestPath = updateNativeHelperManifest({ - projectRoot, - helperId, - sourceDir, - binaryPath: bundledExePath, - binaryName: "wgc-capture.exe", - }); - console.log(`[build-windows-capture] Updated helper manifest: ${manifestPath}`); + const manifestPath = updateNativeHelperManifest({ + projectRoot, + helperId, + sourceDir, + binaryPath: bundledExePath, + binaryName: "wgc-capture.exe", + }); + console.log(`[build-windows-capture] Updated helper manifest: ${manifestPath}`); } else { - console.error('[build-windows-capture] Expected exe not found at', exePath); - process.exit(1); + console.error("[build-windows-capture] Expected exe not found at", exePath); + process.exit(1); } diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs index 19535bda..cff56c29 100644 --- a/scripts/i18n-check.mjs +++ b/scripts/i18n-check.mjs @@ -1,86 +1,86 @@ -import fs from 'node:fs' -import path from 'node:path' +import fs from "node:fs"; +import path from "node:path"; -const root = process.cwd() -const localesDir = path.join(root, 'src', 'i18n', 'locales') +const root = process.cwd(); +const localesDir = path.join(root, "src", "i18n", "locales"); const locales = fs.readdirSync(localesDir).filter((entry) => { - const fullPath = path.join(localesDir, entry) - return fs.statSync(fullPath).isDirectory() -}) + const fullPath = path.join(localesDir, entry); + return fs.statSync(fullPath).isDirectory(); +}); -if (!locales.includes('en')) { - console.error('i18n-check: expected base locale directory "en"') - process.exit(1) +if (!locales.includes("en")) { + console.error('i18n-check: expected base locale directory "en"'); + process.exit(1); } function loadJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, 'utf8')) + return JSON.parse(fs.readFileSync(filePath, "utf8")); } -function collectKeyPaths(obj, prefix = '') { - if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { - return prefix ? [prefix] : [] - } - - const keys = Object.keys(obj) - if (keys.length === 0) { - return prefix ? [prefix] : [] - } - - const paths = [] - for (const key of keys) { - const nextPrefix = prefix ? `${prefix}.${key}` : key - const value = obj[key] - if (value && typeof value === 'object' && !Array.isArray(value)) { - paths.push(...collectKeyPaths(value, nextPrefix)) - } else { - paths.push(nextPrefix) - } - } - return paths +function collectKeyPaths(obj, prefix = "") { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) { + return prefix ? [prefix] : []; + } + + const keys = Object.keys(obj); + if (keys.length === 0) { + return prefix ? [prefix] : []; + } + + const paths = []; + for (const key of keys) { + const nextPrefix = prefix ? `${prefix}.${key}` : key; + const value = obj[key]; + if (value && typeof value === "object" && !Array.isArray(value)) { + paths.push(...collectKeyPaths(value, nextPrefix)); + } else { + paths.push(nextPrefix); + } + } + return paths; } -const baseLocaleDir = path.join(localesDir, 'en') -const namespaceFiles = fs.readdirSync(baseLocaleDir).filter((file) => file.endsWith('.json')) +const baseLocaleDir = path.join(localesDir, "en"); +const namespaceFiles = fs.readdirSync(baseLocaleDir).filter((file) => file.endsWith(".json")); -let hasErrors = false +let hasErrors = false; for (const namespaceFile of namespaceFiles) { - const baseData = loadJson(path.join(baseLocaleDir, namespaceFile)) - const baseKeys = new Set(collectKeyPaths(baseData)) - - for (const locale of locales) { - if (locale === 'en') continue - - const localeFile = path.join(localesDir, locale, namespaceFile) - if (!fs.existsSync(localeFile)) { - console.error(`i18n-check: missing namespace file ${locale}/${namespaceFile}`) - hasErrors = true - continue - } - - const localeData = loadJson(localeFile) - const localeKeys = new Set(collectKeyPaths(localeData)) - - for (const key of baseKeys) { - if (!localeKeys.has(key)) { - console.error(`i18n-check: missing key ${locale}/${namespaceFile}:${key}`) - hasErrors = true - } - } - - for (const key of localeKeys) { - if (!baseKeys.has(key)) { - console.error(`i18n-check: extra key ${locale}/${namespaceFile}:${key}`) - hasErrors = true - } - } - } + const baseData = loadJson(path.join(baseLocaleDir, namespaceFile)); + const baseKeys = new Set(collectKeyPaths(baseData)); + + for (const locale of locales) { + if (locale === "en") continue; + + const localeFile = path.join(localesDir, locale, namespaceFile); + if (!fs.existsSync(localeFile)) { + console.error(`i18n-check: missing namespace file ${locale}/${namespaceFile}`); + hasErrors = true; + continue; + } + + const localeData = loadJson(localeFile); + const localeKeys = new Set(collectKeyPaths(localeData)); + + for (const key of baseKeys) { + if (!localeKeys.has(key)) { + console.error(`i18n-check: missing key ${locale}/${namespaceFile}:${key}`); + hasErrors = true; + } + } + + for (const key of localeKeys) { + if (!baseKeys.has(key)) { + console.error(`i18n-check: extra key ${locale}/${namespaceFile}:${key}`); + hasErrors = true; + } + } + } } if (hasErrors) { - process.exit(1) + process.exit(1); } -console.log('i18n-check: locale files are structurally consistent') \ No newline at end of file +console.log("i18n-check: locale files are structurally consistent"); diff --git a/scripts/native-helper-manifest.mjs b/scripts/native-helper-manifest.mjs index 5c13996e..90ab8cda 100644 --- a/scripts/native-helper-manifest.mjs +++ b/scripts/native-helper-manifest.mjs @@ -1,12 +1,5 @@ import { createHash } from "node:crypto"; -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - statSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; const MANIFEST_FILE_NAME = "helpers-manifest.json"; @@ -66,7 +59,11 @@ function hashFile(filePath) { return hashBuffer(readFileSync(filePath)); } -export function getNativeHelperManifestPath({ projectRoot, platform = process.platform, arch = process.arch }) { +export function getNativeHelperManifestPath({ + projectRoot, + platform = process.platform, + arch = process.arch, +}) { return path.join( projectRoot, "electron", @@ -186,4 +183,4 @@ export function verifyNativeHelperManifest({ export function formatNativeHelperManifestWarning(helperLabel, verificationResult) { const reasonText = verificationResult.reasons.join(", "); return `[${helperLabel}] Bundled helper provenance check failed (${reasonText}). Rebuild the helper to refresh ${path.basename(verificationResult.manifestPath)}.`; -} \ No newline at end of file +} diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index 3a35d3db..b0715a83 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -23,9 +23,7 @@ function runScript(scriptName) { }); if (result.error) { - console.error( - `[postinstall] Failed to start "${scriptName}" (${result.error.message}).`, - ); + console.error(`[postinstall] Failed to start "${scriptName}" (${result.error.message}).`); return false; } @@ -35,9 +33,7 @@ function runScript(scriptName) { } if (result.status !== 0) { - console.error( - `[postinstall] "${scriptName}" exited with code ${result.status}.`, - ); + console.error(`[postinstall] "${scriptName}" exited with code ${result.status}.`); return false; } diff --git a/src/App.css b/src/App.css index d936c2c5..45b15283 100644 --- a/src/App.css +++ b/src/App.css @@ -1,43 +1,42 @@ #root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; } .logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; } .logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); + filter: drop-shadow(0 0 2em #646cffaa); } .logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); + filter: drop-shadow(0 0 2em #61dafbaa); } @keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } @media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } } .card { - padding: 2em; + padding: 2em; } .read-the-docs { - color: #888; + color: #888; } - diff --git a/src/App.tsx b/src/App.tsx index 4622bdda..0beb8baa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,12 +49,12 @@ export default function App() { switch (windowType) { case "hud-overlay": - return ( - <> - - - - ); + return ( + <> + + + + ); case "source-selector": return ; case "countdown": diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 46bef471..1246fcf4 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,8 +1,8 @@ import { AppWindow, ArrowUpCircle, - ChevronUp, CheckCircle2, + ChevronUp, Eye, EyeOff, FolderOpen, @@ -678,8 +678,8 @@ export function LaunchWindow() { case "ready": return updateStatus.availableVersion ? t("recording.update.availableTitle", "Recordly {{version}} is available.", { - version: updateStatus.availableVersion, - }) + version: updateStatus.availableVersion, + }) : t("recording.update.availableGenericTitle"); case "downloading": return updateStatus.detail ?? t("recording.update.downloadingTitle"); diff --git a/src/components/launch/SourceSelector.module.css b/src/components/launch/SourceSelector.module.css index d817be6e..79cf7853 100644 --- a/src/components/launch/SourceSelector.module.css +++ b/src/components/launch/SourceSelector.module.css @@ -1,71 +1,74 @@ - .glassContainer { - background: linear-gradient(135deg, rgba(28,28,34,0.92) 0%, rgba(18,18,22,0.88) 100%); - backdrop-filter: blur(20px) saturate(160%); - -webkit-backdrop-filter: blur(20px) saturate(160%); - border-radius: 14px; - box-shadow: 0 4px 16px 0 rgba(0,0,0,0.32), 0 1px 3px 0 rgba(0,0,0,0.18) inset; - border: 1px solid rgba(60,60,80,0.18); + background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%); + backdrop-filter: blur(20px) saturate(160%); + -webkit-backdrop-filter: blur(20px) saturate(160%); + border-radius: 14px; + box-shadow: + 0 4px 16px 0 rgba(0, 0, 0, 0.32), + 0 1px 3px 0 rgba(0, 0, 0, 0.18) inset; + border: 1px solid rgba(60, 60, 80, 0.18); } .sourceCard { - border-radius: 10px; - background: linear-gradient(120deg, rgba(38,38,48,0.98) 0%, rgba(24,24,32,0.96) 100%); - border: 1px solid rgba(60,60,80,0.22); - box-shadow: 0 2px 8px 0 rgba(0,0,0,0.18); - transition: box-shadow 0.2s, border 0.2s, background 0.2s; + border-radius: 10px; + background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%); + border: 1px solid rgba(60, 60, 80, 0.22); + box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18); + transition: + box-shadow 0.2s, + border 0.2s, + background 0.2s; } .selected { - border: 2px solid #2563EB; - background: linear-gradient(120deg, rgba(91,33,182,0.18) 0%, rgba(38,38,48,0.98) 100%); - box-shadow: 0 0 0 2px #2563EB33; + border: 2px solid #2563eb; + background: linear-gradient(120deg, rgba(91, 33, 182, 0.18) 0%, rgba(38, 38, 48, 0.98) 100%); + box-shadow: 0 0 0 2px #2563eb33; } .icon { - width: 13px; - height: 13px; - color: #c7d2fe; + width: 13px; + height: 13px; + color: #c7d2fe; } .name { - font-size: 0.85rem; - color: #f3f4f6; - font-weight: 500; - letter-spacing: 0.01em; + font-size: 0.85rem; + color: #f3f4f6; + font-weight: 500; + letter-spacing: 0.01em; } .cardText { - color: #a1a1aa; - font-size: 0.75rem; + color: #a1a1aa; + font-size: 0.75rem; } /* scrollbar */ .sourceGridScroll { - scrollbar-width: thin; - scrollbar-color: rgba(37, 99, 235, 0.5) rgba(40, 40, 50, 0.6); + scrollbar-width: thin; + scrollbar-color: rgba(37, 99, 235, 0.5) rgba(40, 40, 50, 0.6); } .sourceGridScroll::-webkit-scrollbar { - width: 8px; + width: 8px; } .sourceGridScroll::-webkit-scrollbar-track { - background: rgba(30, 30, 38, 0.5); - border-radius: 4px; - margin: 4px 0; + background: rgba(30, 30, 38, 0.5); + border-radius: 4px; + margin: 4px 0; } .sourceGridScroll::-webkit-scrollbar-thumb { - background: rgba(80, 80, 100, 0.6); - border-radius: 4px; + background: rgba(80, 80, 100, 0.6); + border-radius: 4px; } .sourceGridScroll::-webkit-scrollbar-thumb:hover { - background: rgba(37, 99, 235, 0.6); + background: rgba(37, 99, 235, 0.6); } .sourceGridScroll::-webkit-scrollbar-thumb:active { - background: rgba(37, 99, 235, 0.8); + background: rgba(37, 99, 235, 0.8); } - diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 9b98e329..d4c01da6 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -1,245 +1,282 @@ -import { useState, useEffect } from "react"; -import { Button } from "../ui/button"; +import { useEffect, useState } from "react"; import { MdCheck } from "react-icons/md"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; -import { Card } from "../ui/card"; import { useScopedT } from "../../contexts/I18nContext"; +import { Button } from "../ui/button"; +import { Card } from "../ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import styles from "./SourceSelector.module.css"; interface DesktopSource { - id: string; - name: string; - thumbnail: string | null; - display_id: string; - appIcon: string | null; - originalName: string; - sourceType: 'screen' | 'window'; - appName?: string; - windowTitle?: string; + id: string; + name: string; + thumbnail: string | null; + display_id: string; + appIcon: string | null; + originalName: string; + sourceType: "screen" | "window"; + appName?: string; + windowTitle?: string; } function parseSourceMetadata(source: ProcessedDesktopSource) { - if (source.sourceType === 'window' && (source.appName || source.windowTitle)) { - return { - sourceType: 'window' as const, - appName: source.appName, - windowTitle: source.windowTitle ?? source.name, - displayName: source.windowTitle ?? source.name, - }; - } + if (source.sourceType === "window" && (source.appName || source.windowTitle)) { + return { + sourceType: "window" as const, + appName: source.appName, + windowTitle: source.windowTitle ?? source.name, + displayName: source.windowTitle ?? source.name, + }; + } - const sourceType: 'screen' | 'window' = source.id.startsWith('window:') ? 'window' : 'screen'; - if (sourceType === 'window') { - const [appNamePart, ...windowTitleParts] = source.name.split(' — '); - const appName = appNamePart?.trim() || undefined; - const windowTitle = windowTitleParts.join(' — ').trim() || source.name.trim(); + const sourceType: "screen" | "window" = source.id.startsWith("window:") ? "window" : "screen"; + if (sourceType === "window") { + const [appNamePart, ...windowTitleParts] = source.name.split(" — "); + const appName = appNamePart?.trim() || undefined; + const windowTitle = windowTitleParts.join(" — ").trim() || source.name.trim(); - return { - sourceType, - appName, - windowTitle, - displayName: windowTitle, - }; - } + return { + sourceType, + appName, + windowTitle, + displayName: windowTitle, + }; + } - return { - sourceType, - appName: undefined, - windowTitle: undefined, - displayName: source.name, - }; + return { + sourceType, + appName: undefined, + windowTitle: undefined, + displayName: source.name, + }; } export function SourceSelector() { - const t = useScopedT('launch'); - const [sources, setSources] = useState([]); - const [selectedSource, setSelectedSource] = useState(null); - const [activeTab, setActiveTab] = useState<'screens' | 'windows'>('screens'); - const [loading, setLoading] = useState(true); + const t = useScopedT("launch"); + const [sources, setSources] = useState([]); + const [selectedSource, setSelectedSource] = useState(null); + const [activeTab, setActiveTab] = useState<"screens" | "windows">("screens"); + const [loading, setLoading] = useState(true); - useEffect(() => { - async function fetchSources() { - setLoading(true); - try { - const rawSources = await window.electronAPI.getSources({ - types: ['screen', 'window'], - thumbnailSize: { width: 320, height: 180 }, - fetchWindowIcons: true - }); - setSources( - rawSources.map(source => { - const metadata = parseSourceMetadata(source); + useEffect(() => { + async function fetchSources() { + setLoading(true); + try { + const rawSources = await window.electronAPI.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 320, height: 180 }, + fetchWindowIcons: true, + }); + setSources( + rawSources.map((source) => { + const metadata = parseSourceMetadata(source); - return { - id: source.id, - name: metadata.displayName, - thumbnail: source.thumbnail, - display_id: source.display_id, - appIcon: source.appIcon, - originalName: source.name, - sourceType: metadata.sourceType, - appName: metadata.appName, - windowTitle: metadata.windowTitle, - }; - }) - ); - } catch (error) { - console.error('Error loading sources:', error); - } finally { - setLoading(false); - } - } - fetchSources(); - }, []); + return { + id: source.id, + name: metadata.displayName, + thumbnail: source.thumbnail, + display_id: source.display_id, + appIcon: source.appIcon, + originalName: source.name, + sourceType: metadata.sourceType, + appName: metadata.appName, + windowTitle: metadata.windowTitle, + }; + }), + ); + } catch (error) { + console.error("Error loading sources:", error); + } finally { + setLoading(false); + } + } + fetchSources(); + }, []); - const screenSources = sources.filter(s => s.id.startsWith('screen:')); - const windowSources = sources.filter(s => s.id.startsWith('window:')); + const screenSources = sources.filter((s) => s.id.startsWith("screen:")); + const windowSources = sources.filter((s) => s.id.startsWith("window:")); - useEffect(() => { - if (loading) { - return; - } + useEffect(() => { + if (loading) { + return; + } - if (screenSources.length === 0 && windowSources.length > 0) { - setActiveTab('windows'); - return; - } + if (screenSources.length === 0 && windowSources.length > 0) { + setActiveTab("windows"); + return; + } - if (windowSources.length === 0 && screenSources.length > 0) { - setActiveTab('screens'); - } - }, [loading, screenSources.length, windowSources.length]); + if (windowSources.length === 0 && screenSources.length > 0) { + setActiveTab("screens"); + } + }, [loading, screenSources.length, windowSources.length]); - const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source); - const handleShare = async () => { - if (selectedSource) await window.electronAPI.selectSource(selectedSource); - }; + const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source); + const handleShare = async () => { + if (selectedSource) await window.electronAPI.selectSource(selectedSource); + }; - if (loading) { - return ( -
-
-
-

{t('sourceSelector.loadingSources')}

-
-
- ); - } + if (loading) { + return ( +
+
+
+

{t("sourceSelector.loadingSources")}

+
+
+ ); + } - return ( -
-
- setActiveTab(value as 'screens' | 'windows')}> - - - {t('sourceSelector.screens')} ({screenSources.length}) - - - {t('sourceSelector.windows')} ({windowSources.length}) - - -
- -
- {screenSources.length === 0 && ( -
No screens available
- )} - {screenSources.map(source => ( - handleSourceSelect(source)} - > -
-
- {source.name} - {selectedSource?.id === source.id && ( -
-
- -
-
- )} -
-
{source.name}
-
-
- ))} -
-
- -

{t('sourceSelector.windowsNote')}

-
- {windowSources.length === 0 && ( -
No windows available
- )} - {windowSources.map(source => ( - handleSourceSelect(source)} - > -
-
- {source.thumbnail ? ( - {source.name} - ) : ( -
- {source.appIcon ? ( - App icon - ) : ( -
- )} -
{t('sourceSelector.windowPlaceholder')}
-
- )} - {selectedSource?.id === source.id && ( -
-
- -
-
- )} -
-
- {source.appIcon && ( - App icon - )} -
{source.name}
-
-
- - ))} -
- -
- -
-
-
- - -
-
-
- ); + return ( +
+
+ setActiveTab(value as "screens" | "windows")} + > + + + {t("sourceSelector.screens")} ({screenSources.length}) + + + {t("sourceSelector.windows")} ({windowSources.length}) + + +
+ +
+ {screenSources.length === 0 && ( +
+ No screens available +
+ )} + {screenSources.map((source) => ( + handleSourceSelect(source)} + > +
+
+ {source.name} + {selectedSource?.id === source.id && ( +
+
+ +
+
+ )} +
+
{source.name}
+
+
+ ))} +
+
+ +

+ {t("sourceSelector.windowsNote")} +

+
+ {windowSources.length === 0 && ( +
+ No windows available +
+ )} + {windowSources.map((source) => ( + handleSourceSelect(source)} + > +
+
+ {source.thumbnail ? ( + {source.name} + ) : ( +
+ {source.appIcon ? ( + App icon + ) : ( +
+ )} +
+ {t("sourceSelector.windowPlaceholder")} +
+
+ )} + {selectedSource?.id === source.id && ( +
+
+ +
+
+ )} +
+
+ {source.appIcon && ( + App icon + )} +
{source.name}
+
+
+ + ))} +
+ +
+ +
+
+
+ + +
+
+
+ ); } - diff --git a/src/components/launch/UpdateToastWindow.tsx b/src/components/launch/UpdateToastWindow.tsx index 100a2339..eb7342d2 100644 --- a/src/components/launch/UpdateToastWindow.tsx +++ b/src/components/launch/UpdateToastWindow.tsx @@ -190,9 +190,7 @@ export function UpdateToastWindow() {

Checking for updates

-

- Waiting for updater state from the main process. -

+

Waiting for updater state from the main process.

@@ -287,7 +285,9 @@ export function UpdateToastWindow() { >
{payload.phase === "available" ? : null} - {payload.phase === "downloading" ? : null} + {payload.phase === "downloading" ? ( + + ) : null} {payload.phase === "ready" ? : null} {payload.phase === "error" ? : null}
@@ -295,7 +295,19 @@ export function UpdateToastWindow() {

{getToastTitle(payload)}

{payload.isPreview ? ( - + Dev ) : null} @@ -304,22 +316,39 @@ export function UpdateToastWindow() { {payload.phase === "downloading" ? (
-
+
-

{normalizedProgress}% downloaded

+

+ {normalizedProgress}% downloaded +

) : null}
{primaryActionLabel ? ( - ) : null} @@ -386,4 +415,4 @@ export function UpdateToastWindow() {
); -} \ No newline at end of file +} diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx index 8c106859..85336fde 100644 --- a/src/components/ui/accordion.tsx +++ b/src/components/ui/accordion.tsx @@ -1,56 +1,55 @@ -import * as React from "react" -import * as AccordionPrimitive from "@radix-ui/react-accordion" -import { ChevronDown } from "lucide-react" +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Accordion = AccordionPrimitive.Root +const Accordion = AccordionPrimitive.Root; const AccordionItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -AccordionItem.displayName = "AccordionItem" + +)); +AccordionItem.displayName = "AccordionItem"; const AccordionTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - - svg]:rotate-180", - className - )} - {...props} - > - {children} - - - -)) -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; const AccordionContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - -
{children}
-
-)) -AccordionContent.displayName = AccordionPrimitive.Content.displayName - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/src/components/ui/audio-level-meter.tsx b/src/components/ui/audio-level-meter.tsx index d5b209d2..55e4bd1e 100644 --- a/src/components/ui/audio-level-meter.tsx +++ b/src/components/ui/audio-level-meter.tsx @@ -1,37 +1,37 @@ interface AudioLevelMeterProps { - level: number; - className?: string; + level: number; + className?: string; } const bars = [ - { threshold: 10, height: '30%' }, - { threshold: 25, height: '45%' }, - { threshold: 45, height: '60%' }, - { threshold: 65, height: '75%' }, - { threshold: 85, height: '90%' }, -] + { threshold: 10, height: "30%" }, + { threshold: 25, height: "45%" }, + { threshold: 45, height: "60%" }, + { threshold: 65, height: "75%" }, + { threshold: 85, height: "90%" }, +]; function getBarColor(level: number, threshold: number) { - if (!level || level < threshold) return 'bg-slate-700' - if (threshold > 80) return 'bg-red-500' - if (threshold > 60) return 'bg-yellow-500' - if (threshold > 40) return 'bg-blue-500' - return 'bg-blue-400' + if (!level || level < threshold) return "bg-slate-700"; + if (threshold > 80) return "bg-red-500"; + if (threshold > 60) return "bg-yellow-500"; + if (threshold > 40) return "bg-blue-500"; + return "bg-blue-400"; } -export function AudioLevelMeter({ level, className = '' }: AudioLevelMeterProps) { - return ( -
- {bars.map((bar, index) => ( -
= bar.threshold ? bar.height : '15%', - opacity: level >= bar.threshold ? 1 : 0.4, - }} - /> - ))} -
- ) +export function AudioLevelMeter({ level, className = "" }: AudioLevelMeterProps) { + return ( +
+ {bars.map((bar, index) => ( +
= bar.threshold ? bar.height : "15%", + opacity: level >= bar.threshold ? 1 : 0.4, + }} + /> + ))} +
+ ); } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 266f0e09..c4606018 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,58 +1,50 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - variant: { - default: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", - outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2", - sm: "h-8 rounded-md px-3 text-xs", - lg: "h-10 rounded-md px-8", - icon: "h-9 w-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" - return ( - - ) - } -) -Button.displayName = "Button" - -export { Button, buttonVariants } + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; +export { Button, buttonVariants }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index d7ca9454..2935ed46 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,77 +1,55 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -const Card = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -Card.displayName = "Card" - -const CardHeader = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -CardHeader.displayName = "CardHeader" - -const CardTitle = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -CardTitle.displayName = "CardTitle" - -const CardDescription = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -CardDescription.displayName = "CardDescription" - -const CardContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -CardContent.displayName = "CardContent" - -const CardFooter = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -CardFooter.displayName = "CardFooter" - -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } - +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/src/components/ui/content-clamp.tsx b/src/components/ui/content-clamp.tsx index 4435e9ec..f03e4df1 100644 --- a/src/components/ui/content-clamp.tsx +++ b/src/components/ui/content-clamp.tsx @@ -1,87 +1,81 @@ -"use client" +"use client"; -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" -import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "./popover" +import { cn } from "@/lib/utils"; +import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "./popover"; interface ContentClampProps extends React.HTMLAttributes { - children: React.ReactNode - truncateLength?: number + children: React.ReactNode; + truncateLength?: number; } -function ContentClamp({ - children, - className, - truncateLength = 50, - ...props -}: ContentClampProps) { - const text = typeof children === "string" ? children : String(children ?? "") - const isTruncated = text.length > truncateLength +function ContentClamp({ children, className, truncateLength = 50, ...props }: ContentClampProps) { + const text = typeof children === "string" ? children : String(children ?? ""); + const isTruncated = text.length > truncateLength; - const [open, setOpen] = React.useState(false) - const timeoutRef = React.useRef(null) + const [open, setOpen] = React.useState(false); + const timeoutRef = React.useRef(null); - const handleMouseEnter = () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - timeoutRef.current = null - } - setOpen(true) - } + const handleMouseEnter = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setOpen(true); + }; - const handleMouseLeave = () => { - timeoutRef.current = setTimeout(() => { - setOpen(false) - }, 100) - } + const handleMouseLeave = () => { + timeoutRef.current = setTimeout(() => { + setOpen(false); + }, 100); + }; - React.useEffect(() => { - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - } - } - }, []) + React.useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); - if (!isTruncated) { - return ( -
- {children} -
- ) - } + if (!isTruncated) { + return ( +
+ {children} +
+ ); + } - const truncatedText = text.slice(0, truncateLength) + "..." + const truncatedText = text.slice(0, truncateLength) + "..."; - return ( - - - e.preventDefault()} - {...props} - > - {truncatedText} - - - e.preventDefault()} - onClick={(e) => e.stopPropagation()} - > - - {children} - - - ) + return ( + + + e.preventDefault()} + {...props} + > + {truncatedText} + + + e.preventDefault()} + onClick={(e) => e.stopPropagation()} + > + + {children} + + + ); } -export { ContentClamp } - +export { ContentClamp }; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 697b3b88..0c3efbb2 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,121 +1,102 @@ -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { X } from "lucide-react" +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Dialog = DialogPrimitive.Root +const Dialog = DialogPrimitive.Root; -const DialogTrigger = DialogPrimitive.Trigger +const DialogTrigger = DialogPrimitive.Trigger; -const DialogPortal = DialogPrimitive.Portal +const DialogPortal = DialogPrimitive.Portal; -const DialogClose = DialogPrimitive.Close +const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)) -DialogContent.displayName = DialogPrimitive.Content.displayName + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; -const DialogHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -DialogHeader.displayName = "DialogHeader" +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; -const DialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -DialogFooter.displayName = "DialogFooter" +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; export { - Dialog, - DialogPortal, - DialogOverlay, - DialogTrigger, - DialogClose, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -} - + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index d8ce2db3..c15187de 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,200 +1,186 @@ -import * as React from "react" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { Check, ChevronRight, Circle } from "lucide-react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const DropdownMenu = DropdownMenuPrimitive.Root +const DropdownMenu = DropdownMenuPrimitive.Root; -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; -const DropdownMenuGroup = DropdownMenuPrimitive.Group +const DropdownMenuGroup = DropdownMenuPrimitive.Group; -const DropdownMenuPortal = DropdownMenuPrimitive.Portal +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; -const DropdownMenuSub = DropdownMenuPrimitive.Sub +const DropdownMenuSub = DropdownMenuPrimitive.Sub; -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } >(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)) -DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, sideOffset = 4, ...props }, ref) => ( - - - -)) -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } >(({ className, inset, ...props }, ref) => ( - svg]:size-4 [&>svg]:shrink-0", - inset && "pl-8", - className - )} - {...props} - /> -)) -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)) -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)) -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } >(({ className, inset, ...props }, ref) => ( - -)) -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName - -const DropdownMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ) -} -DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, -} - + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 7ba03f87..b5e4484c 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,25 +1,23 @@ -import * as React from "react" -import { cn } from "@/lib/utils" +import * as React from "react"; +import { cn } from "@/lib/utils"; -export interface InputProps - extends React.InputHTMLAttributes {} +export interface InputProps extends React.InputHTMLAttributes {} const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { - return ( - - ) - } -) -Input.displayName = "Input" + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; -export { Input } - +export { Input }; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx index 67784787..fe3a2883 100644 --- a/src/components/ui/label.tsx +++ b/src/components/ui/label.tsx @@ -1,24 +1,20 @@ -import * as React from "react" -import { cn } from "@/lib/utils" +import * as React from "react"; +import { cn } from "@/lib/utils"; -export interface LabelProps - extends React.LabelHTMLAttributes {} +export interface LabelProps extends React.LabelHTMLAttributes {} -const Label = React.forwardRef( - ({ className, ...props }, ref) => { - return ( -