diff --git a/.github/actions/merge-macos-metadata/action.yml b/.github/actions/merge-macos-metadata/action.yml new file mode 100644 index 00000000..e7db52cd --- /dev/null +++ b/.github/actions/merge-macos-metadata/action.yml @@ -0,0 +1,66 @@ +name: Merge macOS metadata +description: Merge x64 and arm64 electron-updater metadata into a single latest-mac.yml file + +inputs: + x64-directory: + description: Directory containing the x64 latest-mac.yml file + required: true + arm64-directory: + description: Directory containing the arm64 latest-mac.yml file + required: true + output-file: + description: Path where the merged latest-mac.yml should be written + required: true + +runs: + using: composite + steps: + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install PyYAML + shell: bash + run: python -m pip install pyyaml + + - name: Merge latest-mac.yml + shell: bash + env: + X64_DIRECTORY: ${{ inputs.x64-directory }} + ARM64_DIRECTORY: ${{ inputs.arm64-directory }} + OUTPUT_FILE: ${{ inputs.output-file }} + run: | + set -euo pipefail + mkdir -p "$(dirname "$OUTPUT_FILE")" + python <<'PY' + from pathlib import Path + import os + + import yaml + + x64_info = yaml.safe_load(Path(os.environ['X64_DIRECTORY'], 'latest-mac.yml').read_text()) + arm64_info = yaml.safe_load(Path(os.environ['ARM64_DIRECTORY'], 'latest-mac.yml').read_text()) + + merged_files = [] + seen_urls = set() + for info in (x64_info, arm64_info): + for file_info in info.get('files', []): + url = file_info.get('url') + if url and url not in seen_urls: + seen_urls.add(url) + merged_files.append(file_info) + + if not merged_files: + raise SystemExit('No macOS update files found to merge.') + + preferred = next((item for item in merged_files if 'x64' in item.get('url', '')), merged_files[0]) + merged = dict(x64_info) + merged['files'] = merged_files + merged['path'] = preferred.get('url', merged.get('path')) + merged['sha512'] = preferred.get('sha512', merged.get('sha512')) + if not merged.get('releaseDate') and arm64_info.get('releaseDate'): + merged['releaseDate'] = arm64_info['releaseDate'] + + Path(os.environ['OUTPUT_FILE']).write_text(yaml.safe_dump(merged, sort_keys=False)) + PY \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b976c249..1e5a08c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -252,50 +252,12 @@ jobs: name: macos-arm64-release path: release-assets/macos-arm64 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install PyYAML - run: python -m pip install pyyaml - - name: Merge latest-mac.yml - shell: bash - run: | - set -euo pipefail - mkdir -p release-assets/merged - python <<'PY' - from pathlib import Path - import yaml - - x64_info = yaml.safe_load(Path('release-assets/macos-x64/latest-mac.yml').read_text()) - arm64_info = yaml.safe_load(Path('release-assets/macos-arm64/latest-mac.yml').read_text()) - - merged_files = [] - seen_urls = set() - for info in (x64_info, arm64_info): - for file_info in info.get('files', []): - url = file_info.get('url') - if url and url not in seen_urls: - seen_urls.add(url) - merged_files.append(file_info) - - if not merged_files: - raise SystemExit('No macOS update files found to merge.') - - preferred = next((item for item in merged_files if 'x64' in item.get('url', '')), merged_files[0]) - merged = dict(x64_info) - merged['files'] = merged_files - merged['path'] = preferred.get('url', merged.get('path')) - merged['sha512'] = preferred.get('sha512', merged.get('sha512')) - if not merged.get('releaseDate') and arm64_info.get('releaseDate'): - merged['releaseDate'] = arm64_info['releaseDate'] - - Path('release-assets/merged/latest-mac.yml').write_text( - yaml.safe_dump(merged, sort_keys=False) - ) - PY + uses: ./.github/actions/merge-macos-metadata + with: + x64-directory: release-assets/macos-x64 + arm64-directory: release-assets/macos-arm64 + output-file: release-assets/merged/latest-mac.yml - name: Upload merged latest-mac.yml artifact uses: actions/upload-artifact@v4 diff --git a/electron/editorWindows.ts b/electron/editorWindows.ts new file mode 100644 index 00000000..b260c151 --- /dev/null +++ b/electron/editorWindows.ts @@ -0,0 +1,293 @@ +import path from "node:path"; +import { BrowserWindow } from "electron"; +import { getPackagedRendererBaseUrl } from "./rendererServer"; +import { + PRELOAD_PATH, + RENDERER_DIST, + VITE_DEV_SERVER_URL, + WINDOW_ICON_PATH, + getScreen, + loadRendererWindow, +} from "./windowShared"; + +let countdownWindow: BrowserWindow | null = null; + +function getEditorWindowQuery(): Record { + const query: Record = { windowType: "editor" }; + + if (process.env.RECORDLY_SMOKE_EXPORT !== "1") { + return query; + } + + query.smokeExport = "1"; + const mappings: Array<[string, string]> = [ + ["RECORDLY_SMOKE_EXPORT_INPUT", "smokeInput"], + ["RECORDLY_SMOKE_EXPORT_OUTPUT", "smokeOutput"], + ["RECORDLY_SMOKE_EXPORT_ENCODING_MODE", "smokeEncodingMode"], + ["RECORDLY_SMOKE_EXPORT_SHADOW_INTENSITY", "smokeShadowIntensity"], + ["RECORDLY_SMOKE_EXPORT_WEBCAM_INPUT", "smokeWebcamInput"], + ["RECORDLY_SMOKE_EXPORT_WEBCAM_SHADOW", "smokeWebcamShadow"], + ["RECORDLY_SMOKE_EXPORT_WEBCAM_SIZE", "smokeWebcamSize"], + ["RECORDLY_SMOKE_EXPORT_PIPELINE", "smokePipelineModel"], + ["RECORDLY_SMOKE_EXPORT_BACKEND", "smokeBackendPreference"], + ["RECORDLY_SMOKE_EXPORT_MAX_ENCODE_QUEUE", "smokeMaxEncodeQueue"], + ["RECORDLY_SMOKE_EXPORT_MAX_DECODE_QUEUE", "smokeMaxDecodeQueue"], + ["RECORDLY_SMOKE_EXPORT_MAX_PENDING_FRAMES", "smokeMaxPendingFrames"], + ]; + + for (const [envKey, queryKey] of mappings) { + const value = process.env[envKey]; + if (value) { + query[queryKey] = value; + } + } + + if (process.env.RECORDLY_SMOKE_EXPORT_USE_NATIVE === "1") { + query.smokeUseNativeExport = "1"; + } + + return query; +} + +function loadPackagedEditorWindow(window: BrowserWindow) { + const query = getEditorWindowQuery(); + const queryString = new URLSearchParams(query).toString(); + const indexHtmlPath = path.join(RENDERER_DIST, "index.html"); + const packagedRendererBaseUrl = getPackagedRendererBaseUrl(); + const webContents = window.webContents; + + const loadFromFile = () => { + if (!window.isDestroyed()) { + console.log("[editor-window] load-file", indexHtmlPath); + void window.loadFile(indexHtmlPath, { query }); + } + }; + + if (!packagedRendererBaseUrl) { + loadFromFile(); + return; + } + + const targetUrl = `${packagedRendererBaseUrl}/?${queryString}`; + let settled = false; + let timeoutId: NodeJS.Timeout | null = setTimeout(() => { + fallbackToFile("load-timeout"); + }, 5000); + + const clearTimeoutIfNeeded = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + + const detachLoadListeners = () => { + clearTimeoutIfNeeded(); + if (!webContents.isDestroyed()) { + webContents.removeListener("did-fail-load", handleDidFailLoad); + webContents.removeListener("did-finish-load", handleDidFinishLoad); + } + }; + + const fallbackToFile = (reason: string, details?: Record) => { + if (settled || window.isDestroyed()) { + return; + } + + settled = true; + detachLoadListeners(); + console.warn("[editor-window] packaged renderer URL failed, falling back to file", { + reason, + targetUrl, + ...details, + }); + loadFromFile(); + }; + + const handleDidFailLoad = ( + _event: Electron.Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean, + ) => { + if (isMainFrame && validatedURL === targetUrl) { + fallbackToFile("did-fail-load", { errorCode, errorDescription, validatedURL }); + } + }; + + const handleDidFinishLoad = () => { + if (webContents.getURL() === targetUrl) { + settled = true; + detachLoadListeners(); + } + }; + + webContents.on("did-fail-load", handleDidFailLoad); + webContents.on("did-finish-load", handleDidFinishLoad); + window.once("closed", clearTimeoutIfNeeded); + + console.log("[editor-window] load-url", targetUrl); + void window.loadURL(targetUrl).catch((error) => { + fallbackToFile("load-url-rejected", { + error: error instanceof Error ? error.message : String(error), + }); + }); +} + +export function createEditorWindow(): BrowserWindow { + const isMac = process.platform === "darwin"; + const { workArea, workAreaSize } = getScreen().getPrimaryDisplay(); + const initialWidth = isMac ? Math.round(workAreaSize.width * 0.85) : workArea.width; + const initialHeight = isMac ? Math.round(workAreaSize.height * 0.85) : workArea.height; + const window = new BrowserWindow({ + width: initialWidth, + height: initialHeight, + ...(!isMac && { x: workArea.x, y: workArea.y }), + minWidth: 800, + minHeight: 600, + ...(process.platform !== "darwin" && { icon: WINDOW_ICON_PATH }), + ...(isMac && { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 12, y: 12 }, + }), + autoHideMenuBar: !isMac, + transparent: false, + resizable: true, + alwaysOnTop: false, + skipTaskbar: false, + title: "Recordly", + show: false, + backgroundColor: "#000000", + webPreferences: { + preload: PRELOAD_PATH, + nodeIntegration: false, + contextIsolation: true, + webSecurity: false, + backgroundThrottling: false, + }, + }); + + window.once("ready-to-show", () => { + console.log("[editor-window] ready-to-show"); + window.show(); + }); + + window.webContents.on("did-finish-load", () => { + console.log("[editor-window] did-finish-load", window.webContents.getURL()); + window.webContents.send("main-process-message", new Date().toLocaleString()); + }); + + window.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => { + console.error("[editor-window] did-fail-load", { errorCode, errorDescription, validatedURL }); + }); + + window.webContents.on("render-process-gone", (_event, details) => { + console.error("[editor-window] render-process-gone", details); + }); + + window.on("show", () => { + console.log("[editor-window] show"); + }); + + window.on("focus", () => { + console.log("[editor-window] focus"); + }); + + if (VITE_DEV_SERVER_URL) { + const query = new URLSearchParams(getEditorWindowQuery()); + void window.loadURL(`${VITE_DEV_SERVER_URL}?${query.toString()}`); + } else { + loadPackagedEditorWindow(window); + } + + return window; +} + +export function createSourceSelectorWindow(): BrowserWindow { + const { width, height } = getScreen().getPrimaryDisplay().workAreaSize; + const window = new BrowserWindow({ + width: 620, + height: 420, + minHeight: 350, + maxHeight: 500, + x: Math.round((width - 620) / 2), + y: Math.round((height - 420) / 2), + frame: false, + resizable: false, + alwaysOnTop: true, + transparent: true, + show: false, + ...(process.platform !== "darwin" && { icon: WINDOW_ICON_PATH }), + backgroundColor: "#00000000", + webPreferences: { + preload: PRELOAD_PATH, + nodeIntegration: false, + contextIsolation: true, + }, + }); + + window.webContents.on("did-finish-load", () => { + setTimeout(() => { + if (!window.isDestroyed()) { + window.show(); + } + }, 100); + }); + + loadRendererWindow(window, "source-selector"); + return window; +} + +export function createCountdownWindow(): BrowserWindow { + const { width, height } = getScreen().getPrimaryDisplay().workAreaSize; + const windowSize = 200; + const window = new BrowserWindow({ + width: windowSize, + height: windowSize, + x: Math.floor((width - windowSize) / 2), + y: Math.floor((height - windowSize) / 2), + frame: false, + transparent: true, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + focusable: true, + show: false, + webPreferences: { + preload: PRELOAD_PATH, + nodeIntegration: false, + contextIsolation: true, + }, + }); + + countdownWindow = window; + window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + + window.webContents.on("did-finish-load", () => { + if (!window.isDestroyed()) { + window.show(); + } + }); + + window.on("closed", () => { + if (countdownWindow === window) { + countdownWindow = null; + } + }); + + loadRendererWindow(window, "countdown"); + return window; +} + +export function getCountdownWindow(): BrowserWindow | null { + return countdownWindow; +} + +export function closeCountdownWindow(): void { + if (countdownWindow && !countdownWindow.isDestroyed()) { + countdownWindow.close(); + countdownWindow = null; + } +} \ No newline at end of file diff --git a/electron/electron-api-capture.d.ts b/electron/electron-api-capture.d.ts new file mode 100644 index 00000000..d208c165 --- /dev/null +++ b/electron/electron-api-capture.d.ts @@ -0,0 +1,125 @@ +interface ElectronAPICapture { + hudOverlaySetIgnoreMouse: (ignore: boolean) => void; + hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => void; + hudOverlayHide: () => void; + hudOverlayClose: () => void; + setHudOverlayExpanded: (expanded: boolean) => void; + setHudOverlayCompactWidth: (width: number) => void; + setHudOverlayMeasuredHeight: (height: number, expanded: boolean) => void; + getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>; + setHudOverlayCaptureProtection: ( + enabled: boolean, + ) => Promise<{ success: boolean; enabled: boolean }>; + getAssetBasePath: () => Promise; + getSources: (opts: Electron.SourcesOptions) => Promise; + switchToEditor: () => Promise; + openSourceSelector: () => Promise; + selectSource: (source: ProcessedDesktopSource) => Promise; + showSourceHighlight: (source: ProcessedDesktopSource) => Promise<{ success: boolean }>; + getSelectedSource: () => Promise; + onSelectedSourceChanged: ( + callback: (source: ProcessedDesktopSource | null) => void, + ) => () => void; + startNativeScreenRecording: ( + source: ProcessedDesktopSource, + options?: { + capturesSystemAudio?: boolean; + capturesMicrophone?: boolean; + microphoneDeviceId?: string; + microphoneLabel?: string; + }, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + userNotified?: boolean; + microphoneFallbackRequired?: boolean; + }>; + stopNativeScreenRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + recoverNativeScreenRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + getLastNativeCaptureDiagnostics: () => Promise<{ + success: boolean; + diagnostics?: NativeCaptureDiagnostics | null; + }>; + pauseNativeScreenRecording: () => Promise<{ + success: boolean; + message?: string; + error?: string; + }>; + resumeNativeScreenRecording: () => Promise<{ + success: boolean; + message?: string; + error?: string; + }>; + startFfmpegRecording: ( + source: ProcessedDesktopSource, + ) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>; + stopFfmpegRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + setRecordingState: (recording: boolean) => Promise; + getCursorTelemetry: (videoPath?: string) => Promise<{ + success: boolean; + samples: CursorTelemetryPoint[]; + message?: string; + error?: string; + }>; + getSystemCursorAssets: () => Promise<{ + success: boolean; + cursors: Record; + error?: string; + }>; + onStopRecordingFromTray: (callback: () => void) => () => void; + onRecordingStateChanged: ( + callback: (state: { recording: boolean; sourceName: string }) => void, + ) => () => void; + onRecordingInterrupted: ( + callback: (state: { reason: string; message: string }) => void, + ) => () => void; + onCursorStateChanged: ( + callback: (state: { cursorType: CursorTelemetryPoint["cursorType"] }) => void, + ) => () => void; + getAccessibilityPermissionStatus: () => Promise<{ + success: boolean; + trusted: boolean; + prompted: boolean; + error?: string; + }>; + requestAccessibilityPermission: () => Promise<{ + success: boolean; + trusted: boolean; + prompted: boolean; + error?: string; + }>; + getScreenRecordingPermissionStatus: () => Promise<{ + success: boolean; + status: string; + error?: string; + }>; + openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }>; + openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }>; + isNativeWindowsCaptureAvailable: () => Promise<{ available: boolean }>; + muxNativeWindowsRecording: ( + pauseSegments?: Array<{ startMs: number; endMs: number }>, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + hideOsCursor: () => Promise<{ success: boolean }>; +} \ No newline at end of file diff --git a/electron/electron-api-export.d.ts b/electron/electron-api-export.d.ts new file mode 100644 index 00000000..598692c9 --- /dev/null +++ b/electron/electron-api-export.d.ts @@ -0,0 +1,132 @@ +interface ElectronAPIExport { + storeRecordedVideo: ( + videoData: ArrayBuffer, + fileName: string, + ) => Promise<{ success: boolean; path?: string; message?: string }>; + storeMicrophoneSidecar: ( + audioData: ArrayBuffer, + videoPath: string, + ) => Promise<{ success: boolean; path?: string; error?: string }>; + getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>; + listAssetDirectory: (relativeDir: string) => Promise<{ + success: boolean; + files?: string[]; + error?: string; + }>; + readLocalFile: (filePath: string) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>; + generateWallpaperThumbnail: ( + filePath: string, + ) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>; + nativeVideoExportStart: (options: { + width: number; + height: number; + frameRate: number; + bitrate: number; + encodingMode: "fast" | "balanced" | "quality"; + inputMode?: "rawvideo" | "h264-stream"; + }) => Promise<{ + success: boolean; + sessionId?: string; + encoderName?: string; + error?: string; + }>; + nativeVideoExportWriteFrame: ( + sessionId: string, + frameData: Uint8Array, + ) => Promise<{ success: boolean; error?: string }>; + nativeVideoExportFinish: ( + sessionId: string, + options?: { + audioMode?: "none" | "copy-source" | "trim-source" | "edited-track"; + audioSourcePath?: string | null; + trimSegments?: Array<{ startMs: number; endMs: number }>; + editedAudioData?: ArrayBuffer; + editedAudioMimeType?: string | null; + }, + ) => Promise<{ + success: boolean; + data?: Uint8Array; + encoderName?: string; + error?: string; + }>; + nativeVideoExportCancel: ( + sessionId: string, + ) => Promise<{ success: boolean; error?: string }>; + muxExportedVideoAudio: ( + videoData: ArrayBuffer, + options?: { + audioMode?: "none" | "copy-source" | "trim-source" | "edited-track"; + audioSourcePath?: string | null; + trimSegments?: Array<{ startMs: number; endMs: number }>; + editedAudioData?: ArrayBuffer; + editedAudioMimeType?: string | null; + }, + ) => Promise<{ + success: boolean; + data?: Uint8Array; + error?: string; + }>; + getVideoAudioFallbackPaths: ( + videoPath: string, + ) => Promise<{ success: boolean; paths: string[]; error?: string }>; + saveExportedVideo: ( + videoData: ArrayBuffer, + fileName: string, + ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; + writeExportedVideoToPath: ( + videoData: ArrayBuffer, + outputPath: string, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + canceled?: boolean; + }>; + openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; + openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; + openWhisperExecutablePicker: () => Promise<{ + success: boolean; + path?: string; + canceled?: boolean; + error?: string; + }>; + openWhisperModelPicker: () => Promise<{ + success: boolean; + path?: string; + canceled?: boolean; + error?: string; + }>; + getWhisperSmallModelStatus: () => Promise<{ + success: boolean; + exists: boolean; + path?: string | null; + error?: string; + }>; + downloadWhisperSmallModel: () => Promise<{ + success: boolean; + path?: string; + alreadyDownloaded?: boolean; + error?: string; + }>; + deleteWhisperSmallModel: () => Promise<{ success: boolean; error?: string }>; + onWhisperSmallModelDownloadProgress: ( + callback: (state: { + status: "idle" | "downloading" | "downloaded" | "error"; + progress: number; + path?: string | null; + error?: string; + }) => void, + ) => () => void; + generateAutoCaptions: (options: { + videoPath: string; + whisperExecutablePath?: string; + whisperModelPath: string; + language?: string; + }) => Promise<{ + success: boolean; + cues?: AutoCaptionCue[]; + message?: string; + error?: string; + }>; +} \ No newline at end of file diff --git a/electron/electron-api-projects.d.ts b/electron/electron-api-projects.d.ts new file mode 100644 index 00000000..70aefb87 --- /dev/null +++ b/electron/electron-api-projects.d.ts @@ -0,0 +1,94 @@ +interface ElectronAPIProjects { + setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; + setCurrentRecordingSession: (session: { + videoPath: string; + webcamPath?: string | null; + timeOffsetMs?: number; + }) => Promise<{ success: boolean }>; + getCurrentRecordingSession: () => Promise<{ + success: boolean; + session?: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }; + }>; + getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; + clearCurrentVideoPath: () => Promise<{ success: boolean }>; + deleteRecordingFile: (filePath: string) => Promise<{ success: boolean; error?: string }>; + getLocalMediaUrl: (filePath: string) => Promise<{ success: true; url: string } | { success: false }>; + saveProjectFile: ( + projectData: unknown, + suggestedName?: string, + existingProjectPath?: string, + thumbnailDataUrl?: string | null, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + error?: string; + }>; + loadProjectFile: () => Promise<{ + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; + }>; + loadCurrentProjectFile: () => Promise<{ + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; + }>; + getProjectsDirectory: () => Promise<{ success: boolean; path?: string; error?: string }>; + listProjectFiles: () => Promise<{ + success: boolean; + projectsDir?: string | null; + entries: Array<{ + path: string; + name: string; + updatedAt: number; + thumbnailPath: string | null; + isCurrent: boolean; + isInProjectsDirectory: boolean; + }>; + error?: string; + }>; + openProjectFileAtPath: (filePath: string) => Promise<{ + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; + }>; + openProjectsDirectory: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + getPlatform: () => Promise; + revealInFolder: (filePath: string) => Promise<{ success: boolean; error?: string; message?: string }>; + openRecordingsFolder: () => Promise<{ success: boolean; error?: string; message?: string }>; + getRecordingsDirectory: () => Promise<{ + success: boolean; + path: string; + isDefault: boolean; + error?: string; + }>; + chooseRecordingsDirectory: () => Promise<{ + success: boolean; + canceled?: boolean; + path?: string; + isDefault?: boolean; + message?: string; + error?: string; + }>; + getShortcuts: () => Promise | null>; + saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; + setHasUnsavedChanges: (hasChanges: boolean) => void; + onRequestSaveBeforeClose: (callback: () => Promise) => () => void; + getAppVersion: () => Promise; +} \ No newline at end of file diff --git a/electron/electron-api-settings.d.ts b/electron/electron-api-settings.d.ts new file mode 100644 index 00000000..fc312d63 --- /dev/null +++ b/electron/electron-api-settings.d.ts @@ -0,0 +1,81 @@ +interface ElectronAPISettings { + openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; + installDownloadedUpdate: () => Promise<{ success: boolean }>; + downloadAvailableUpdate: () => Promise<{ success: boolean; message?: string }>; + deferDownloadedUpdate: (delayMs?: number) => Promise<{ success: boolean; message?: string }>; + dismissUpdateToast: () => Promise<{ success: boolean }>; + skipUpdateVersion: () => Promise<{ success: boolean; message?: string }>; + getCurrentUpdateToastPayload: () => Promise; + getUpdateStatusSummary: () => Promise; + 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; + onMenuLoadProject: (callback: () => void) => () => void; + onMenuSaveProject: (callback: () => void) => () => void; + onMenuSaveProjectAs: (callback: () => void) => () => void; + getRecordingPreferences: () => Promise<{ + success: boolean; + microphoneEnabled: boolean; + microphoneDeviceId?: string; + systemAudioEnabled: boolean; + }>; + setRecordingPreferences: (prefs: { + microphoneEnabled?: boolean; + microphoneDeviceId?: string; + systemAudioEnabled?: boolean; + }) => Promise<{ success: boolean; error?: string }>; + getCountdownDelay: () => Promise<{ success: boolean; delay: number }>; + setCountdownDelay: (delay: number) => Promise<{ success: boolean; error?: string }>; + startCountdown: (seconds: number) => Promise<{ success: boolean; cancelled?: boolean }>; + cancelCountdown: () => Promise<{ success: boolean }>; + getActiveCountdown: () => Promise<{ success: boolean; seconds: number | null }>; + onCountdownTick: (callback: (seconds: number) => void) => () => void; + extensionsDiscover: () => Promise; + extensionsList: () => Promise; + extensionsGet: (id: string) => Promise; + extensionsEnable: (id: string) => Promise<{ success: boolean; error?: string }>; + extensionsDisable: (id: string) => Promise<{ success: boolean; error?: string }>; + extensionsInstallFromFolder: () => Promise<{ + success: boolean; + extension?: RendererExtensionInfo; + message?: string; + error?: string; + canceled?: boolean; + }>; + extensionsUninstall: (id: string) => Promise<{ success: boolean; error?: string }>; + extensionsGetDirectory: () => Promise<{ success: boolean; path?: string; error?: string }>; + extensionsOpenDirectory: () => Promise<{ success: boolean; path?: string; error?: string }>; + extensionsMarketplaceSearch: (params: { + query?: string; + tags?: string[]; + sort?: string; + page?: number; + pageSize?: number; + }) => Promise; + extensionsMarketplaceGet: (id: string) => Promise; + extensionsMarketplaceInstall: ( + extensionId: string, + downloadUrl: string, + ) => Promise<{ success: boolean; error?: string }>; + extensionsMarketplaceSubmit: ( + extensionId: string, + ) => Promise<{ success: boolean; reviewId?: string; error?: string }>; + extensionsReviewsList: (params: { + status?: RendererMarketplaceReviewStatus; + page?: number; + pageSize?: number; + }) => Promise<{ reviews: RendererExtensionReview[]; total: number; error?: string }>; + extensionsReviewUpdate: ( + reviewId: string, + status: RendererMarketplaceReviewStatus, + notes?: string, + ) => Promise<{ success: boolean; error?: string }>; +} \ No newline at end of file diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4eff6ece..ce9a8755 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -69,464 +69,27 @@ type RendererMarketplaceReviewStatus = type RendererMarketplaceSearchResult = import("./extensions/extensionTypes").MarketplaceSearchResult; +interface ElectronAPI + extends ElectronAPICapture, + ElectronAPIExport, + ElectronAPIProjects, + ElectronAPISettings {} + interface Window { - electronAPI: { - hudOverlaySetIgnoreMouse: (ignore: boolean) => void; - hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => void; - hudOverlayHide: () => void; - hudOverlayClose: () => void; - setHudOverlayExpanded: (expanded: boolean) => void; - setHudOverlayCompactWidth: (width: number) => void; - setHudOverlayMeasuredHeight: (height: number, expanded: boolean) => void; - getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>; - setHudOverlayCaptureProtection: ( - enabled: boolean, - ) => Promise<{ success: boolean; enabled: boolean }>; - getAssetBasePath: () => Promise; - getSources: (opts: Electron.SourcesOptions) => Promise; - switchToEditor: () => Promise; - openSourceSelector: () => Promise; - selectSource: (source: ProcessedDesktopSource) => Promise; - showSourceHighlight: (source: ProcessedDesktopSource) => Promise<{ success: boolean }>; - getSelectedSource: () => Promise; - onSelectedSourceChanged: ( - callback: (source: ProcessedDesktopSource | null) => void, - ) => () => void; - startNativeScreenRecording: ( - source: ProcessedDesktopSource, - options?: { - capturesSystemAudio?: boolean; - capturesMicrophone?: boolean; - microphoneDeviceId?: string; - microphoneLabel?: string; - }, - ) => Promise<{ - success: boolean; - path?: string; - message?: string; - error?: string; - userNotified?: boolean; - microphoneFallbackRequired?: boolean; - }>; - stopNativeScreenRecording: () => Promise<{ - success: boolean; - path?: string; - message?: string; - error?: string; - }>; - recoverNativeScreenRecording: () => Promise<{ - success: boolean; - path?: string; - message?: string; - error?: string; - }>; - getLastNativeCaptureDiagnostics: () => Promise<{ - success: boolean; - diagnostics?: NativeCaptureDiagnostics | null; - }>; - pauseNativeScreenRecording: () => Promise<{ - success: boolean; - message?: string; - error?: string; - }>; - resumeNativeScreenRecording: () => Promise<{ - success: boolean; - message?: string; - error?: string; - }>; - startFfmpegRecording: ( - source: ProcessedDesktopSource, - ) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>; - stopFfmpegRecording: () => Promise<{ - success: boolean; - path?: string; - message?: string; - error?: string; - }>; - storeRecordedVideo: ( - videoData: ArrayBuffer, - fileName: string, - ) => Promise<{ success: boolean; path?: string; message?: string }>; - storeMicrophoneSidecar: ( - audioData: ArrayBuffer, - videoPath: string, - ) => Promise<{ success: boolean; path?: string; error?: string }>; - getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>; - listAssetDirectory: (relativeDir: string) => Promise<{ - success: boolean; - files?: string[]; - error?: string; - }>; - readLocalFile: ( - filePath: string, - ) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>; - generateWallpaperThumbnail: ( - filePath: string, - ) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>; - nativeVideoExportStart: (options: { - width: number; - height: number; - frameRate: number; - bitrate: number; - encodingMode: "fast" | "balanced" | "quality"; - inputMode?: "rawvideo" | "h264-stream"; - }) => Promise<{ - success: boolean; - sessionId?: string; - encoderName?: string; - error?: string; - }>; - nativeVideoExportWriteFrame: ( - sessionId: string, - frameData: Uint8Array, - ) => Promise<{ success: boolean; error?: string }>; - nativeVideoExportFinish: ( - sessionId: string, - options?: { - audioMode?: "none" | "copy-source" | "trim-source" | "edited-track"; - audioSourcePath?: string | null; - trimSegments?: Array<{ startMs: number; endMs: number }>; - editedAudioData?: ArrayBuffer; - editedAudioMimeType?: string | null; - }, - ) => Promise<{ - success: boolean; - data?: Uint8Array; - encoderName?: string; - error?: string; - }>; - nativeVideoExportCancel: ( - sessionId: string, - ) => Promise<{ success: boolean; error?: string }>; - muxExportedVideoAudio: ( - videoData: ArrayBuffer, - options?: { - audioMode?: "none" | "copy-source" | "trim-source" | "edited-track"; - audioSourcePath?: string | null; - trimSegments?: Array<{ startMs: number; endMs: number }>; - editedAudioData?: ArrayBuffer; - editedAudioMimeType?: string | null; - }, - ) => Promise<{ - success: boolean; - data?: Uint8Array; - error?: string; - }>; - getVideoAudioFallbackPaths: ( - videoPath: string, - ) => Promise<{ success: boolean; paths: string[]; error?: string }>; - setRecordingState: (recording: boolean) => Promise; - getCursorTelemetry: (videoPath?: string) => Promise<{ - success: boolean; - samples: CursorTelemetryPoint[]; - message?: string; - error?: string; - }>; - getSystemCursorAssets: () => Promise<{ - success: boolean; - cursors: Record; - error?: string; - }>; - onStopRecordingFromTray: (callback: () => void) => () => void; - onRecordingStateChanged: ( - callback: (state: { recording: boolean; sourceName: string }) => void, - ) => () => void; - onRecordingInterrupted: ( - callback: (state: { reason: string; message: string }) => void, - ) => () => void; - onCursorStateChanged: ( - callback: (state: { cursorType: CursorTelemetryPoint["cursorType"] }) => void, - ) => () => void; - openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; - getAccessibilityPermissionStatus: () => Promise<{ - success: boolean; - trusted: boolean; - prompted: boolean; - error?: string; - }>; - requestAccessibilityPermission: () => Promise<{ - success: boolean; - trusted: boolean; - prompted: boolean; - error?: string; - }>; - getScreenRecordingPermissionStatus: () => Promise<{ - success: boolean; - status: string; - error?: string; - }>; - openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }>; - openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }>; - saveExportedVideo: ( - videoData: ArrayBuffer, - fileName: string, - ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; - writeExportedVideoToPath: ( - videoData: ArrayBuffer, - outputPath: string, - ) => Promise<{ - success: boolean; - path?: string; - message?: string; - error?: string; - canceled?: boolean; - }>; - openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; - openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; - openWhisperExecutablePicker: () => Promise<{ - success: boolean; - path?: string; - canceled?: boolean; - error?: string; - }>; - openWhisperModelPicker: () => Promise<{ - success: boolean; - path?: string; - canceled?: boolean; - error?: string; - }>; - getWhisperSmallModelStatus: () => Promise<{ - success: boolean; - exists: boolean; - path?: string | null; - error?: string; - }>; - downloadWhisperSmallModel: () => Promise<{ - success: boolean; - path?: string; - alreadyDownloaded?: boolean; - error?: string; - }>; - deleteWhisperSmallModel: () => Promise<{ success: boolean; error?: string }>; - onWhisperSmallModelDownloadProgress: ( - callback: (state: { - status: "idle" | "downloading" | "downloaded" | "error"; - progress: number; - path?: string | null; - error?: string; - }) => void, - ) => () => void; - generateAutoCaptions: (options: { - videoPath: string; - whisperExecutablePath?: string; - whisperModelPath: string; - language?: string; - }) => Promise<{ - success: boolean; - cues?: AutoCaptionCue[]; - message?: string; - error?: string; - }>; - setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; - setCurrentRecordingSession: (session: { - videoPath: string; - webcamPath?: string | null; - timeOffsetMs?: number; - }) => Promise<{ success: boolean }>; - getCurrentRecordingSession: () => Promise<{ - success: boolean; - session?: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }; - }>; - getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; - clearCurrentVideoPath: () => Promise<{ success: boolean }>; - deleteRecordingFile: (filePath: string) => Promise<{ success: boolean; error?: string }>; - getLocalMediaUrl: (filePath: string) => Promise< - { success: true; url: string } | { success: false } - >; - saveProjectFile: ( - projectData: unknown, - suggestedName?: string, - existingProjectPath?: string, - thumbnailDataUrl?: string | null, - ) => Promise<{ - success: boolean; - path?: string; - message?: string; - canceled?: boolean; - error?: string; - }>; - loadProjectFile: () => Promise<{ - success: boolean; - path?: string; - project?: unknown; - message?: string; - canceled?: boolean; - error?: string; - }>; - loadCurrentProjectFile: () => Promise<{ - success: boolean; - path?: string; - project?: unknown; - message?: string; - canceled?: boolean; - error?: string; - }>; - getProjectsDirectory: () => Promise<{ - success: boolean; - path?: string; - error?: string; - }>; - listProjectFiles: () => Promise<{ - success: boolean; - projectsDir?: string | null; - entries: Array<{ - path: string; - name: string; - updatedAt: number; - thumbnailPath: string | null; - isCurrent: boolean; - isInProjectsDirectory: boolean; - }>; - error?: string; - }>; - openProjectFileAtPath: (filePath: string) => Promise<{ - success: boolean; - path?: string; - project?: unknown; - message?: string; - canceled?: boolean; - error?: string; - }>; - openProjectsDirectory: () => Promise<{ - success: boolean; - path?: string; - message?: string; - error?: string; - }>; - installDownloadedUpdate: () => Promise<{ success: boolean }>; - downloadAvailableUpdate: () => Promise<{ success: boolean; message?: string }>; - deferDownloadedUpdate: (delayMs?: number) => Promise<{ - success: boolean; - message?: string; - }>; - dismissUpdateToast: () => Promise<{ success: boolean }>; - skipUpdateVersion: () => Promise<{ success: boolean; message?: string }>; - getCurrentUpdateToastPayload: () => Promise; - getUpdateStatusSummary: () => Promise; - 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; - onMenuLoadProject: (callback: () => void) => () => void; - onMenuSaveProject: (callback: () => void) => () => void; - onMenuSaveProjectAs: (callback: () => void) => () => void; - getPlatform: () => Promise; - revealInFolder: ( - filePath: string, - ) => Promise<{ success: boolean; error?: string; message?: string }>; - openRecordingsFolder: () => Promise<{ success: boolean; error?: string; message?: string }>; - getRecordingsDirectory: () => Promise<{ - success: boolean; - path: string; - isDefault: boolean; - error?: string; - }>; - chooseRecordingsDirectory: () => Promise<{ - success: boolean; - canceled?: boolean; - path?: string; - isDefault?: boolean; - message?: string; - error?: string; - }>; - getShortcuts: () => Promise | null>; - saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; - setHasUnsavedChanges: (hasChanges: boolean) => void; - onRequestSaveBeforeClose: (callback: () => Promise) => () => void; - isNativeWindowsCaptureAvailable: () => Promise<{ available: boolean }>; - muxNativeWindowsRecording: ( - pauseSegments?: Array<{ startMs: number; endMs: number }>, - ) => Promise<{ - success: boolean; - path?: string; - message?: string; - error?: string; - }>; - /** Returns the app version from package.json */ - getAppVersion: () => Promise; - /** 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 }>; - /** Countdown timer before recording */ - getCountdownDelay: () => Promise<{ success: boolean; delay: number }>; - setCountdownDelay: (delay: number) => Promise<{ success: boolean; error?: string }>; - startCountdown: (seconds: number) => Promise<{ success: boolean; cancelled?: boolean }>; - cancelCountdown: () => Promise<{ success: boolean }>; - getActiveCountdown: () => Promise<{ success: boolean; seconds: number | null }>; - onCountdownTick: (callback: (seconds: number) => void) => () => void; - extensionsDiscover: () => Promise; - extensionsList: () => Promise; - extensionsGet: (id: string) => Promise; - extensionsEnable: (id: string) => Promise<{ success: boolean; error?: string }>; - extensionsDisable: (id: string) => Promise<{ success: boolean; error?: string }>; - extensionsInstallFromFolder: () => Promise<{ - success: boolean; - extension?: RendererExtensionInfo; - message?: string; - error?: string; - canceled?: boolean; - }>; - extensionsUninstall: (id: string) => Promise<{ success: boolean; error?: string }>; - extensionsGetDirectory: () => Promise<{ success: boolean; path?: string; error?: string }>; - extensionsOpenDirectory: () => Promise<{ success: boolean; path?: string; error?: string }>; - extensionsMarketplaceSearch: (params: { - query?: string; - tags?: string[]; - sort?: string; - page?: number; - pageSize?: number; - }) => Promise; - extensionsMarketplaceGet: (id: string) => Promise; - extensionsMarketplaceInstall: ( - extensionId: string, - downloadUrl: string, - ) => Promise<{ success: boolean; error?: string }>; - extensionsMarketplaceSubmit: ( - extensionId: string, - ) => Promise<{ success: boolean; reviewId?: string; error?: string }>; - extensionsReviewsList: (params: { - status?: RendererMarketplaceReviewStatus; - page?: number; - pageSize?: number; - }) => Promise<{ reviews: RendererExtensionReview[]; total: number; error?: string }>; - extensionsReviewUpdate: ( - reviewId: string, - status: RendererMarketplaceReviewStatus, - notes?: string, - ) => Promise<{ success: boolean; error?: string }>; - }; + electronAPI: ElectronAPI; } interface ProcessedDesktopSource { id: string; name: string; - display_id: string; - thumbnail: string | null; - appIcon: string | null; + display_id?: string; + thumbnail?: string | null; + appIcon?: string | null; originalName?: string; sourceType?: "screen" | "window"; appName?: string; windowTitle?: string; + [key: string]: unknown; } interface CursorTelemetryPoint { diff --git a/electron/hudWindows.ts b/electron/hudWindows.ts new file mode 100644 index 00000000..33213e3a --- /dev/null +++ b/electron/hudWindows.ts @@ -0,0 +1,498 @@ +import fs from "node:fs"; +import os from "node:os"; +import { BrowserWindow, ipcMain } from "electron"; +import { USER_DATA_PATH } from "./appPaths"; +import { PRELOAD_PATH, getScreen, loadRendererWindow } from "./windowShared"; +let hudOverlayWindow: BrowserWindow | null = null; +let hudOverlayHiddenFromCapture = true; +let hudOverlayCaptureProtectionLoaded = false; +let updateToastWindow: BrowserWindow | null = null; +const HUD_OVERLAY_SETTINGS_FILE = `${USER_DATA_PATH}/hud-overlay-settings.json`; +const HUD_BOTTOM_CLEARANCE_CM = 3.5; +const DIP_PER_INCH = 96; +const CM_PER_INCH = 2.54; +const HUD_EDGE_MARGIN_DIP = 16; +const HUD_SHADOW_BLEED_DIP = 36; +const HUD_MIN_WINDOW_WIDTH = 560; +const HUD_COMPACT_HEIGHT = 96; +const HUD_MIN_EXPANDED_HEIGHT = 520 + HUD_SHADOW_BLEED_DIP; +const UPDATE_TOAST_WIDTH = 420; +const UPDATE_TOAST_HEIGHT = 212; +const UPDATE_TOAST_GAP_DIP = 18; + +let hudOverlayExpanded = false; +let hudOverlayCompactWidth = HUD_MIN_WINDOW_WIDTH; +let hudOverlayCompactHeight = HUD_COMPACT_HEIGHT; +let hudOverlayExpandedHeight = HUD_MIN_EXPANDED_HEIGHT; +let hudUserPosition: { x: number; y: number } | null = null; +let hudDragOffset: { x: number; y: number } | null = null; +let hudDragLastCursor: { x: number; y: number } | null = null; +let hudDragFixedSize: { width: number; height: number } | null = null; + +function isHudOverlayCaptureProtectionSupported(): boolean { + return process.platform !== "linux"; +} + +function getWindowsBuildNumber(): number | null { + if (process.platform !== "win32") { + return null; + } + + const build = Number.parseInt(os.release().split(".")[2] ?? "", 10); + return Number.isFinite(build) ? build : null; +} + +export function isHudOverlayMousePassthroughSupported(): boolean { + if (process.platform === "linux") { + return false; + } + + const build = getWindowsBuildNumber(); + return build === null || build >= 22000; +} + +function loadHudOverlayCaptureProtectionSetting(): boolean { + if (hudOverlayCaptureProtectionLoaded) { + return hudOverlayHiddenFromCapture; + } + + hudOverlayCaptureProtectionLoaded = true; + + try { + if (!fs.existsSync(HUD_OVERLAY_SETTINGS_FILE)) { + return hudOverlayHiddenFromCapture; + } + + const raw = fs.readFileSync(HUD_OVERLAY_SETTINGS_FILE, "utf-8"); + const parsed = JSON.parse(raw) as { hiddenFromCapture?: unknown }; + if (typeof parsed.hiddenFromCapture === "boolean") { + hudOverlayHiddenFromCapture = parsed.hiddenFromCapture; + } + } catch { + // Ignore settings read failures and fall back to defaults. + } + + return hudOverlayHiddenFromCapture; +} + +function persistHudOverlayCaptureProtectionSetting(enabled: boolean): void { + try { + fs.writeFileSync( + HUD_OVERLAY_SETTINGS_FILE, + JSON.stringify({ hiddenFromCapture: enabled }, null, 2), + "utf-8", + ); + } catch { + // Ignore settings write failures and keep runtime state working. + } +} + +export function getHudOverlayWindow(): BrowserWindow | null { + return hudOverlayWindow && !hudOverlayWindow.isDestroyed() ? hudOverlayWindow : null; +} + +function getHudOverlayDisplay() { + const hudWindow = getHudOverlayWindow(); + return hudWindow + ? getScreen().getDisplayMatching(hudWindow.getBounds()) + : getScreen().getPrimaryDisplay(); +} + +function getHudOverlayBounds(expanded: boolean) { + const { bounds, workArea } = getHudOverlayDisplay(); + const maxWindowWidth = Math.max(HUD_MIN_WINDOW_WIDTH, workArea.width - HUD_EDGE_MARGIN_DIP * 2); + const windowWidth = Math.min( + maxWindowWidth, + Math.max(HUD_MIN_WINDOW_WIDTH, Math.round(hudOverlayCompactWidth)), + ); + const maxWindowHeight = Math.max(HUD_COMPACT_HEIGHT, workArea.height - HUD_EDGE_MARGIN_DIP * 2); + const desiredHeight = expanded + ? Math.max(HUD_MIN_EXPANDED_HEIGHT, Math.round(hudOverlayExpandedHeight)) + : Math.max(HUD_COMPACT_HEIGHT, Math.round(hudOverlayCompactHeight)); + const windowHeight = Math.min(maxWindowHeight, desiredHeight); + const bottomClearanceDip = Math.round((HUD_BOTTOM_CLEARANCE_CM / CM_PER_INCH) * DIP_PER_INCH); + const screenBottom = bounds.y + bounds.height; + const workAreaBottom = workArea.y + workArea.height; + const preferredBottom = screenBottom - bottomClearanceDip; + const maximumSafeBottom = workAreaBottom - HUD_EDGE_MARGIN_DIP; + const windowBottom = Math.min(preferredBottom, maximumSafeBottom); + + const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); + const y = Math.max(workArea.y + HUD_EDGE_MARGIN_DIP, Math.floor(windowBottom - windowHeight)); + + return { x, y, width: windowWidth, height: windowHeight }; +} + +function getUpdateToastBounds() { + const hudWindow = getHudOverlayWindow(); + if (hudWindow) { + const hudBounds = hudWindow.getBounds(); + const display = getScreen().getDisplayMatching(hudBounds); + const x = Math.round(hudBounds.x + (hudBounds.width - UPDATE_TOAST_WIDTH) / 2); + const y = Math.max( + display.workArea.y + HUD_EDGE_MARGIN_DIP, + hudBounds.y - UPDATE_TOAST_HEIGHT - UPDATE_TOAST_GAP_DIP, + ); + + return { x, y, width: UPDATE_TOAST_WIDTH, height: UPDATE_TOAST_HEIGHT }; + } + + const primaryDisplay = getScreen().getPrimaryDisplay(); + const { workArea } = primaryDisplay; + return { + x: Math.round(workArea.x + (workArea.width - UPDATE_TOAST_WIDTH) / 2), + y: workArea.y + HUD_EDGE_MARGIN_DIP, + width: UPDATE_TOAST_WIDTH, + height: UPDATE_TOAST_HEIGHT, + }; +} + +function positionUpdateToastWindow() { + if (!updateToastWindow || updateToastWindow.isDestroyed()) { + return; + } + + updateToastWindow.setBounds(getUpdateToastBounds(), false); + updateToastWindow.moveTop(); +} + +function reapplyHudOverlayMousePassthrough(window: BrowserWindow) { + if (process.platform !== "win32" || !isHudOverlayMousePassthroughSupported()) { + return; + } + + window.setIgnoreMouseEvents(false); + setTimeout(() => { + if (!window.isDestroyed()) { + window.setIgnoreMouseEvents(true, { forward: true }); + } + }, 50); +} + +function applyHudOverlayBounds(expanded: boolean) { + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { + return; + } + + hudOverlayExpanded = expanded; + const computed = getHudOverlayBounds(expanded); + + if (hudUserPosition) { + const { workArea } = getHudOverlayDisplay(); + const x = Math.max( + workArea.x, + Math.min(hudUserPosition.x, workArea.x + workArea.width - computed.width), + ); + const y = Math.max( + workArea.y, + Math.min(hudUserPosition.y, workArea.y + workArea.height - computed.height), + ); + hudOverlayWindow.setBounds({ x, y, width: computed.width, height: computed.height }, false); + } else { + hudOverlayWindow.setBounds(computed, false); + } + + positionUpdateToastWindow(); + if (hudOverlayWindow.isVisible()) { + hudOverlayWindow.moveTop(); + } +} + +ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => { + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { + return; + } + + if (!isHudOverlayMousePassthroughSupported()) { + hudOverlayWindow.setIgnoreMouseEvents(false); + return; + } + + if (ignore) { + hudOverlayWindow.setIgnoreMouseEvents(true, { forward: true }); + return; + } + + hudOverlayWindow.setIgnoreMouseEvents(false); +}); + +ipcMain.on("hud-overlay-drag", (_event, phase: string, screenX: number, screenY: number) => { + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { + return; + } + + if (phase === "start") { + const bounds = hudOverlayWindow.getBounds(); + hudDragOffset = { x: screenX - bounds.x, y: screenY - bounds.y }; + hudDragLastCursor = { x: screenX, y: screenY }; + hudDragFixedSize = { width: bounds.width, height: bounds.height }; + return; + } + + if (phase === "move" && hudDragOffset) { + if ( + hudDragLastCursor && + hudDragLastCursor.x === screenX && + hudDragLastCursor.y === screenY + ) { + return; + } + + hudDragLastCursor = { x: screenX, y: screenY }; + const targetX = Math.round(screenX - hudDragOffset.x); + const targetY = Math.round(screenY - hudDragOffset.y); + const fixedWidth = hudDragFixedSize?.width ?? hudOverlayWindow.getBounds().width; + const fixedHeight = hudDragFixedSize?.height ?? hudOverlayWindow.getBounds().height; + hudOverlayWindow.setBounds( + { x: targetX, y: targetY, width: fixedWidth, height: fixedHeight }, + false, + ); + return; + } + + if (phase === "end") { + const finalBounds = hudOverlayWindow.getBounds(); + hudUserPosition = { x: finalBounds.x, y: finalBounds.y }; + hudDragOffset = null; + hudDragLastCursor = null; + hudDragFixedSize = null; + } +}); + +ipcMain.on("hud-overlay-hide", () => { + if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) hudOverlayWindow.minimize(); +}); + +ipcMain.on("set-hud-overlay-expanded", (_event, expanded: boolean) => applyHudOverlayBounds(Boolean(expanded))); + +ipcMain.on("set-hud-overlay-compact-width", (_event, width: number) => { + if (!Number.isFinite(width)) { + return; + } + + const maxWindowWidth = Math.max( + HUD_MIN_WINDOW_WIDTH, + getHudOverlayDisplay().workArea.width - HUD_EDGE_MARGIN_DIP * 2, + ); + const nextWidth = Math.min(maxWindowWidth, Math.max(HUD_MIN_WINDOW_WIDTH, Math.round(width))); + + if (nextWidth === hudOverlayCompactWidth) { + return; + } + + hudOverlayCompactWidth = nextWidth; + applyHudOverlayBounds(hudOverlayExpanded); +}); + +ipcMain.on("set-hud-overlay-measured-height", (_event, height: number, expanded: boolean) => { + if (!Number.isFinite(height)) { + return; + } + + const maxWindowHeight = Math.max( + HUD_COMPACT_HEIGHT, + getHudOverlayDisplay().workArea.height - HUD_EDGE_MARGIN_DIP * 2, + ); + const nextHeight = Math.min(maxWindowHeight, Math.max(HUD_COMPACT_HEIGHT, Math.round(height))); + + if (expanded) { + if (nextHeight === hudOverlayExpandedHeight) { + return; + } + hudOverlayExpandedHeight = Math.max(HUD_MIN_EXPANDED_HEIGHT, nextHeight); + } else { + if (nextHeight === hudOverlayCompactHeight) { + return; + } + hudOverlayCompactHeight = nextHeight; + } + + applyHudOverlayBounds(hudOverlayExpanded); +}); + +ipcMain.handle("get-hud-overlay-capture-protection", () => ({ success: true, enabled: loadHudOverlayCaptureProtectionSetting() })); + +ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean) => { + loadHudOverlayCaptureProtectionSetting(); + hudOverlayHiddenFromCapture = Boolean(enabled); + persistHudOverlayCaptureProtectionSetting(hudOverlayHiddenFromCapture); + + if ( + isHudOverlayCaptureProtectionSupported() && + hudOverlayWindow && + !hudOverlayWindow.isDestroyed() + ) { + hudOverlayWindow.setContentProtection(hudOverlayHiddenFromCapture); + } + + return { success: true, enabled: hudOverlayHiddenFromCapture }; +}); + +export function createHudOverlayWindow(): BrowserWindow { + loadHudOverlayCaptureProtectionSetting(); + const initialBounds = getHudOverlayBounds(false); + const win = new BrowserWindow({ + width: initialBounds.width, + height: initialBounds.height, + minWidth: HUD_MIN_WINDOW_WIDTH, + minHeight: HUD_COMPACT_HEIGHT, + maxHeight: Math.max( + HUD_COMPACT_HEIGHT, + getHudOverlayDisplay().workArea.height - HUD_EDGE_MARGIN_DIP * 2, + ), + x: initialBounds.x, + y: initialBounds.y, + frame: false, + transparent: true, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + show: false, + webPreferences: { + preload: PRELOAD_PATH, + nodeIntegration: false, + contextIsolation: true, + webSecurity: false, + backgroundThrottling: false, + }, + }); + + if (isHudOverlayCaptureProtectionSupported()) { + win.setContentProtection(hudOverlayHiddenFromCapture); + } + + if (isHudOverlayMousePassthroughSupported()) { + win.setIgnoreMouseEvents(true, { forward: true }); + } + + if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { + win.on("focus", () => { + if (!win.isDestroyed()) { + reapplyHudOverlayMousePassthrough(win); + } + }); + } + + win.webContents.on("did-finish-load", () => { + win.webContents.send("main-process-message", new Date().toLocaleString()); + setTimeout(() => { + if (!win.isDestroyed()) { + win.show(); + win.moveTop(); + reapplyHudOverlayMousePassthrough(win); + } + }, 100); + }); + + win.once("ready-to-show", () => { + setTimeout(() => { + if (!win.isDestroyed() && !win.isVisible()) { + win.show(); + win.moveTop(); + } + }, 500); + }); + + hudOverlayWindow = win; + const screen = getScreen(); + const handleDisplayRemoved = () => { + hudUserPosition = null; + }; + const handleDisplayMetricsChanged = () => { + if (hudUserPosition) { + const displays = screen.getAllDisplays(); + const onScreen = displays.some( + (display) => + hudUserPosition!.x >= display.workArea.x && + hudUserPosition!.x < display.workArea.x + display.workArea.width && + hudUserPosition!.y >= display.workArea.y && + hudUserPosition!.y < display.workArea.y + display.workArea.height, + ); + if (!onScreen) { + hudUserPosition = null; + } + } + + applyHudOverlayBounds(hudOverlayExpanded); + }; + + screen.on("display-removed", handleDisplayRemoved); + screen.on("display-metrics-changed", handleDisplayMetricsChanged); + + win.on("closed", () => { + screen.removeListener("display-removed", handleDisplayRemoved); + screen.removeListener("display-metrics-changed", handleDisplayMetricsChanged); + if (hudOverlayWindow === win) { + hudOverlayWindow = null; + } + }); + + loadRendererWindow(win, "hud-overlay"); + return win; +} + +export function createUpdateToastWindow(): BrowserWindow { + const initialBounds = getUpdateToastBounds(); + const parentWindow = process.platform === "darwin" && hudOverlayWindow && !hudOverlayWindow.isDestroyed() ? hudOverlayWindow : undefined; + const useTransparentToastWindow = process.platform !== "win32"; + const win = new BrowserWindow({ + width: initialBounds.width, + height: initialBounds.height, + x: initialBounds.x, + y: initialBounds.y, + frame: false, + transparent: useTransparentToastWindow, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + show: false, + focusable: true, + ...(parentWindow ? { parent: parentWindow } : {}), + backgroundColor: useTransparentToastWindow ? "#00000000" : "#101418", + webPreferences: { + preload: PRELOAD_PATH, + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }); + + if (process.platform === "darwin") win.setAlwaysOnTop(true, "status"); + + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + updateToastWindow = win; + + win.on("closed", () => { + if (updateToastWindow === win) { + updateToastWindow = null; + } + }); + + loadRendererWindow(win, "update-toast"); + return win; +} + +export function getUpdateToastWindow(): BrowserWindow | null { + return updateToastWindow && !updateToastWindow.isDestroyed() ? updateToastWindow : null; +} + +export function showUpdateToastWindow(): BrowserWindow { + const win = getUpdateToastWindow() ?? createUpdateToastWindow(); + positionUpdateToastWindow(); + if (!win.isVisible()) { + if (process.platform === "win32") { + win.show(); + win.moveTop(); + } else win.showInactive(); + } else { + win.moveTop(); + } + + return win; +} + +export function hideUpdateToastWindow(): void { + if (updateToastWindow && !updateToastWindow.isDestroyed()) updateToastWindow.hide(); +} \ No newline at end of file diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts deleted file mode 100644 index 78184758..00000000 --- a/electron/ipc/register/recording.ts +++ /dev/null @@ -1,1172 +0,0 @@ -import type { ChildProcessWithoutNullStreams } from "node:child_process"; -import { execFile, spawn } from "node:child_process"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { promisify } from "node:util"; -import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, shell, systemPreferences } from "electron"; -import { showCursor } from "../../cursorHider"; -import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants"; -import type { SelectedSource, NativeMacRecordingOptions, PauseSegment, CursorTelemetryPoint } from "../types"; -import { - selectedSource, - nativeScreenRecordingActive, - setNativeScreenRecordingActive, - currentVideoPath, - nativeCaptureProcess, - setNativeCaptureProcess, - nativeCaptureOutputBuffer, - setNativeCaptureOutputBuffer, - nativeCaptureTargetPath, - setNativeCaptureTargetPath, - setNativeCaptureStopRequested, - nativeCaptureSystemAudioPath, - setNativeCaptureSystemAudioPath, - nativeCaptureMicrophonePath, - setNativeCaptureMicrophonePath, - nativeCapturePaused, - setNativeCapturePaused, - windowsCaptureProcess, - setWindowsCaptureProcess, - windowsCaptureTargetPath, - setWindowsCaptureTargetPath, - windowsNativeCaptureActive, - setWindowsNativeCaptureActive, - setWindowsCaptureStopRequested, - windowsCapturePaused, - setWindowsCapturePaused, - windowsSystemAudioPath, - setWindowsSystemAudioPath, - windowsMicAudioPath, - setWindowsMicAudioPath, - windowsPendingVideoPath, - setWindowsPendingVideoPath, - lastNativeCaptureDiagnostics, - ffmpegScreenRecordingActive, - setFfmpegScreenRecordingActive, - ffmpegCaptureProcess, - setFfmpegCaptureProcess, - ffmpegCaptureOutputBuffer, - setFfmpegCaptureOutputBuffer, - ffmpegCaptureTargetPath, - setFfmpegCaptureTargetPath, - cachedSystemCursorAssets, - setCachedSystemCursorAssets, - cachedSystemCursorAssetsSourceMtimeMs, - setCachedSystemCursorAssetsSourceMtimeMs, - setCursorCaptureStartTimeMs, - setActiveCursorSamples, - setPendingCursorSamples, - setIsCursorCaptureActive, - setLastLeftClick, - setLinuxCursorScreenPoint, - windowsCaptureOutputBuffer, - setWindowsCaptureOutputBuffer, -} from "../state"; -import { - getRecordingsDir, - getScreen, - getMacPrivacySettingsUrl, - moveFileWithOverwrite, - parseWindowId, - normalizeVideoSourcePath, - getTelemetryPathForVideo, -} from "../utils"; -import { - ensureSwiftHelperBinary, - getSystemCursorHelperSourcePath, - getSystemCursorHelperBinaryPath, - getNativeCaptureHelperBinaryPath, - ensureNativeCaptureHelperBinary, - getWindowsCaptureExePath, -} from "../paths/binaries"; -import { getFfmpegBinaryPath } from "../ffmpeg/binary"; -import { - recordNativeCaptureDiagnostics, - getFileSizeIfPresent, - getCompanionAudioFallbackPaths, -} from "../recording/diagnostics"; -import { rememberApprovedLocalReadPath } from "../project/manager"; -import { - isNativeWindowsCaptureAvailable, - waitForWindowsCaptureStart, - waitForWindowsCaptureStop, - attachWindowsCaptureLifecycle, - muxNativeWindowsVideoWithAudio, -} from "../recording/windows"; -import { - waitForNativeCaptureStart, - waitForNativeCaptureStop, - muxNativeMacRecordingWithAudio, - attachNativeCaptureLifecycle, - finalizeStoredVideo, - recoverNativeMacCaptureOutput, -} from "../recording/mac"; -import { - buildFfmpegCaptureArgs, - waitForFfmpegCaptureStart, - waitForFfmpegCaptureStop, - getDisplayBoundsForSource, -} from "../recording/ffmpeg"; -import { resolveWindowsCaptureDisplay } from "../windowsCaptureSelection"; -import { - clamp, - stopCursorCapture, - sampleCursorPoint, - startCursorSampling, - snapshotCursorTelemetryForPersistence, -} from "../cursor/telemetry"; -import { - startWindowBoundsCapture, - stopWindowBoundsCapture, -} from "../cursor/bounds"; -import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction"; -import { stopNativeCursorMonitor, startNativeCursorMonitor } from "../cursor/monitor"; - -const execFileAsync = promisify(execFile); - -async function getSystemCursorAssets() { - if (process.platform !== "darwin") { - setCachedSystemCursorAssets({}); - setCachedSystemCursorAssetsSourceMtimeMs(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", - "recordly-system-cursors", - ); - const { stdout } = await execFileAsync(binaryPath, [], { timeout: 15000, maxBuffer: 20 * 1024 * 1024 }); - const parsed = JSON.parse(stdout) as Record>; - const result = 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; - setCachedSystemCursorAssets(result); - setCachedSystemCursorAssetsSourceMtimeMs(sourceStat.mtimeMs); - return result; -} - -function normalizeDesktopSourceName(value: string) { - return value.trim().replace(/\s+/g, " ").toLowerCase(); -} - -export function registerRecordingHandlers( - onRecordingStateChange?: (recording: boolean, sourceName: string) => void, -) { - 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 */ } - setWindowsCaptureProcess(null) - setWindowsCaptureTargetPath(null) - setWindowsCaptureStopRequested(false) - } - - if (windowsCaptureProcess) { - return { success: false, message: 'A native Windows screen recording is already active.' } - } - - let wcProc: ChildProcessWithoutNullStreams | null = null - 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 - setWindowsSystemAudioPath(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 - } - setWindowsMicAudioPath(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, - }) - - setWindowsCaptureOutputBuffer('') - setWindowsCaptureTargetPath(outputPath) - setWindowsCaptureStopRequested(false) - setWindowsCapturePaused(false) - wcProc = spawn(exePath, [JSON.stringify(config)], { - cwd: recordingsDir, - stdio: ['pipe', 'pipe', 'pipe'], - }) - setWindowsCaptureProcess(wcProc) - attachWindowsCaptureLifecycle(wcProc) - - wcProc.stdout.on('data', (chunk: Buffer) => { - setWindowsCaptureOutputBuffer(windowsCaptureOutputBuffer + chunk.toString()) - }) - wcProc.stderr.on('data', (chunk: Buffer) => { - setWindowsCaptureOutputBuffer(windowsCaptureOutputBuffer + chunk.toString()) - }) - - await waitForWindowsCaptureStart(wcProc) - setWindowsNativeCaptureActive(true) - setNativeScreenRecordingActive(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 { if (wcProc) wcProc.kill() } catch { /* ignore */ } - setWindowsNativeCaptureActive(false) - setNativeScreenRecordingActive(false) - setWindowsCaptureProcess(null) - setWindowsCaptureTargetPath(null) - setWindowsCaptureStopRequested(false) - setWindowsCapturePaused(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 - } - setNativeCaptureProcess(null) - setNativeCaptureTargetPath(null) - setNativeCaptureStopRequested(false) - } - - if (nativeCaptureProcess) { - return { success: false, message: 'A native screen recording is already active.' } - } - - let captProc: ChildProcessWithoutNullStreams | null = null - 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) - } - - setNativeCaptureOutputBuffer('') - setNativeCaptureTargetPath(outputPath) - setNativeCaptureSystemAudioPath(systemAudioOutputPath) - setNativeCaptureMicrophonePath(microphoneOutputPath) - setNativeCaptureStopRequested(false) - setNativeCapturePaused(false) - captProc = spawn(helperPath, [JSON.stringify(config)], { - cwd: recordingsDir, - stdio: ['pipe', 'pipe', 'pipe'], - }) - setNativeCaptureProcess(captProc) - attachNativeCaptureLifecycle(captProc) - - captProc.stdout.on('data', (chunk: Buffer) => { - setNativeCaptureOutputBuffer(nativeCaptureOutputBuffer + chunk.toString()) - }) - captProc.stderr.on('data', (chunk: Buffer) => { - setNativeCaptureOutputBuffer(nativeCaptureOutputBuffer + chunk.toString()) - }) - - await waitForNativeCaptureStart(captProc) - setNativeScreenRecordingActive(true) - - // If the native helper reported MICROPHONE_CAPTURE_UNAVAILABLE, it started - // capture without microphone. Clear the mic path so the renderer can fall - // back to a browser-side sidecar recording for the microphone track. - const micUnavailableNatively = nativeCaptureOutputBuffer.includes('MICROPHONE_CAPTURE_UNAVAILABLE') - if (micUnavailableNatively) { - setNativeCaptureMicrophonePath(null) - } - - 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: nativeCaptureMicrophonePath, - processOutput: nativeCaptureOutputBuffer.trim() || undefined, - }) - return { success: true, microphoneFallbackRequired: micUnavailableNatively } - } 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 { if (captProc) captProc.kill() } catch { /* ignore */ } - setNativeScreenRecordingActive(false) - setNativeCaptureProcess(null) - setNativeCaptureTargetPath(null) - setNativeCaptureSystemAudioPath(null) - setNativeCaptureMicrophonePath(null) - setNativeCaptureStopRequested(false) - setNativeCapturePaused(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 { if (captProc) captProc.kill() } catch { /* ignore */ } - setNativeScreenRecordingActive(false) - setNativeCaptureProcess(null) - setNativeCaptureTargetPath(null) - setNativeCaptureSystemAudioPath(null) - setNativeCaptureMicrophonePath(null) - setNativeCaptureStopRequested(false) - setNativeCapturePaused(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 { - if (captProc) captProc.kill() - } catch { - // ignore cleanup failures - } - setNativeScreenRecordingActive(false) - setNativeCaptureProcess(null) - setNativeCaptureTargetPath(null) - setNativeCaptureSystemAudioPath(null) - setNativeCaptureMicrophonePath(null) - setNativeCaptureStopRequested(false) - setNativeCapturePaused(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 - setWindowsCaptureStopRequested(true) - proc.stdin.write('stop\n') - const tempVideoPath = await waitForWindowsCaptureStop(proc) - setWindowsCaptureProcess(null) - setWindowsNativeCaptureActive(false) - setNativeScreenRecordingActive(false) - setWindowsCaptureTargetPath(null) - setWindowsCaptureStopRequested(false) - setWindowsCapturePaused(false) - - const finalVideoPath = preferredVideoPath ?? tempVideoPath - if (tempVideoPath !== finalVideoPath) { - await moveFileWithOverwrite(tempVideoPath, finalVideoPath) - } - - setWindowsPendingVideoPath(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 - setWindowsNativeCaptureActive(false) - setNativeScreenRecordingActive(false) - setWindowsCaptureProcess(null) - setWindowsCaptureTargetPath(null) - setWindowsCaptureStopRequested(false) - setWindowsCapturePaused(false) - setWindowsSystemAudioPath(null) - setWindowsMicAudioPath(null) - setWindowsPendingVideoPath(null) - - if (fallbackPath) { - try { - await fs.access(fallbackPath) - setWindowsPendingVideoPath(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) - setNativeCaptureStopRequested(true) - process.stdin.write('stop\n') - const tempVideoPath = await waitForNativeCaptureStop(process) - console.log('[stop-native] Helper stopped, tempVideoPath:', tempVideoPath) - setNativeCaptureProcess(null) - setNativeScreenRecordingActive(false) - setNativeCaptureTargetPath(null) - setNativeCaptureSystemAudioPath(null) - setNativeCaptureMicrophonePath(null) - setNativeCaptureStopRequested(false) - setNativeCapturePaused(false) - - const finalVideoPath = preferredVideoPath ?? tempVideoPath - if (tempVideoPath !== finalVideoPath) { - await moveFileWithOverwrite(tempVideoPath, finalVideoPath) - } - - if (preferredSystemAudioPath || preferredMicrophonePath) { - console.log('[stop-native] Attempting audio mux (merging separate tracks) into:', finalVideoPath) - try { - await muxNativeMacRecordingWithAudio(finalVideoPath, preferredSystemAudioPath, preferredMicrophonePath) - console.log('[stop-native] Audio mux completed successfully') - } catch (error) { - console.warn('[stop-native] Audio mux failed (video still has inline audio):', error) - } - } else { - console.log('[stop-native] No separate audio tracks to mux') - } - - return await finalizeStoredVideo(finalVideoPath) - } catch (error) { - console.error('Failed to stop native ScreenCaptureKit recording:', error) - const fallbackPath = nativeCaptureTargetPath - const fallbackSystemAudioPath = nativeCaptureSystemAudioPath - const fallbackMicrophonePath = nativeCaptureMicrophonePath - const fallbackFileSizeBytes = await getFileSizeIfPresent(fallbackPath) - setNativeScreenRecordingActive(false) - setNativeCaptureProcess(null) - setNativeCaptureTargetPath(null) - setNativeCaptureSystemAudioPath(null) - setNativeCaptureMicrophonePath(null) - setNativeCaptureStopRequested(false) - setNativeCapturePaused(false) - - recordNativeCaptureDiagnostics({ - backend: 'mac-screencapturekit', - phase: 'stop', - sourceId: lastNativeCaptureDiagnostics?.sourceId ?? null, - sourceType: lastNativeCaptureDiagnostics?.sourceType ?? 'unknown', - displayId: lastNativeCaptureDiagnostics?.displayId ?? null, - displayBounds: lastNativeCaptureDiagnostics?.displayBounds ?? null, - windowHandle: lastNativeCaptureDiagnostics?.windowHandle ?? null, - helperPath: lastNativeCaptureDiagnostics?.helperPath ?? null, - outputPath: fallbackPath, - systemAudioPath: fallbackSystemAudioPath, - microphonePath: fallbackMicrophonePath, - osRelease: lastNativeCaptureDiagnostics?.osRelease, - supported: lastNativeCaptureDiagnostics?.supported, - helperExists: lastNativeCaptureDiagnostics?.helperExists, - processOutput: nativeCaptureOutputBuffer.trim() || undefined, - fileSizeBytes: fallbackFileSizeBytes, - error: String(error), - }) - - // Try to recover: if the target file exists on disk, finalize with it - if (fallbackPath) { - try { - await fs.access(fallbackPath) - console.log('[stop-native-screen-recording] Recovering with fallback path:', fallbackPath) - if (fallbackSystemAudioPath || fallbackMicrophonePath) { - try { - await muxNativeMacRecordingWithAudio( - fallbackPath, - fallbackSystemAudioPath, - fallbackMicrophonePath, - ) - } catch (muxError) { - console.warn('Failed to mux recovered native macOS audio into capture:', muxError) - } - } - 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') - setWindowsCapturePaused(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') - setNativeCapturePaused(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') - setWindowsCapturePaused(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') - setNativeCapturePaused(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 { - const paths = await getCompanionAudioFallbackPaths(videoPath) - await Promise.all([ - rememberApprovedLocalReadPath(videoPath), - ...paths.map((fallbackPath) => rememberApprovedLocalReadPath(fallbackPath)), - ]) - return { success: true, paths } - } 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 - setWindowsPendingVideoPath(null) - - if (!videoPath) { - return { success: false, message: 'No native Windows video pending for mux' } - } - - try { - if (windowsSystemAudioPath || windowsMicAudioPath) { - await muxNativeWindowsVideoWithAudio(videoPath, windowsSystemAudioPath, windowsMicAudioPath, pauseSegments ?? []) - setWindowsSystemAudioPath(null) - setWindowsMicAudioPath(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), - }) - setWindowsSystemAudioPath(null) - setWindowsMicAudioPath(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) - - setFfmpegCaptureOutputBuffer('') - setFfmpegCaptureTargetPath(outputPath) - const ffProc = spawn(ffmpegPath, args, { - cwd: recordingsDir, - stdio: ['pipe', 'pipe', 'pipe'], - }) - setFfmpegCaptureProcess(ffProc) - - ffProc.stdout.on('data', (chunk: Buffer) => { - setFfmpegCaptureOutputBuffer(ffmpegCaptureOutputBuffer + chunk.toString()) - }) - ffProc.stderr.on('data', (chunk: Buffer) => { - setFfmpegCaptureOutputBuffer(ffmpegCaptureOutputBuffer + chunk.toString()) - }) - - await waitForFfmpegCaptureStart(ffProc) - setFfmpegScreenRecordingActive(true) - return { success: true } - } catch (error) { - console.error('Failed to start FFmpeg recording:', error) - setFfmpegScreenRecordingActive(false) - setFfmpegCaptureProcess(null) - setFfmpegCaptureTargetPath(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) - - setFfmpegCaptureProcess(null) - setFfmpegCaptureTargetPath(null) - setFfmpegScreenRecordingActive(false) - - return await finalizeStoredVideo(finalVideoPath) - } catch (error) { - console.error('Failed to stop FFmpeg recording:', error) - try { - ffmpegCaptureProcess?.kill() - } catch { - // ignore cleanup failures - } - setFfmpegCaptureProcess(null) - setFfmpegCaptureTargetPath(null) - setFfmpegScreenRecordingActive(false) - return { - success: false, - message: 'Failed to stop FFmpeg recording', - error: String(error), - } - } - }) - - - - ipcMain.handle('store-microphone-sidecar', async (_, audioData: ArrayBuffer, videoPath: string) => { - try { - const baseName = videoPath.replace(/\.[^.]+$/, '') - const sidecarPath = `${baseName}.mic.webm` - await fs.writeFile(sidecarPath, Buffer.from(audioData)) - return { success: true, path: sidecarPath } - } catch (error) { - console.error('Failed to store microphone sidecar:', error) - return { success: false, 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 entries = await fs.readdir(recordingsDir, { withFileTypes: true }) - const candidates = await Promise.all( - entries - .filter((entry) => entry.isFile() && /^recording-\d+\.(webm|mov|mp4)$/i.test(entry.name)) - .map(async (entry) => { - const fullPath = path.join(recordingsDir, entry.name) - const stat = await fs.stat(fullPath).catch(() => null) - return stat ? { path: fullPath, mtimeMs: stat.mtimeMs } : null - }), - ) - const latestVideo = candidates - .filter((candidate): candidate is { path: string; mtimeMs: number } => candidate !== null) - .sort((left, right) => right.mtimeMs - left.mtimeMs)[0] - - if (!latestVideo) { - return { success: false, message: 'No recorded video found' } - } - - return { success: true, path: latestVideo.path } - } 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() - setIsCursorCaptureActive(true) - setActiveCursorSamples([]) - setPendingCursorSamples([]) - setCursorCaptureStartTimeMs(Date.now()) - setLinuxCursorScreenPoint(null) - setLastLeftClick(null) - sampleCursorPoint() - startCursorSampling() - void startInteractionCapture() - } else { - setIsCursorCaptureActive(false) - stopCursorCapture() - stopInteractionCapture() - stopWindowBoundsCapture() - stopNativeCursorMonitor() - showCursor() - setLinuxCursorScreenPoint(null) - snapshotCursorTelemetryForPersistence() - setActiveCursorSamples([]) - } - - 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: [] } - } - }) - - -} diff --git a/electron/ipc/register/recording/ffmpegHandlers.ts b/electron/ipc/register/recording/ffmpegHandlers.ts new file mode 100644 index 00000000..c7ab0147 --- /dev/null +++ b/electron/ipc/register/recording/ffmpegHandlers.ts @@ -0,0 +1,104 @@ +import { spawn } from "node:child_process" +import path from "node:path" +import { ipcMain } from "electron" +import { + ffmpegCaptureOutputBuffer, + ffmpegCaptureProcess, + ffmpegCaptureTargetPath, + ffmpegScreenRecordingActive, + setFfmpegCaptureOutputBuffer, + setFfmpegCaptureProcess, + setFfmpegCaptureTargetPath, + setFfmpegScreenRecordingActive, +} from "../../state" +import type { SelectedSource } from "../../types" +import { getRecordingsDir } from "../../utils" +import { getFfmpegBinaryPath } from "../../ffmpeg/binary" +import { + buildFfmpegCaptureArgs, + waitForFfmpegCaptureStart, + waitForFfmpegCaptureStop, +} from "../../recording/ffmpeg" +import { finalizeStoredVideo } from "../../recording/mac" + +export function registerFfmpegRecordingHandlers() { + 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) + + setFfmpegCaptureOutputBuffer("") + setFfmpegCaptureTargetPath(outputPath) + const process = spawn(ffmpegPath, args, { + cwd: recordingsDir, + stdio: ["pipe", "pipe", "pipe"], + }) + setFfmpegCaptureProcess(process) + + process.stdout.on("data", (chunk: Buffer) => { + setFfmpegCaptureOutputBuffer(ffmpegCaptureOutputBuffer + chunk.toString()) + }) + process.stderr.on("data", (chunk: Buffer) => { + setFfmpegCaptureOutputBuffer(ffmpegCaptureOutputBuffer + chunk.toString()) + }) + + await waitForFfmpegCaptureStart(process) + setFfmpegScreenRecordingActive(true) + return { success: true } + } catch (error) { + console.error("Failed to start FFmpeg recording:", error) + setFfmpegScreenRecordingActive(false) + setFfmpegCaptureProcess(null) + setFfmpegCaptureTargetPath(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) + + setFfmpegCaptureProcess(null) + setFfmpegCaptureTargetPath(null) + setFfmpegScreenRecordingActive(false) + + return await finalizeStoredVideo(finalVideoPath) + } catch (error) { + console.error("Failed to stop FFmpeg recording:", error) + try { + ffmpegCaptureProcess?.kill() + } catch { + // ignore cleanup failures + } + setFfmpegCaptureProcess(null) + setFfmpegCaptureTargetPath(null) + setFfmpegScreenRecordingActive(false) + return { + success: false, + message: "Failed to stop FFmpeg recording", + error: String(error), + } + } + }) +} \ No newline at end of file diff --git a/electron/ipc/register/recording/index.ts b/electron/ipc/register/recording/index.ts new file mode 100644 index 00000000..c7e0f5a9 --- /dev/null +++ b/electron/ipc/register/recording/index.ts @@ -0,0 +1,17 @@ +import { registerFfmpegRecordingHandlers } from "./ffmpegHandlers" +import { registerNativeRecordingControlHandlers } from "./nativeControlHandlers" +import { registerNativeRecordingStartHandlers } from "./nativeStartHandlers" +import { registerNativeRecordingStopHandlers } from "./nativeStopHandlers" +import { registerRecordingStorageHandlers } from "./storageHandlers" +import { registerRecordingTelemetryHandlers } from "./telemetryHandlers" + +export function registerRecordingHandlers( + onRecordingStateChange?: (recording: boolean, sourceName: string) => void, +) { + registerNativeRecordingStartHandlers() + registerNativeRecordingStopHandlers() + registerNativeRecordingControlHandlers() + registerFfmpegRecordingHandlers() + registerRecordingStorageHandlers() + registerRecordingTelemetryHandlers(onRecordingStateChange) +} \ No newline at end of file diff --git a/electron/ipc/register/recording/nativeControlHandlers.ts b/electron/ipc/register/recording/nativeControlHandlers.ts new file mode 100644 index 00000000..e0e42fc2 --- /dev/null +++ b/electron/ipc/register/recording/nativeControlHandlers.ts @@ -0,0 +1,262 @@ +import { execFile } from "node:child_process" +import fs from "node:fs/promises" +import { promisify } from "node:util" +import { ipcMain } from "electron" +import { + cachedSystemCursorAssets, + cachedSystemCursorAssetsSourceMtimeMs, + lastNativeCaptureDiagnostics, + nativeCapturePaused, + nativeCaptureProcess, + nativeScreenRecordingActive, + setCachedSystemCursorAssets, + setCachedSystemCursorAssetsSourceMtimeMs, + setNativeCapturePaused, + setWindowsCapturePaused, + setWindowsMicAudioPath, + setWindowsPendingVideoPath, + setWindowsSystemAudioPath, + windowsCapturePaused, + windowsCaptureProcess, + windowsMicAudioPath, + windowsNativeCaptureActive, + windowsPendingVideoPath, + windowsSystemAudioPath, +} from "../../state" +import type { PauseSegment } from "../../types" +import { + ensureSwiftHelperBinary, + getSystemCursorHelperBinaryPath, + getSystemCursorHelperSourcePath, +} from "../../paths/binaries" +import { getCompanionAudioFallbackPaths, getFileSizeIfPresent, recordNativeCaptureDiagnostics } from "../../recording/diagnostics" +import { finalizeStoredVideo } from "../../recording/mac" +import { + isNativeWindowsCaptureAvailable, + muxNativeWindowsVideoWithAudio, +} from "../../recording/windows" +import { rememberApprovedLocalReadPath } from "../../project/manager" + +const execFileAsync = promisify(execFile) + +async function getSystemCursorAssets() { + if (process.platform !== "darwin") { + setCachedSystemCursorAssets({}) + setCachedSystemCursorAssetsSourceMtimeMs(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", + "recordly-system-cursors", + ) + const { stdout } = await execFileAsync(binaryPath, [], { + timeout: 15000, + maxBuffer: 20 * 1024 * 1024, + }) + const parsed = JSON.parse(stdout) as Record> + const result = 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 + setCachedSystemCursorAssets(result) + setCachedSystemCursorAssetsSourceMtimeMs(sourceStat.mtimeMs) + return result +} + +export function registerNativeRecordingControlHandlers() { + 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") + setWindowsCapturePaused(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") + setNativeCapturePaused(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") + setWindowsCapturePaused(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") + setNativeCapturePaused(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 { + const paths = await getCompanionAudioFallbackPaths(videoPath) + await Promise.all([ + rememberApprovedLocalReadPath(videoPath), + ...paths.map((fallbackPath) => rememberApprovedLocalReadPath(fallbackPath)), + ]) + return { success: true, paths } + } 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 + setWindowsPendingVideoPath(null) + + if (!videoPath) { + return { success: false, message: "No native Windows video pending for mux" } + } + + try { + if (windowsSystemAudioPath || windowsMicAudioPath) { + await muxNativeWindowsVideoWithAudio( + videoPath, + windowsSystemAudioPath, + windowsMicAudioPath, + pauseSegments ?? [], + ) + setWindowsSystemAudioPath(null) + setWindowsMicAudioPath(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), + }) + setWindowsSystemAudioPath(null) + setWindowsMicAudioPath(null) + try { + return await finalizeStoredVideo(videoPath) + } catch { + return { + success: false, + message: "Failed to mux native Windows recording", + error: String(error), + } + } + } + }) +} \ No newline at end of file diff --git a/electron/ipc/register/recording/nativeStartHandlers.ts b/electron/ipc/register/recording/nativeStartHandlers.ts new file mode 100644 index 00000000..20229027 --- /dev/null +++ b/electron/ipc/register/recording/nativeStartHandlers.ts @@ -0,0 +1,490 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process" +import { spawn } from "node:child_process" +import path from "node:path" +import { app, desktopCapturer, dialog, ipcMain, shell, systemPreferences } from "electron" +import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../../constants" +import { + nativeCaptureMicrophonePath, + nativeCaptureOutputBuffer, + nativeCaptureProcess, + nativeCaptureSystemAudioPath, + nativeCaptureTargetPath, + nativeScreenRecordingActive, + setNativeCaptureMicrophonePath, + setNativeCaptureOutputBuffer, + setNativeCapturePaused, + setNativeCaptureProcess, + setNativeCaptureStopRequested, + setNativeCaptureSystemAudioPath, + setNativeCaptureTargetPath, + setNativeScreenRecordingActive, + setWindowsCaptureOutputBuffer, + setWindowsCapturePaused, + setWindowsCaptureProcess, + setWindowsCaptureStopRequested, + setWindowsCaptureTargetPath, + setWindowsMicAudioPath, + setWindowsNativeCaptureActive, + setWindowsSystemAudioPath, + windowsCaptureOutputBuffer, + windowsCaptureProcess, + windowsMicAudioPath, + windowsNativeCaptureActive, + windowsSystemAudioPath, + windowsCaptureTargetPath, + setNativeScreenRecordingActive as setRecordingActive, +} from "../../state" +import type { + NativeMacRecordingOptions, + SelectedSource, +} from "../../types" +import { + getMacPrivacySettingsUrl, + getRecordingsDir, + getScreen, + parseWindowId, +} from "../../utils" +import { + ensureNativeCaptureHelperBinary, + getWindowsCaptureExePath, +} from "../../paths/binaries" +import { recordNativeCaptureDiagnostics } from "../../recording/diagnostics" +import { + attachNativeCaptureLifecycle, + waitForNativeCaptureStart, +} from "../../recording/mac" +import { + attachWindowsCaptureLifecycle, + isNativeWindowsCaptureAvailable, + waitForWindowsCaptureStart, +} from "../../recording/windows" +import { getDisplayBoundsForSource } from "../../recording/ffmpeg" +import { resolveWindowsCaptureDisplay } from "../../windowsCaptureSelection" + +function normalizeDesktopSourceName(value: string) { + return value.trim().replace(/\s+/g, " ").toLowerCase() +} + +export function registerNativeRecordingStartHandlers() { + ipcMain.handle( + "start-native-screen-recording", + async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => { + 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 stale helper cleanup failures + } + setWindowsCaptureProcess(null) + setWindowsCaptureTargetPath(null) + setWindowsCaptureStopRequested(false) + } + + if (windowsCaptureProcess) { + return { + success: false, + message: "A native Windows screen recording is already active.", + } + } + + let windowsProcess: ChildProcessWithoutNullStreams | null = null + 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 + setWindowsSystemAudioPath(audioPath) + } + + if (options?.capturesMicrophone) { + const microphonePath = path.join(recordingsDir, `recording-${timestamp}.mic.wav`) + config.captureMic = true + config.micOutputPath = microphonePath + if (options.microphoneLabel) { + config.micDeviceName = options.microphoneLabel + } + setWindowsMicAudioPath(microphonePath) + } + + 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 + 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, + }) + + setWindowsCaptureOutputBuffer("") + setWindowsCaptureTargetPath(outputPath) + setWindowsCaptureStopRequested(false) + setWindowsCapturePaused(false) + windowsProcess = spawn(exePath, [JSON.stringify(config)], { + cwd: recordingsDir, + stdio: ["pipe", "pipe", "pipe"], + }) + setWindowsCaptureProcess(windowsProcess) + attachWindowsCaptureLifecycle(windowsProcess) + + windowsProcess.stdout.on("data", (chunk: Buffer) => { + setWindowsCaptureOutputBuffer(windowsCaptureOutputBuffer + chunk.toString()) + }) + windowsProcess.stderr.on("data", (chunk: Buffer) => { + setWindowsCaptureOutputBuffer(windowsCaptureOutputBuffer + chunk.toString()) + }) + + await waitForWindowsCaptureStart(windowsProcess) + setWindowsNativeCaptureActive(true) + setRecordingActive(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 { + windowsProcess?.kill() + } catch { + // ignore cleanup failures + } + setWindowsNativeCaptureActive(false) + setRecordingActive(false) + setWindowsCaptureProcess(null) + setWindowsCaptureTargetPath(null) + setWindowsCaptureStopRequested(false) + setWindowsCapturePaused(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 + } + setNativeCaptureProcess(null) + setNativeCaptureTargetPath(null) + setNativeCaptureStopRequested(false) + } + + if (nativeCaptureProcess) { + return { + success: false, + message: "A native screen recording is already active.", + } + } + + let nativeProcess: ChildProcessWithoutNullStreams | null = null + try { + const recordingsDir = await getRecordingsDir() + + try { + await desktopCapturer.getSources({ + types: ["screen"], + thumbnailSize: { width: 1, height: 1 }, + }) + } catch { + // non-fatal – the helper will report its own permission status + } + + if (options?.capturesMicrophone) { + const microphoneStatus = systemPreferences.getMediaAccessStatus("microphone") + if (microphoneStatus !== "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) + } + + setNativeCaptureOutputBuffer("") + setNativeCaptureTargetPath(outputPath) + setNativeCaptureSystemAudioPath(systemAudioOutputPath) + setNativeCaptureMicrophonePath(microphoneOutputPath) + setNativeCaptureStopRequested(false) + setNativeCapturePaused(false) + nativeProcess = spawn(helperPath, [JSON.stringify(config)], { + cwd: recordingsDir, + stdio: ["pipe", "pipe", "pipe"], + }) + setNativeCaptureProcess(nativeProcess) + attachNativeCaptureLifecycle(nativeProcess) + + nativeProcess.stdout.on("data", (chunk: Buffer) => { + setNativeCaptureOutputBuffer(nativeCaptureOutputBuffer + chunk.toString()) + }) + nativeProcess.stderr.on("data", (chunk: Buffer) => { + setNativeCaptureOutputBuffer(nativeCaptureOutputBuffer + chunk.toString()) + }) + + await waitForNativeCaptureStart(nativeProcess) + setNativeScreenRecordingActive(true) + + const microphoneUnavailableNatively = nativeCaptureOutputBuffer.includes( + "MICROPHONE_CAPTURE_UNAVAILABLE", + ) + if (microphoneUnavailableNatively) { + setNativeCaptureMicrophonePath(null) + } + + 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: nativeCaptureMicrophonePath, + processOutput: nativeCaptureOutputBuffer.trim() || undefined, + }) + return { + success: true, + microphoneFallbackRequired: microphoneUnavailableNatively, + } + } catch (error) { + console.error("Failed to start native ScreenCaptureKit recording:", error) + const errorString = String(error) + + if ( + errorString.includes("declined TCC") || + errorString.includes("declined TCCs") || + errorString.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 { + nativeProcess?.kill() + } catch { + // ignore cleanup failures + } + setNativeScreenRecordingActive(false) + setNativeCaptureProcess(null) + setNativeCaptureTargetPath(null) + setNativeCaptureSystemAudioPath(null) + setNativeCaptureMicrophonePath(null) + setNativeCaptureStopRequested(false) + setNativeCapturePaused(false) + return { + success: false, + message: + "Screen recording permission not granted. Please allow access in System Settings and restart the app.", + userNotified: true, + } + } + + if (errorString.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 { + nativeProcess?.kill() + } catch { + // ignore cleanup failures + } + setNativeScreenRecordingActive(false) + setNativeCaptureProcess(null) + setNativeCaptureTargetPath(null) + setNativeCaptureSystemAudioPath(null) + setNativeCaptureMicrophonePath(null) + setNativeCaptureStopRequested(false) + setNativeCapturePaused(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: await Promise.resolve().then(() => ensureNativeCaptureHelperBinary()).catch(() => null), + outputPath: nativeCaptureTargetPath, + systemAudioPath: nativeCaptureSystemAudioPath, + microphonePath: nativeCaptureMicrophonePath, + processOutput: nativeCaptureOutputBuffer.trim() || undefined, + error: String(error), + }) + try { + nativeProcess?.kill() + } catch { + // ignore cleanup failures + } + setNativeScreenRecordingActive(false) + setNativeCaptureProcess(null) + setNativeCaptureTargetPath(null) + setNativeCaptureSystemAudioPath(null) + setNativeCaptureMicrophonePath(null) + setNativeCaptureStopRequested(false) + setNativeCapturePaused(false) + return { + success: false, + message: "Failed to start native ScreenCaptureKit recording", + error: String(error), + } + } + }, + ) +} \ No newline at end of file diff --git a/electron/ipc/register/recording/nativeStopHandlers.ts b/electron/ipc/register/recording/nativeStopHandlers.ts new file mode 100644 index 00000000..b1628a35 --- /dev/null +++ b/electron/ipc/register/recording/nativeStopHandlers.ts @@ -0,0 +1,294 @@ +import fs from "node:fs/promises" +import { ipcMain } from "electron" +import { + lastNativeCaptureDiagnostics, + nativeCaptureMicrophonePath, + nativeCaptureOutputBuffer, + nativeCaptureProcess, + nativeCaptureSystemAudioPath, + nativeCaptureTargetPath, + nativeScreenRecordingActive, + setNativeCaptureMicrophonePath, + setNativeCapturePaused, + setNativeCaptureProcess, + setNativeCaptureStopRequested, + setNativeCaptureSystemAudioPath, + setNativeCaptureTargetPath, + setNativeScreenRecordingActive, + setNativeScreenRecordingActive as setRecordingActive, + setWindowsCapturePaused, + setWindowsCaptureProcess, + setWindowsCaptureStopRequested, + setWindowsCaptureTargetPath, + setWindowsMicAudioPath, + setWindowsNativeCaptureActive, + setWindowsPendingVideoPath, + setWindowsSystemAudioPath, + windowsCaptureOutputBuffer, + windowsCaptureProcess, + windowsCaptureTargetPath, + windowsMicAudioPath, + windowsNativeCaptureActive, + windowsSystemAudioPath, +} from "../../state" +import { getFileSizeIfPresent, recordNativeCaptureDiagnostics } from "../../recording/diagnostics" +import { + finalizeStoredVideo, + muxNativeMacRecordingWithAudio, + recoverNativeMacCaptureOutput, + waitForNativeCaptureStop, +} from "../../recording/mac" +import { waitForWindowsCaptureStop } from "../../recording/windows" +import { moveFileWithOverwrite } from "../../utils" + +export function registerNativeRecordingStopHandlers() { + ipcMain.handle("stop-native-screen-recording", async () => { + if (process.platform === "win32" && windowsNativeCaptureActive) { + try { + if (!windowsCaptureProcess) { + throw new Error("Native Windows capture process is not running") + } + + const process = windowsCaptureProcess + const preferredVideoPath = windowsCaptureTargetPath + setWindowsCaptureStopRequested(true) + process.stdin.write("stop\n") + const tempVideoPath = await waitForWindowsCaptureStop(process) + setWindowsCaptureProcess(null) + setWindowsNativeCaptureActive(false) + setRecordingActive(false) + setWindowsCaptureTargetPath(null) + setWindowsCaptureStopRequested(false) + setWindowsCapturePaused(false) + + const finalVideoPath = preferredVideoPath ?? tempVideoPath + if (tempVideoPath !== finalVideoPath) { + await moveFileWithOverwrite(tempVideoPath, finalVideoPath) + } + + setWindowsPendingVideoPath(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 + setWindowsNativeCaptureActive(false) + setRecordingActive(false) + setWindowsCaptureProcess(null) + setWindowsCaptureTargetPath(null) + setWindowsCaptureStopRequested(false) + setWindowsCapturePaused(false) + setWindowsSystemAudioPath(null) + setWindowsMicAudioPath(null) + setWindowsPendingVideoPath(null) + + if (fallbackPath) { + try { + await fs.access(fallbackPath) + setWindowsPendingVideoPath(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, + ) + setNativeCaptureStopRequested(true) + process.stdin.write("stop\n") + const tempVideoPath = await waitForNativeCaptureStop(process) + console.log("[stop-native] Helper stopped, tempVideoPath:", tempVideoPath) + setNativeCaptureProcess(null) + setNativeScreenRecordingActive(false) + setNativeCaptureTargetPath(null) + setNativeCaptureSystemAudioPath(null) + setNativeCaptureMicrophonePath(null) + setNativeCaptureStopRequested(false) + setNativeCapturePaused(false) + + const finalVideoPath = preferredVideoPath ?? tempVideoPath + if (tempVideoPath !== finalVideoPath) { + await moveFileWithOverwrite(tempVideoPath, finalVideoPath) + } + + if (preferredSystemAudioPath || preferredMicrophonePath) { + console.log( + "[stop-native] Attempting audio mux (merging separate tracks) into:", + finalVideoPath, + ) + try { + await muxNativeMacRecordingWithAudio( + finalVideoPath, + preferredSystemAudioPath, + preferredMicrophonePath, + ) + console.log("[stop-native] Audio mux completed successfully") + } catch (error) { + console.warn( + "[stop-native] Audio mux failed (video still has inline audio):", + error, + ) + } + } else { + console.log("[stop-native] No separate audio tracks to mux") + } + + return await finalizeStoredVideo(finalVideoPath) + } catch (error) { + console.error("Failed to stop native ScreenCaptureKit recording:", error) + const fallbackPath = nativeCaptureTargetPath + const fallbackSystemAudioPath = nativeCaptureSystemAudioPath + const fallbackMicrophonePath = nativeCaptureMicrophonePath + const fallbackFileSizeBytes = await getFileSizeIfPresent(fallbackPath) + setNativeScreenRecordingActive(false) + setNativeCaptureProcess(null) + setNativeCaptureTargetPath(null) + setNativeCaptureSystemAudioPath(null) + setNativeCaptureMicrophonePath(null) + setNativeCaptureStopRequested(false) + setNativeCapturePaused(false) + + recordNativeCaptureDiagnostics({ + backend: "mac-screencapturekit", + phase: "stop", + sourceId: lastNativeCaptureDiagnostics?.sourceId ?? null, + sourceType: lastNativeCaptureDiagnostics?.sourceType ?? "unknown", + displayId: lastNativeCaptureDiagnostics?.displayId ?? null, + displayBounds: lastNativeCaptureDiagnostics?.displayBounds ?? null, + windowHandle: lastNativeCaptureDiagnostics?.windowHandle ?? null, + helperPath: lastNativeCaptureDiagnostics?.helperPath ?? null, + outputPath: fallbackPath, + systemAudioPath: fallbackSystemAudioPath, + microphonePath: fallbackMicrophonePath, + osRelease: lastNativeCaptureDiagnostics?.osRelease, + supported: lastNativeCaptureDiagnostics?.supported, + helperExists: lastNativeCaptureDiagnostics?.helperExists, + processOutput: nativeCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: fallbackFileSizeBytes, + error: String(error), + }) + + 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.", + } + }) +} \ No newline at end of file diff --git a/electron/ipc/register/recording/storageHandlers.ts b/electron/ipc/register/recording/storageHandlers.ts new file mode 100644 index 00000000..c6e0fc07 --- /dev/null +++ b/electron/ipc/register/recording/storageHandlers.ts @@ -0,0 +1,71 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { ipcMain } from "electron" +import { getRecordingsDir } from "../../utils" +import { finalizeStoredVideo } from "../../recording/mac" + +export function registerRecordingStorageHandlers() { + ipcMain.handle("store-microphone-sidecar", async (_, audioData: ArrayBuffer, videoPath: string) => { + try { + const baseName = videoPath.replace(/\.[^.]+$/, "") + const sidecarPath = `${baseName}.mic.webm` + await fs.writeFile(sidecarPath, Buffer.from(audioData)) + return { success: true, path: sidecarPath } + } catch (error) { + console.error("Failed to store microphone sidecar:", error) + return { success: false, 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 entries = await fs.readdir(recordingsDir, { withFileTypes: true }) + const candidates = await Promise.all( + entries + .filter( + (entry) => entry.isFile() && /^recording-\d+\.(webm|mov|mp4)$/i.test(entry.name), + ) + .map(async (entry) => { + const fullPath = path.join(recordingsDir, entry.name) + const stat = await fs.stat(fullPath).catch(() => null) + return stat ? { path: fullPath, mtimeMs: stat.mtimeMs } : null + }), + ) + const latestVideo = candidates + .filter( + (candidate): candidate is { path: string; mtimeMs: number } => candidate !== null, + ) + .sort((left, right) => right.mtimeMs - left.mtimeMs)[0] + + if (!latestVideo) { + return { success: false, message: "No recorded video found" } + } + + return { success: true, path: latestVideo.path } + } catch (error) { + console.error("Failed to get video path:", error) + return { + success: false, + message: "Failed to get video path", + error: String(error), + } + } + }) +} \ No newline at end of file diff --git a/electron/ipc/register/recording/telemetryHandlers.ts b/electron/ipc/register/recording/telemetryHandlers.ts new file mode 100644 index 00000000..371a7c11 --- /dev/null +++ b/electron/ipc/register/recording/telemetryHandlers.ts @@ -0,0 +1,143 @@ +import fs from "node:fs/promises" +import { BrowserWindow, ipcMain } from "electron" +import { showCursor } from "../../../cursorHider" +import { + currentVideoPath, + selectedSource, + setActiveCursorSamples, + setCursorCaptureStartTimeMs, + setIsCursorCaptureActive, + setLastLeftClick, + setLinuxCursorScreenPoint, + setPendingCursorSamples, +} from "../../state" +import type { CursorTelemetryPoint } from "../../types" +import { getTelemetryPathForVideo, normalizeVideoSourcePath } from "../../utils" +import { + clamp, + sampleCursorPoint, + snapshotCursorTelemetryForPersistence, + startCursorSampling, + stopCursorCapture, +} from "../../cursor/telemetry" +import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../../cursor/bounds" +import { startInteractionCapture, stopInteractionCapture } from "../../cursor/interaction" +import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../../cursor/monitor" + +export function registerRecordingTelemetryHandlers( + onRecordingStateChange?: (recording: boolean, sourceName: string) => void, +) { + ipcMain.handle("set-recording-state", (_, recording: boolean) => { + if (recording) { + stopCursorCapture() + stopInteractionCapture() + startWindowBoundsCapture() + void startNativeCursorMonitor() + setIsCursorCaptureActive(true) + setActiveCursorSamples([]) + setPendingCursorSamples([]) + setCursorCaptureStartTimeMs(Date.now()) + setLinuxCursorScreenPoint(null) + setLastLeftClick(null) + sampleCursorPoint() + startCursorSampling() + void startInteractionCapture() + } else { + setIsCursorCaptureActive(false) + stopCursorCapture() + stopInteractionCapture() + stopWindowBoundsCapture() + stopNativeCursorMonitor() + showCursor() + setLinuxCursorScreenPoint(null) + snapshotCursorTelemetryForPersistence() + setActiveCursorSamples([]) + } + + const source = selectedSource || { name: "Screen" } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("recording-state-changed", { + recording, + sourceName: source.name, + }) + } + } + + 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((left: CursorTelemetryPoint, right: CursorTelemetryPoint) => left.timeMs - right.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: [], + } + } + }) +} \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index 636c29ff..68877dfb 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,20 +1,6 @@ -import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { - app, - BrowserWindow, - desktopCapturer, - dialog, - ipcMain, - Menu, - Notification, - nativeImage, - session, - systemPreferences, - Tray, -} from "electron"; -import { RECORDINGS_DIR } from "./appPaths"; +import { app, desktopCapturer, ipcMain, session, systemPreferences } from "electron"; import { showCursor } from "./cursorHider"; import { registerExtensionIpcHandlers } from "./extensions/extensionIpc"; import { @@ -23,31 +9,39 @@ import { killWindowsCaptureProcess, registerIpcHandlers, } from "./ipc/handlers"; +import { + configureGpuAccelerationSwitches, + ensureRecordingsDir, + logSmokeExportGpuDiagnostics, +} from "./mainBootstrapHelpers"; +import { mainRuntimeState } from "./mainRuntimeState"; +import { + initializeMainUpdateIntegration, + registerUpdateIpcHandlers, + runManualUpdateCheck, + setupMainAutoUpdates, +} from "./mainUpdateIntegration"; +import { + createEditorWindowWrapper, + createSourceSelectorWindowWrapper, + createTray, + createWindow, + focusOrCreateMainWindow, + initializeMainWindowControls, + reassertHudOverlayMouseState, + restoreWindowSafely, + setupApplicationMenu, + syncDockIcon, + updateTrayMenu, +} from "./mainWindowControls"; import { ensureMediaServer } from "./mediaServer"; import { ensurePackagedRendererServer } from "./rendererServer"; -import type { UpdateToastPayload } from "./updater"; -import { - checkForAppUpdates, - deferUpdateReminder, - dismissUpdateToast, - downloadAvailableUpdate, - getCurrentUpdateToastPayload, - getUpdaterLogPath, - getUpdateStatusSummary, - installDownloadedUpdateNow, - previewUpdateToast, - setupAutoUpdates, - skipAvailableUpdateVersion, -} from "./updater"; import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow, getHudOverlayWindow, - getUpdateToastWindow, - hideUpdateToastWindow, isHudOverlayMousePassthroughSupported, - showUpdateToastWindow, } from "./windows"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -57,62 +51,10 @@ app.commandLine.appendSwitch("ignore-gpu-blocklist"); app.commandLine.appendSwitch("enable-unsafe-webgpu"); app.commandLine.appendSwitch("enable-gpu-rasterization"); -function configureGpuAccelerationSwitches() { - if (process.platform === "darwin") { - app.commandLine.appendSwitch("use-angle", "metal"); - app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare"); - return; - } - - if (process.platform === "win32") { - app.commandLine.appendSwitch("use-angle", "d3d11"); - return; - } - - // Linux (and other Unix): prefer EGL over GLX for better Wayland compatibility. - // Disable VAAPI — many distros ship broken drivers that cause - // "vaInitialize failed" and prevent the renderer from loading. - app.commandLine.appendSwitch("use-gl", "egl"); - app.commandLine.appendSwitch("disable-features", "VaapiVideoDecoder,VaapiVideoEncoder"); -} - -async function logSmokeExportGpuDiagnostics() { - if (!IS_SMOKE_EXPORT) { - return; - } - - try { - console.log("[smoke-export] GPU feature status", JSON.stringify(app.getGPUFeatureStatus())); - console.log("[smoke-export] GPU info", JSON.stringify(await app.getGPUInfo("basic"))); - } catch (error) { - console.warn("[smoke-export] Failed to read GPU diagnostics:", error); - } -} - configureGpuAccelerationSwitches(); -async function ensureRecordingsDir() { - try { - await fs.mkdir(RECORDINGS_DIR, { recursive: true }); - console.log("RECORDINGS_DIR:", RECORDINGS_DIR); - console.log("User Data Path:", app.getPath("userData")); - } catch (error) { - console.error("Failed to create recordings directory:", error); - } -} - -// The built directory structure -// -// ├─┬─┬ dist -// │ │ └── index.html -// │ │ -// │ ├─┬ dist-electron -// │ │ ├── main.js -// │ │ └── preload.mjs -// │ process.env.APP_ROOT = path.join(__dirname, ".."); -// Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x export const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron"); export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist"); @@ -121,645 +63,27 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST; -// Window references -let mainWindow: BrowserWindow | null = null; -let sourceSelectorWindow: BrowserWindow | null = null; -let tray: Tray | null = null; -let trayContextMenu: Menu | null = null; -let selectedSourceName = ""; -let editorHasUnsavedChanges = false; -let isForceClosing = false; -let activeUpdateNotification: Notification | null = null; -let activeUpdateNotificationKey: string | null = null; const hasSingleInstanceLock = app.requestSingleInstanceLock(); - if (!hasSingleInstanceLock) { app.quit(); } -function closeEditorWindowBypassingUnsavedPrompt(window: BrowserWindow | null) { - if (!window || window.isDestroyed()) { - return; - } - - if (isEditorWindow(window)) { - isForceClosing = true; - editorHasUnsavedChanges = false; - } - window.close(); -} - -function restoreWindowSafely(window: BrowserWindow | null) { - if (!window || window.isDestroyed()) { - return; - } - - window.restore(); -} - -// Tray Icons (lazily created after app is ready to avoid accessing Electron APIs too early) -let defaultTrayIcon: ReturnType | null = null; -let recordingTrayIcon: ReturnType | null = null; - -function getDefaultTrayIcon() { - if (!defaultTrayIcon) { - defaultTrayIcon = getTrayIcon("app-icons/recordly-32.png"); - } - return defaultTrayIcon; -} - -function getRecordingTrayIcon() { - if (!recordingTrayIcon) { - recordingTrayIcon = getTrayIcon("rec-button.png"); - } - return recordingTrayIcon; -} - -function showHudOverlayFromTray() { - const hud = getHudOverlayWindow(); - if (!hud) { - return false; - } - - if (hud.isMinimized()) { - hud.restore(); - } - - if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { - hud.showInactive(); - hud.moveTop(); - reassertHudOverlayMouseState(); - return true; - } - - hud.show(); - hud.moveTop(); - hud.focus(); - return true; -} - -ipcMain.on("set-has-unsaved-changes", (_event, hasChanges: boolean) => { - editorHasUnsavedChanges = hasChanges; +initializeMainWindowControls({ + rendererDist: RENDERER_DIST, + createHudOverlayWindow, + createEditorWindow, + createSourceSelectorWindow, + getHudOverlayWindow, + isHudOverlayMousePassthroughSupported, + onCheckForUpdates: runManualUpdateCheck, }); -function createWindow() { - if (!app.isReady()) { - void app.whenReady().then(() => { - if (!mainWindow || mainWindow.isDestroyed()) { - createWindow(); - } - }); - return; - } - - mainWindow = createHudOverlayWindow(); -} - -function focusOrCreateMainWindow() { - if (!app.isReady()) { - void app.whenReady().then(() => { - focusOrCreateMainWindow(); - }); - return; - } - - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - return; - } - - if (mainWindow && !mainWindow.isDestroyed()) { - // On Linux/Wayland, focus() often doesn't take effect (compositor ignores it). Apps like Telegram - // work because they receive an XDG activation token via StatusNotifierItem.ProvideXdgActivationToken; - // Electron's tray doesn't handle that yet. Workaround: destroy and recreate the HUD so the new - // window gets focus (creation path works). Only for HUD, not editor. - if ( - process.platform === "linux" && - !mainWindow.isFocused() && - !isEditorWindow(mainWindow) - ) { - const win = mainWindow; - mainWindow = null; - win.once("closed", () => createWindow()); - win.destroy(); - return; - } - - // On Win32 with mouse passthrough enabled (Win11+), calling - // show/moveTop/focus on the transparent HUD overlay permanently corrupts - // setIgnoreMouseEvents forwarding, making it click-through. Only focus - // the editor window; the HUD is alwaysOnTop so it doesn't need explicit - // focus. On Win10 (passthrough disabled), the HUD is always interactive - // and can be safely shown/restored. - if ( - process.platform === "win32" && - !isEditorWindow(mainWindow) && - isHudOverlayMousePassthroughSupported() - ) { - showHudOverlayFromTray(); - return; - } - - mainWindow.show(); - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.moveTop(); - mainWindow.focus(); - } -} - -/** - * On Windows 10, focus changes and native notifications can break - * {@link BrowserWindow.setIgnoreMouseEvents} forwarding on the transparent HUD - * overlay, causing it to become permanently click-through. Call this after any - * operation that may alter focus or z-order so that hover detection keeps working. - */ -function reassertHudOverlayMouseState() { - if (process.platform !== "win32" || !isHudOverlayMousePassthroughSupported()) { - return; - } - - const hud = getHudOverlayWindow(); - if (!hud) { - return; - } - - // Toggle off then back on so the native WS_EX_TRANSPARENT flag is fully - // re-initialised rather than merely re-asserted in a potentially broken state. - hud.setIgnoreMouseEvents(false); - setTimeout(() => { - if (!hud.isDestroyed()) { - hud.setIgnoreMouseEvents(true, { forward: true }); - } - }, 50); -} - -function isEditorWindow(window: BrowserWindow) { - return window.webContents.getURL().includes("windowType=editor"); -} - -function sendEditorMenuAction( - channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as", -) { - let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow; - - if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) { - createEditorWindowWrapper(); - targetWindow = mainWindow; - if (!targetWindow || targetWindow.isDestroyed()) return; - - targetWindow.webContents.once("did-finish-load", () => { - if (!targetWindow || targetWindow.isDestroyed()) return; - targetWindow.webContents.send(channel); - }); - return; - } - - targetWindow.webContents.send(channel); -} - -function setupApplicationMenu() { - const isMac = process.platform === "darwin"; - if (!isMac) { - Menu.setApplicationMenu(null); - return; - } - - const template: Electron.MenuItemConstructorOptions[] = []; - template.push({ - label: app.name, - submenu: [ - { role: "about" }, - { type: "separator" }, - { role: "services" }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { role: "quit" }, - ], - }); - - template.push( - { - label: "File", - submenu: [ - { - label: "Open Projects…", - accelerator: "CmdOrCtrl+O", - click: () => sendEditorMenuAction("menu-load-project"), - }, - { - label: "Save Project…", - accelerator: "CmdOrCtrl+S", - click: () => sendEditorMenuAction("menu-save-project"), - }, - { - label: "Save Project As…", - accelerator: "CmdOrCtrl+Shift+S", - click: () => sendEditorMenuAction("menu-save-project-as"), - }, - ...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]), - ], - }, - { - label: "Edit", - submenu: [ - { role: "undo" }, - { role: "redo" }, - { type: "separator" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { role: "selectAll" }, - ], - }, - { - label: "View", - submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, - { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, - { type: "separator" }, - { role: "togglefullscreen" }, - ], - }, - { - label: "Window", - submenu: isMac - ? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }] - : [{ role: "minimize" }, { role: "close" }], - }, - { - label: "Help", - submenu: [ - { - label: "Check for Updates…", - click: () => { - void checkForAppUpdates(getUpdateDialogWindow, { manual: true }); - }, - }, - ], - }, - ); - - const menu = Menu.buildFromTemplate(template); - Menu.setApplicationMenu(menu); -} - -function isPrimaryTrayClick(event: unknown) { - const button = - event && typeof event === "object" && "button" in event - ? (event as { button?: number | string }).button - : undefined; - return button === undefined || button === 0 || button === "left"; -} - -function createTray() { - tray = new Tray(getDefaultTrayIcon()); - tray.on("click", (event) => { - if (process.platform === "win32" && !isPrimaryTrayClick(event)) { - return; - } - - focusOrCreateMainWindow(); - }); - - if (process.platform === "win32") { - tray.on("right-click", () => { - if (!tray || !trayContextMenu) { - return; - } - - tray.popUpContextMenu(trayContextMenu); - }); - return; - } - - tray.on("double-click", () => focusOrCreateMainWindow()); -} - -function getPublicAssetPath(filename: string) { - return path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename); -} - -function getAppImage(filename: string) { - return nativeImage.createFromPath(getPublicAssetPath(filename)); -} - -function getTrayIcon(filename: string) { - return getAppImage(filename).resize({ - width: 24, - height: 24, - quality: "best", - }); -} - -function syncDockIcon() { - if (process.platform !== "darwin" || !app.dock) { - return; - } - - const dockIcon = getAppImage("app-icons/recordly-512.png"); - if (!dockIcon.isEmpty()) { - app.dock.setIcon(dockIcon); - } -} - -function getUpdateNotificationTitle(payload: UpdateToastPayload) { - switch (payload.phase) { - case "available": - return `Recordly ${payload.version} is available`; - case "downloading": - return `Downloading Recordly ${payload.version}`; - case "ready": - return `Recordly ${payload.version} is ready`; - case "error": - return `Recordly ${payload.version} needs attention`; - } -} - -function getUpdateNotificationBody(payload: UpdateToastPayload) { - switch (payload.phase) { - case "available": - return "Click to download the update."; - case "downloading": - return "Recordly is downloading the update in the foreground."; - case "ready": - return "Click to install the downloaded update."; - case "error": - return "Click to retry checking for updates."; - } -} - -function clearActiveUpdateNotification() { - if (activeUpdateNotification) { - activeUpdateNotification.close(); - activeUpdateNotification = null; - } - activeUpdateNotificationKey = null; -} - -function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknown) { - if (process.platform !== "darwin") { - if (!payload) { - clearActiveUpdateNotification(); - return true; - } - - const updatePayload = payload as UpdateToastPayload; - if (updatePayload.phase === "downloading") { - return true; - } - - if (!Notification.isSupported()) { - return false; - } - - const notificationKey = [ - updatePayload.phase, - updatePayload.version, - updatePayload.detail, - ].join(":"); - if (activeUpdateNotificationKey === notificationKey) { - return true; - } - - clearActiveUpdateNotification(); - const notification = new Notification({ - title: getUpdateNotificationTitle(updatePayload), - body: getUpdateNotificationBody(updatePayload), - icon: getAppImage("app-icons/recordly-128.png"), - silent: false, - }); - - notification.on("click", () => { - focusOrCreateMainWindow(); - switch (updatePayload.phase) { - case "available": - void downloadAvailableUpdate(sendUpdateToastToWindows); - break; - case "ready": - installDownloadedUpdateNow(sendUpdateToastToWindows); - break; - case "error": - void checkForAppUpdates(getUpdateDialogWindow, { manual: true }); - break; - default: - break; - } - }); - - notification.on("close", () => { - if (activeUpdateNotification === notification) { - activeUpdateNotification = null; - activeUpdateNotificationKey = null; - } - }); - - notification.show(); - // On Win10, showing a native notification can break setIgnoreMouseEvents - // forwarding on the transparent HUD overlay. Re-assert it after a short - // delay so the renderer's hover detection keeps working. - reassertHudOverlayMouseState(); - activeUpdateNotification = notification; - activeUpdateNotificationKey = notificationKey; - return true; - } - - if (!payload) { - const existingWindow = getUpdateToastWindow(); - if (!existingWindow) { - return false; - } - - existingWindow.webContents.send(channel, null); - hideUpdateToastWindow(); - return true; - } - - const toastWindow = showUpdateToastWindow(); - const sendPayload = () => { - toastWindow.webContents.send(channel, payload); - showUpdateToastWindow(); - }; - - if (toastWindow.webContents.isLoadingMainFrame()) { - toastWindow.webContents.once("did-finish-load", sendPayload); - } else { - sendPayload(); - } - - return true; -} - -function getUpdateDialogWindow() { - const focusedWindow = BrowserWindow.getFocusedWindow(); - if (focusedWindow && !focusedWindow.isDestroyed()) { - return focusedWindow; - } - - if (mainWindow && !mainWindow.isDestroyed()) { - return mainWindow; - } - - return getHudOverlayWindow(); -} - -ipcMain.handle("install-downloaded-update", () => { - installDownloadedUpdateNow(sendUpdateToastToWindows); - return { success: true }; +initializeMainUpdateIntegration({ + rendererDist: RENDERER_DIST, + focusOrCreateMainWindow, + reassertHudOverlayMouseState, }); -ipcMain.handle("download-available-update", () => { - return downloadAvailableUpdate(sendUpdateToastToWindows); -}); - -ipcMain.handle("defer-downloaded-update", (_event, delayMs?: number) => { - return deferUpdateReminder(getUpdateDialogWindow, sendUpdateToastToWindows, delayMs); -}); - -ipcMain.handle("dismiss-update-toast", () => { - return dismissUpdateToast(getUpdateDialogWindow, sendUpdateToastToWindows); -}); - -ipcMain.handle("skip-update-version", () => { - return skipAvailableUpdateVersion(sendUpdateToastToWindows); -}); - -ipcMain.handle("get-current-update-toast-payload", () => { - return getCurrentUpdateToastPayload(); -}); - -ipcMain.handle("get-update-status-summary", () => { - return getUpdateStatusSummary(); -}); - -ipcMain.handle("preview-update-toast", () => { - return { success: previewUpdateToast(sendUpdateToastToWindows) }; -}); - -ipcMain.handle("check-for-app-updates", async () => { - await checkForAppUpdates(getUpdateDialogWindow, { manual: true }); - return { success: true, logPath: getUpdaterLogPath() }; -}); - -function updateTrayMenu(recording: boolean = false) { - if (!tray) return; - const trayIcon = recording ? getRecordingTrayIcon() : getDefaultTrayIcon(); - const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "Recordly"; - const menuTemplate = recording - ? [ - { - label: "Show Controls", - click: () => { - if (!showHudOverlayFromTray()) { - focusOrCreateMainWindow(); - } - }, - }, - { - label: "Stop Recording", - click: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("stop-recording-from-tray"); - } - }, - }, - ] - : [ - { - label: "Open", - click: () => { - if (!showHudOverlayFromTray()) { - focusOrCreateMainWindow(); - } - }, - }, - { - label: "Quit", - click: () => { - app.quit(); - }, - }, - ]; - const menu = Menu.buildFromTemplate(menuTemplate); - trayContextMenu = menu; - tray.setImage(trayIcon); - tray.setToolTip(trayToolTip); - if (process.platform !== "win32") { - tray.setContextMenu(menu); - } -} - -function createEditorWindowWrapper() { - const previousWindow = mainWindow; - if (previousWindow && !previousWindow.isDestroyed()) { - const closingEditorWindow = isEditorWindow(previousWindow); - closeEditorWindowBypassingUnsavedPrompt(previousWindow); - if (!closingEditorWindow) { - isForceClosing = false; - } - if (mainWindow === previousWindow) { - mainWindow = null; - } - } - const editorWindow = createEditorWindow(); - mainWindow = editorWindow; - editorHasUnsavedChanges = false; - - editorWindow.on("closed", () => { - if (mainWindow === editorWindow) { - mainWindow = null; - } - isForceClosing = false; - editorHasUnsavedChanges = false; - }); - - editorWindow.on("close", (event) => { - if (isForceClosing || !editorHasUnsavedChanges) { - return; - } - - event.preventDefault(); - - const choice = dialog.showMessageBoxSync(editorWindow, { - type: "warning", - buttons: ["Save & Close", "Discard & Close", "Cancel"], - defaultId: 0, - cancelId: 2, - title: "Unsaved Changes", - message: "You have unsaved changes.", - detail: "Do you want to save your project before closing?", - }); - - if (choice === 0) { - editorWindow.webContents.send("request-save-before-close"); - ipcMain.once("save-before-close-done", (_event, saved: boolean) => { - if (saved) { - closeEditorWindowBypassingUnsavedPrompt(editorWindow); - } - }); - } else if (choice === 1) { - closeEditorWindowBypassingUnsavedPrompt(editorWindow); - } - }); -} - -function createSourceSelectorWindowWrapper() { - sourceSelectorWindow = createSourceSelectorWindow(); - sourceSelectorWindow.on("closed", () => { - sourceSelectorWindow = null; - }); - return sourceSelectorWindow; -} - -// On macOS, applications and their menu bar stay active until the user quits -// explicitly with Cmd + Q. app.on("before-quit", () => { killWindowsCaptureProcess(); showCursor(); @@ -773,8 +97,6 @@ app.on("window-all-closed", () => { }); app.on("activate", () => { - // On OS X it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. focusOrCreateMainWindow(); }); @@ -782,7 +104,6 @@ app.on("second-instance", () => { focusOrCreateMainWindow(); }); -// Register all IPC handlers when app is ready app.whenReady().then(async () => { if (process.platform === "win32") { app.setAppUserModelId("dev.recordly.app"); @@ -798,7 +119,7 @@ app.whenReady().then(async () => { callback(allowed.includes(permission)); }); - session.defaultSession.setDevicePermissionHandler((_details) => true); + session.defaultSession.setDevicePermissionHandler(() => true); if (process.platform === "darwin") { const cameraStatus = systemPreferences.getMediaAccessStatus("camera"); @@ -828,11 +149,12 @@ app.whenReady().then(async () => { ipcMain.on("hud-overlay-close", () => { app.quit(); }); + + registerUpdateIpcHandlers(); syncDockIcon(); createTray(); updateTrayMenu(); setupApplicationMenu(); - // Ensure recordings directory exists await ensureRecordingsDir(); if (!VITE_DEV_SERVER_URL) { @@ -852,17 +174,19 @@ app.whenReady().then(async () => { registerIpcHandlers( createEditorWindowWrapper, createSourceSelectorWindowWrapper, - () => mainWindow, - () => sourceSelectorWindow, + () => mainRuntimeState.mainWindow, + () => mainRuntimeState.sourceSelectorWindow, (recording: boolean, sourceName: string) => { - selectedSourceName = sourceName; - if (!tray) createTray(); + mainRuntimeState.selectedSourceName = sourceName; + if (!mainRuntimeState.tray) { + createTray(); + } updateTrayMenu(recording); if (recording) { reassertHudOverlayMouseState(); } if (!recording) { - restoreWindowSafely(mainWindow); + restoreWindowSafely(mainRuntimeState.mainWindow); } }, ); @@ -870,7 +194,7 @@ app.whenReady().then(async () => { registerExtensionIpcHandlers(); if (IS_SMOKE_EXPORT) { - await logSmokeExportGpuDiagnostics(); + await logSmokeExportGpuDiagnostics(IS_SMOKE_EXPORT); console.log( `[smoke-export] Starting editor smoke export for ${process.env.RECORDLY_SMOKE_EXPORT_INPUT ?? ""}`, ); @@ -879,46 +203,17 @@ app.whenReady().then(async () => { } createWindow(); - setupAutoUpdates(getUpdateDialogWindow, sendUpdateToastToWindows); + setupMainAutoUpdates(); - // Register the display media handler so that renderer's getDisplayMedia() - // calls land on the pre-selected source without showing a system picker. - // - // IMPORTANT: The callback must receive a plain { id, name } Video object. - // Passing the full DesktopCapturerSource (with thumbnail, appIcon, etc.) - // via an unsafe cast breaks Electron's internal cursor-constraint - // propagation and causes cursor: 'never' from the renderer to be silently - // ignored by the native capture pipeline. session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { try { - const sourceId = getSelectedSourceId(); - // On Linux/Wayland, calling desktopCapturer.getSources() itself - // invokes the xdg-desktop-portal picker. If we then return one of - // those sources, Chromium triggers a SECOND portal because the - // pre-enumerated source IDs are stale on Wayland. To collapse this - // into a single portal invocation, when the Linux portal sentinel - // is set we skip getSources entirely and hand back a synthetic - // source id; Chromium then opens the portal once to actually - // resolve the capture. - // Default to the sentinel on Linux when no source has been - // pre-selected (e.g. fresh session where the renderer skipped the - // source picker entirely). This avoids calling getSources() which - // would itself trigger an extra portal dialog. - const isLinuxPortalSentinel = - process.platform === "linux" && - (sourceId === "screen:linux-portal" || !sourceId); - if (isLinuxPortalSentinel) { - callback({ video: { id: "screen:0:0", name: "Entire screen" } }); - return; - } const sources = await desktopCapturer.getSources({ types: ["screen", "window"] }); + const sourceId = getSelectedSourceId(); const source = sourceId - ? (sources.find((s) => s.id === sourceId) ?? sources[0]) + ? (sources.find((candidate) => candidate.id === sourceId) ?? sources[0]) : sources[0]; if (source) { - callback({ - video: { id: source.id, name: source.name }, - }); + callback({ video: { id: source.id, name: source.name } }); } else { callback({}); } @@ -927,9 +222,4 @@ app.whenReady().then(async () => { callback({}); } }); - - const currentToastPayload = getCurrentUpdateToastPayload(); - if (currentToastPayload) { - sendUpdateToastToWindows("update-toast-state", currentToastPayload); - } }); diff --git a/electron/mainBootstrapHelpers.ts b/electron/mainBootstrapHelpers.ts new file mode 100644 index 00000000..342c94ea --- /dev/null +++ b/electron/mainBootstrapHelpers.ts @@ -0,0 +1,42 @@ +import fs from "node:fs/promises"; +import { app } from "electron"; +import { RECORDINGS_DIR } from "./appPaths"; + +export function configureGpuAccelerationSwitches() { + if (process.platform === "darwin") { + app.commandLine.appendSwitch("use-angle", "metal"); + app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare"); + return; + } + + if (process.platform === "win32") { + app.commandLine.appendSwitch("use-angle", "d3d11"); + return; + } + + app.commandLine.appendSwitch("use-gl", "egl"); + app.commandLine.appendSwitch("disable-features", "VaapiVideoDecoder,VaapiVideoEncoder"); +} + +export async function logSmokeExportGpuDiagnostics(isSmokeExport: boolean) { + if (!isSmokeExport) { + return; + } + + try { + console.log("[smoke-export] GPU feature status", JSON.stringify(app.getGPUFeatureStatus())); + console.log("[smoke-export] GPU info", JSON.stringify(await app.getGPUInfo("basic"))); + } catch (error) { + console.warn("[smoke-export] Failed to read GPU diagnostics:", error); + } +} + +export async function ensureRecordingsDir() { + try { + await fs.mkdir(RECORDINGS_DIR, { recursive: true }); + console.log("RECORDINGS_DIR:", RECORDINGS_DIR); + console.log("User Data Path:", app.getPath("userData")); + } catch (error) { + console.error("Failed to create recordings directory:", error); + } +} \ No newline at end of file diff --git a/electron/mainRuntimeState.ts b/electron/mainRuntimeState.ts new file mode 100644 index 00000000..783770fb --- /dev/null +++ b/electron/mainRuntimeState.ts @@ -0,0 +1,27 @@ +import type { BrowserWindow, Menu, NativeImage, Notification, Tray } from "electron"; + +export const mainRuntimeState: { + mainWindow: BrowserWindow | null; + sourceSelectorWindow: BrowserWindow | null; + tray: Tray | null; + trayContextMenu: Menu | null; + selectedSourceName: string; + editorHasUnsavedChanges: boolean; + isForceClosing: boolean; + activeUpdateNotification: Notification | null; + activeUpdateNotificationKey: string | null; + defaultTrayIcon: NativeImage | null; + recordingTrayIcon: NativeImage | null; +} = { + mainWindow: null, + sourceSelectorWindow: null, + tray: null, + trayContextMenu: null, + selectedSourceName: "", + editorHasUnsavedChanges: false, + isForceClosing: false, + activeUpdateNotification: null, + activeUpdateNotificationKey: null, + defaultTrayIcon: null, + recordingTrayIcon: null, +}; \ No newline at end of file diff --git a/electron/mainUpdateIntegration.ts b/electron/mainUpdateIntegration.ts new file mode 100644 index 00000000..6ccbc7e3 --- /dev/null +++ b/electron/mainUpdateIntegration.ts @@ -0,0 +1,230 @@ +import path from "node:path"; +import { BrowserWindow, ipcMain, Notification, nativeImage } from "electron"; +import type { UpdateToastPayload } from "./updater"; +import { + checkForAppUpdates, + deferUpdateReminder, + dismissUpdateToast, + downloadAvailableUpdate, + getCurrentUpdateToastPayload, + getUpdaterLogPath, + getUpdateStatusSummary, + installDownloadedUpdateNow, + previewUpdateToast, + setupAutoUpdates, + skipAvailableUpdateVersion, +} from "./updater"; +import { mainRuntimeState } from "./mainRuntimeState"; +import { getHudOverlayWindow, getUpdateToastWindow, hideUpdateToastWindow, showUpdateToastWindow } from "./windows"; + +interface MainUpdateIntegrationDependencies { + rendererDist: string; + focusOrCreateMainWindow: () => void; + reassertHudOverlayMouseState: () => void; +} + +let deps: MainUpdateIntegrationDependencies | null = null; + +function requireDeps() { + if (!deps) { + throw new Error("mainUpdateIntegration has not been initialized"); + } + return deps; +} + +function getPublicAssetPath(filename: string) { + return path.join(process.env.VITE_PUBLIC || requireDeps().rendererDist, filename); +} + +function getAppImage(filename: string) { + return nativeImage.createFromPath(getPublicAssetPath(filename)); +} + +function getUpdateNotificationTitle(payload: UpdateToastPayload) { + switch (payload.phase) { + case "available": + return `Recordly ${payload.version} is available`; + case "downloading": + return `Downloading Recordly ${payload.version}`; + case "ready": + return `Recordly ${payload.version} is ready`; + case "error": + return `Recordly ${payload.version} needs attention`; + } +} + +function getUpdateNotificationBody(payload: UpdateToastPayload) { + switch (payload.phase) { + case "available": + return "Click to download the update."; + case "downloading": + return "Recordly is downloading the update in the foreground."; + case "ready": + return "Click to install the downloaded update."; + case "error": + return "Click to retry checking for updates."; + } +} + +function clearActiveUpdateNotification() { + if (mainRuntimeState.activeUpdateNotification) { + mainRuntimeState.activeUpdateNotification.close(); + mainRuntimeState.activeUpdateNotification = null; + } + mainRuntimeState.activeUpdateNotificationKey = null; +} + +export function initializeMainUpdateIntegration(nextDeps: MainUpdateIntegrationDependencies) { + deps = nextDeps; +} + +export function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknown) { + if (process.platform !== "darwin") { + if (!payload) { + clearActiveUpdateNotification(); + return true; + } + + const updatePayload = payload as UpdateToastPayload; + if (updatePayload.phase === "downloading") { + return true; + } + + if (!Notification.isSupported()) { + return false; + } + + const notificationKey = [updatePayload.phase, updatePayload.version, updatePayload.detail].join(":"); + if (mainRuntimeState.activeUpdateNotificationKey === notificationKey) { + return true; + } + + clearActiveUpdateNotification(); + const notification = new Notification({ + title: getUpdateNotificationTitle(updatePayload), + body: getUpdateNotificationBody(updatePayload), + icon: getAppImage("app-icons/recordly-128.png"), + silent: false, + }); + + notification.on("click", () => { + requireDeps().focusOrCreateMainWindow(); + switch (updatePayload.phase) { + case "available": + void downloadAvailableUpdate(sendUpdateToastToWindows); + break; + case "ready": + installDownloadedUpdateNow(sendUpdateToastToWindows); + break; + case "error": + void checkForAppUpdates(getUpdateDialogWindow, { manual: true }); + break; + default: + break; + } + }); + + notification.on("close", () => { + if (mainRuntimeState.activeUpdateNotification === notification) { + mainRuntimeState.activeUpdateNotification = null; + mainRuntimeState.activeUpdateNotificationKey = null; + } + }); + + notification.show(); + requireDeps().reassertHudOverlayMouseState(); + mainRuntimeState.activeUpdateNotification = notification; + mainRuntimeState.activeUpdateNotificationKey = notificationKey; + return true; + } + + if (!payload) { + const existingWindow = getUpdateToastWindow(); + if (!existingWindow) { + return false; + } + + existingWindow.webContents.send(channel, null); + hideUpdateToastWindow(); + return true; + } + + const toastWindow = showUpdateToastWindow(); + const sendPayload = () => { + toastWindow.webContents.send(channel, payload); + showUpdateToastWindow(); + }; + + if (toastWindow.webContents.isLoadingMainFrame()) { + toastWindow.webContents.once("did-finish-load", sendPayload); + } else { + sendPayload(); + } + + return true; +} + +export function getUpdateDialogWindow() { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (focusedWindow && !focusedWindow.isDestroyed()) { + return focusedWindow; + } + + if (mainRuntimeState.mainWindow && !mainRuntimeState.mainWindow.isDestroyed()) { + return mainRuntimeState.mainWindow; + } + + return getHudOverlayWindow(); +} + +export function registerUpdateIpcHandlers() { + ipcMain.handle("install-downloaded-update", () => { + installDownloadedUpdateNow(sendUpdateToastToWindows); + return { success: true }; + }); + + ipcMain.handle("download-available-update", () => { + return downloadAvailableUpdate(sendUpdateToastToWindows); + }); + + ipcMain.handle("defer-downloaded-update", (_event, delayMs?: number) => { + return deferUpdateReminder(getUpdateDialogWindow, sendUpdateToastToWindows, delayMs); + }); + + ipcMain.handle("dismiss-update-toast", () => { + return dismissUpdateToast(getUpdateDialogWindow, sendUpdateToastToWindows); + }); + + ipcMain.handle("skip-update-version", () => { + return skipAvailableUpdateVersion(sendUpdateToastToWindows); + }); + + ipcMain.handle("get-current-update-toast-payload", () => { + return getCurrentUpdateToastPayload(); + }); + + ipcMain.handle("get-update-status-summary", () => { + return getUpdateStatusSummary(); + }); + + ipcMain.handle("preview-update-toast", () => { + return { success: previewUpdateToast(sendUpdateToastToWindows) }; + }); + + ipcMain.handle("check-for-app-updates", async () => { + await checkForAppUpdates(getUpdateDialogWindow, { manual: true }); + return { success: true, logPath: getUpdaterLogPath() }; + }); +} + +export function runManualUpdateCheck() { + void checkForAppUpdates(getUpdateDialogWindow, { manual: true }); +} + +export function setupMainAutoUpdates() { + setupAutoUpdates(getUpdateDialogWindow, sendUpdateToastToWindows); + const currentToastPayload = getCurrentUpdateToastPayload(); + if (currentToastPayload) { + sendUpdateToastToWindows("update-toast-state", currentToastPayload); + } +} \ No newline at end of file diff --git a/electron/mainWindowControls.ts b/electron/mainWindowControls.ts new file mode 100644 index 00000000..43c70cbd --- /dev/null +++ b/electron/mainWindowControls.ts @@ -0,0 +1,453 @@ +import path from "node:path"; +import { + app, + BrowserWindow, + dialog, + ipcMain, + Menu, + nativeImage, + Tray, +} from "electron"; +import { mainRuntimeState } from "./mainRuntimeState"; + +interface MainWindowControlsDependencies { + rendererDist: string; + createHudOverlayWindow: () => BrowserWindow; + createEditorWindow: () => BrowserWindow; + createSourceSelectorWindow: () => BrowserWindow; + getHudOverlayWindow: () => BrowserWindow | null; + isHudOverlayMousePassthroughSupported: () => boolean; + onCheckForUpdates: () => void; +} + +let deps: MainWindowControlsDependencies | null = null; + +function requireDeps() { + if (!deps) { + throw new Error("mainWindowControls has not been initialized"); + } + return deps; +} + +function getPublicAssetPath(filename: string) { + return path.join(process.env.VITE_PUBLIC || requireDeps().rendererDist, filename); +} + +function getAppImage(filename: string) { + return nativeImage.createFromPath(getPublicAssetPath(filename)); +} + +function getTrayIcon(filename: string) { + return getAppImage(filename).resize({ width: 24, height: 24, quality: "best" }); +} + +function getDefaultTrayIcon() { + if (!mainRuntimeState.defaultTrayIcon) { + mainRuntimeState.defaultTrayIcon = getTrayIcon("app-icons/recordly-32.png"); + } + return mainRuntimeState.defaultTrayIcon; +} + +function getRecordingTrayIcon() { + if (!mainRuntimeState.recordingTrayIcon) { + mainRuntimeState.recordingTrayIcon = getTrayIcon("rec-button.png"); + } + return mainRuntimeState.recordingTrayIcon; +} + +export function initializeMainWindowControls(nextDeps: MainWindowControlsDependencies) { + deps = nextDeps; + ipcMain.on("set-has-unsaved-changes", (_event, hasChanges: boolean) => { + mainRuntimeState.editorHasUnsavedChanges = hasChanges; + }); +} + +export function isEditorWindow(window: BrowserWindow) { + return window.webContents.getURL().includes("windowType=editor"); +} + +export function closeEditorWindowBypassingUnsavedPrompt(window: BrowserWindow | null) { + if (!window || window.isDestroyed()) { + return; + } + + if (isEditorWindow(window)) { + mainRuntimeState.isForceClosing = true; + mainRuntimeState.editorHasUnsavedChanges = false; + } + window.close(); +} + +export function restoreWindowSafely(window: BrowserWindow | null) { + if (!window || window.isDestroyed()) { + return; + } + + window.restore(); +} + +export function showHudOverlayFromTray() { + const hud = requireDeps().getHudOverlayWindow(); + if (!hud) { + return false; + } + + if (hud.isMinimized()) { + hud.restore(); + } + + if (process.platform === "win32" && requireDeps().isHudOverlayMousePassthroughSupported()) { + hud.showInactive(); + hud.moveTop(); + reassertHudOverlayMouseState(); + return true; + } + + hud.show(); + hud.moveTop(); + hud.focus(); + return true; +} + +export function createWindow() { + if (!app.isReady()) { + void app.whenReady().then(() => { + if (!mainRuntimeState.mainWindow || mainRuntimeState.mainWindow.isDestroyed()) { + createWindow(); + } + }); + return; + } + + mainRuntimeState.mainWindow = requireDeps().createHudOverlayWindow(); +} + +export function focusOrCreateMainWindow() { + if (!app.isReady()) { + void app.whenReady().then(() => { + focusOrCreateMainWindow(); + }); + return; + } + + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + return; + } + + if (mainRuntimeState.mainWindow && !mainRuntimeState.mainWindow.isDestroyed()) { + if ( + process.platform === "linux" && + !mainRuntimeState.mainWindow.isFocused() && + !isEditorWindow(mainRuntimeState.mainWindow) + ) { + const windowToRecreate = mainRuntimeState.mainWindow; + mainRuntimeState.mainWindow = null; + windowToRecreate.once("closed", () => createWindow()); + windowToRecreate.destroy(); + return; + } + + if ( + process.platform === "win32" && + !isEditorWindow(mainRuntimeState.mainWindow) && + requireDeps().isHudOverlayMousePassthroughSupported() + ) { + showHudOverlayFromTray(); + return; + } + + mainRuntimeState.mainWindow.show(); + if (mainRuntimeState.mainWindow.isMinimized()) { + mainRuntimeState.mainWindow.restore(); + } + mainRuntimeState.mainWindow.moveTop(); + mainRuntimeState.mainWindow.focus(); + } +} + +export function reassertHudOverlayMouseState() { + if (process.platform !== "win32" || !requireDeps().isHudOverlayMousePassthroughSupported()) { + return; + } + + const hud = requireDeps().getHudOverlayWindow(); + if (!hud) { + return; + } + + hud.setIgnoreMouseEvents(false); + setTimeout(() => { + if (!hud.isDestroyed()) { + hud.setIgnoreMouseEvents(true, { forward: true }); + } + }, 50); +} + +function sendEditorMenuAction( + channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as", +) { + let targetWindow = BrowserWindow.getFocusedWindow() ?? mainRuntimeState.mainWindow; + + if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) { + createEditorWindowWrapper(); + targetWindow = mainRuntimeState.mainWindow; + if (!targetWindow || targetWindow.isDestroyed()) { + return; + } + + targetWindow.webContents.once("did-finish-load", () => { + if (!targetWindow || targetWindow.isDestroyed()) { + return; + } + targetWindow.webContents.send(channel); + }); + return; + } + + targetWindow.webContents.send(channel); +} + +export function setupApplicationMenu() { + const isMac = process.platform === "darwin"; + if (!isMac) { + Menu.setApplicationMenu(null); + return; + } + + const template: Electron.MenuItemConstructorOptions[] = [ + { + label: app.name, + submenu: [ + { role: "about" }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }, + { + label: "File", + submenu: [ + { + label: "Open Projects…", + accelerator: "CmdOrCtrl+O", + click: () => sendEditorMenuAction("menu-load-project"), + }, + { + label: "Save Project…", + accelerator: "CmdOrCtrl+S", + click: () => sendEditorMenuAction("menu-save-project"), + }, + { + label: "Save Project As…", + accelerator: "CmdOrCtrl+Shift+S", + click: () => sendEditorMenuAction("menu-save-project-as"), + }, + ...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]), + ], + }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "selectAll" }, + ], + }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { + label: "Window", + submenu: isMac + ? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }] + : [{ role: "minimize" }, { role: "close" }], + }, + { + label: "Help", + submenu: [ + { + label: "Check for Updates…", + click: () => requireDeps().onCheckForUpdates(), + }, + ], + }, + ]; + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +function isPrimaryTrayClick(event: unknown) { + const button = + event && typeof event === "object" && "button" in event + ? (event as { button?: number | string }).button + : undefined; + return button === undefined || button === 0 || button === "left"; +} + +export function createTray() { + mainRuntimeState.tray = new Tray(getDefaultTrayIcon()); + mainRuntimeState.tray.on("click", (event) => { + if (process.platform === "win32" && !isPrimaryTrayClick(event)) { + return; + } + + focusOrCreateMainWindow(); + }); + + if (process.platform === "win32") { + mainRuntimeState.tray.on("right-click", () => { + if (!mainRuntimeState.tray || !mainRuntimeState.trayContextMenu) { + return; + } + + mainRuntimeState.tray.popUpContextMenu(mainRuntimeState.trayContextMenu); + }); + return; + } + + mainRuntimeState.tray.on("double-click", () => focusOrCreateMainWindow()); +} + +export function syncDockIcon() { + if (process.platform !== "darwin" || !app.dock) { + return; + } + + const dockIcon = getAppImage("app-icons/recordly-512.png"); + if (!dockIcon.isEmpty()) { + app.dock.setIcon(dockIcon); + } +} + +export function updateTrayMenu(recording = false) { + if (!mainRuntimeState.tray) { + return; + } + + const trayIcon = recording ? getRecordingTrayIcon() : getDefaultTrayIcon(); + const trayToolTip = recording + ? `Recording: ${mainRuntimeState.selectedSourceName}` + : "Recordly"; + const menuTemplate = recording + ? [ + { + label: "Show Controls", + click: () => { + if (!showHudOverlayFromTray()) { + focusOrCreateMainWindow(); + } + }, + }, + { + label: "Stop Recording", + click: () => { + if (mainRuntimeState.mainWindow && !mainRuntimeState.mainWindow.isDestroyed()) { + mainRuntimeState.mainWindow.webContents.send("stop-recording-from-tray"); + } + }, + }, + ] + : [ + { + label: "Open", + click: () => { + if (!showHudOverlayFromTray()) { + focusOrCreateMainWindow(); + } + }, + }, + { label: "Quit", click: () => app.quit() }, + ]; + + const menu = Menu.buildFromTemplate(menuTemplate); + mainRuntimeState.trayContextMenu = menu; + mainRuntimeState.tray.setImage(trayIcon); + mainRuntimeState.tray.setToolTip(trayToolTip); + if (process.platform !== "win32") { + mainRuntimeState.tray.setContextMenu(menu); + } +} + +export function createEditorWindowWrapper() { + const previousWindow = mainRuntimeState.mainWindow; + if (previousWindow && !previousWindow.isDestroyed()) { + const closingEditorWindow = isEditorWindow(previousWindow); + closeEditorWindowBypassingUnsavedPrompt(previousWindow); + if (!closingEditorWindow) { + mainRuntimeState.isForceClosing = false; + } + if (mainRuntimeState.mainWindow === previousWindow) { + mainRuntimeState.mainWindow = null; + } + } + + const editorWindow = requireDeps().createEditorWindow(); + mainRuntimeState.mainWindow = editorWindow; + mainRuntimeState.editorHasUnsavedChanges = false; + + editorWindow.on("closed", () => { + if (mainRuntimeState.mainWindow === editorWindow) { + mainRuntimeState.mainWindow = null; + } + mainRuntimeState.isForceClosing = false; + mainRuntimeState.editorHasUnsavedChanges = false; + }); + + editorWindow.on("close", (event) => { + if (mainRuntimeState.isForceClosing || !mainRuntimeState.editorHasUnsavedChanges) { + return; + } + + event.preventDefault(); + + const choice = dialog.showMessageBoxSync(editorWindow, { + type: "warning", + buttons: ["Save & Close", "Discard & Close", "Cancel"], + defaultId: 0, + cancelId: 2, + title: "Unsaved Changes", + message: "You have unsaved changes.", + detail: "Do you want to save your project before closing?", + }); + + if (choice === 0) { + editorWindow.webContents.send("request-save-before-close"); + ipcMain.once("save-before-close-done", (_event, saved: boolean) => { + if (saved) { + closeEditorWindowBypassingUnsavedPrompt(editorWindow); + } + }); + } else if (choice === 1) { + closeEditorWindowBypassingUnsavedPrompt(editorWindow); + } + }); +} + +export function createSourceSelectorWindowWrapper() { + mainRuntimeState.sourceSelectorWindow = requireDeps().createSourceSelectorWindow(); + mainRuntimeState.sourceSelectorWindow.on("closed", () => { + mainRuntimeState.sourceSelectorWindow = null; + }); + return mainRuntimeState.sourceSelectorWindow; +} \ No newline at end of file diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index c5cd8fab..0a3c8c47 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -1,721 +1,83 @@ import Foundation -import ScreenCaptureKit import AVFoundation import CoreGraphics -struct CaptureConfig: Codable { - let fps: Int? - let displayId: CGDirectDisplayID? - let windowId: UInt32? - let outputPath: String? - let capturesSystemAudio: Bool? - let capturesMicrophone: Bool? - let systemAudioOutputPath: String? - let microphoneDeviceId: String? - let microphoneLabel: String? - let microphoneOutputPath: String? -} - -let targetCaptureFPS = 60 -let maxInlineAudioTailExtension = CMTime(seconds: 2.0, preferredTimescale: 600) - -final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { - private let queue = DispatchQueue(label: "recordly.screencapturekit.video") - private var assetWriter: AVAssetWriter? - private var videoInput: AVAssetWriterInput? - private var systemAudioWriter: AVAssetWriter? - private var systemAudioInput: AVAssetWriterInput? - private var microphoneOnlyWriter: AVAssetWriter? - private var microphoneOnlyInput: AVAssetWriterInput? - private var stream: SCStream? - private var firstSampleTime: CMTime = .zero - private var firstSystemAudioSampleTime: CMTime? - private var firstMicrophoneSampleTime: CMTime? - private var lastSampleBuffer: CMSampleBuffer? - private var lastVideoPresentationTime: CMTime = .zero - private var lastVideoDuration: CMTime = .zero - private var lastInlineAudioPresentationTime: CMTime = .invalid - private var lastInlineAudioDuration: CMTime = .zero - private var isRecording = false - private var isPaused = false - private var pauseStartedHostTime: CMTime? - private var pendingResumeAdjustment = false - private var accumulatedPausedDuration: CMTime = .zero - private var sessionStarted = false - private var frameCount = 0 - private var outputURL: URL? - private var microphoneOutputURL: URL? - private var trackedWindowId: UInt32? - private var windowValidationTask: Task? - private var inlineAudioInput: AVAssetWriterInput? - private var firstInlineAudioSampleTime: CMTime? - private var capturesSystemAudio = false - private var capturesMicrophone = false - private var writesSystemAudioToSeparateTrack = false - private var writesMicrophoneToSeparateTrack = false - - private let microphoneOutputTypeRawValue = 2 - - func startCapture(configJSON: String) async throws { - guard !isRecording else { - throw NSError(domain: "RecordlyCapture", code: 1, userInfo: [NSLocalizedDescriptionKey: "Recording is already in progress"]) - } - - guard let data = configJSON.data(using: .utf8) else { - throw NSError(domain: "RecordlyCapture", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON input"]) - } - - let config = try JSONDecoder().decode(CaptureConfig.self, from: data) - let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) - let streamConfig = SCStreamConfiguration() - capturesSystemAudio = config.capturesSystemAudio ?? false - capturesMicrophone = config.capturesMicrophone ?? false - if capturesMicrophone && !supportsNativeMicrophoneCapture(streamConfig: streamConfig) { - fputs("MICROPHONE_CAPTURE_UNAVAILABLE\n", stderr) - fflush(stderr) - capturesMicrophone = false - } - writesSystemAudioToSeparateTrack = capturesSystemAudio - writesMicrophoneToSeparateTrack = capturesSystemAudio && capturesMicrophone - if capturesMicrophone && !capturesSystemAudio { - writesMicrophoneToSeparateTrack = true - } - let requestedFPS = max(targetCaptureFPS, config.fps ?? targetCaptureFPS) - streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(requestedFPS)) - streamConfig.queueDepth = 6 - streamConfig.pixelFormat = kCVPixelFormatType_32BGRA - streamConfig.showsCursor = false - streamConfig.capturesAudio = capturesSystemAudio || capturesMicrophone - streamConfig.sampleRate = 48000 - streamConfig.channelCount = 2 - streamConfig.excludesCurrentProcessAudio = true - - if capturesMicrophone { - streamConfig.setValue(true, forKey: "captureMicrophone") - if let microphoneDeviceId = Self.resolveMicrophoneCaptureDeviceID(config: config) { - streamConfig.setValue(microphoneDeviceId, forKey: "microphoneCaptureDeviceID") - } - } - - let filter: SCContentFilter - let outputWidth: Int - let outputHeight: Int - - if let windowId = config.windowId { - trackedWindowId = windowId - guard let window = availableContent.windows.first(where: { $0.windowID == windowId }) else { - throw NSError(domain: "RecordlyCapture", code: 3, userInfo: [NSLocalizedDescriptionKey: "Window not found"]) - } - - filter = SCContentFilter(desktopIndependentWindow: window) - - let candidateDisplay = availableContent.displays.first(where: { - $0.frame.intersects(window.frame) || $0.frame.contains(CGPoint(x: window.frame.midX, y: window.frame.midY)) - }) - let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: candidateDisplay?.displayID ?? CGMainDisplayID()) - outputWidth = max(2, Int(window.frame.width) * scaleFactor) - outputHeight = max(2, Int(window.frame.height) * scaleFactor) - if #available(macOS 14.0, *) { - streamConfig.ignoreShadowsSingleWindow = true - } - streamConfig.width = outputWidth - streamConfig.height = outputHeight - } else { - trackedWindowId = nil - let displayId = config.displayId ?? CGMainDisplayID() - guard let display = availableContent.displays.first(where: { $0.displayID == displayId }) else { - throw NSError(domain: "RecordlyCapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "Display not found"]) - } - - filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: []) - let displayBounds = CGDisplayBounds(display.displayID) - let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: display.displayID) - outputWidth = max(2, Int(displayBounds.width) * scaleFactor) - outputHeight = max(2, Int(displayBounds.height) * scaleFactor) - streamConfig.width = outputWidth - streamConfig.height = outputHeight - } - - let destinationURL: URL - if let outputPath = config.outputPath, !outputPath.isEmpty { - destinationURL = URL(fileURLWithPath: outputPath) - } else { - destinationURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - .appendingPathComponent("output_\(Int(Date().timeIntervalSince1970)).mp4") - } - - outputURL = destinationURL - let outputFileType: AVFileType = destinationURL.pathExtension.lowercased() == "mp4" ? .mp4 : .mov - assetWriter = try AVAssetWriter(url: destinationURL, fileType: outputFileType) - microphoneOutputURL = nil - firstSystemAudioSampleTime = nil - firstMicrophoneSampleTime = nil - - guard let assistant = AVOutputSettingsAssistant(preset: .preset3840x2160) else { - throw NSError(domain: "RecordlyCapture", code: 5, userInfo: [NSLocalizedDescriptionKey: "Unable to create output settings assistant"]) - } - - assistant.sourceVideoFormat = try CMVideoFormatDescription( - videoCodecType: .h264, - width: outputWidth, - height: outputHeight - ) - - guard var outputSettings = assistant.videoSettings else { - throw NSError(domain: "RecordlyCapture", code: 6, userInfo: [NSLocalizedDescriptionKey: "Output settings unavailable"]) - } - - outputSettings[AVVideoWidthKey] = outputWidth - outputSettings[AVVideoHeightKey] = outputHeight - - let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings) - videoInput.expectsMediaDataInRealTime = true - - guard let assetWriter = assetWriter, assetWriter.canAdd(videoInput) else { - throw NSError(domain: "RecordlyCapture", code: 7, userInfo: [NSLocalizedDescriptionKey: "Unable to add video writer input"]) - } - - assetWriter.add(videoInput) - self.videoInput = videoInput - - // Add inline audio track directly to the video so the .mp4 always contains audio. - // This eliminates the dependency on the post-recording ffmpeg mux step. - if capturesSystemAudio || capturesMicrophone { - let inlineAudio = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 192_000)) - inlineAudio.expectsMediaDataInRealTime = true - if assetWriter.canAdd(inlineAudio) { - assetWriter.add(inlineAudio) - self.inlineAudioInput = inlineAudio - } - } - - if writesSystemAudioToSeparateTrack { - guard let systemAudioOutputPath = config.systemAudioOutputPath, !systemAudioOutputPath.isEmpty else { - throw NSError(domain: "RecordlyCapture", code: 11, userInfo: [NSLocalizedDescriptionKey: "Missing system audio output path for audio capture"]) - } - - let systemAudioURL = URL(fileURLWithPath: systemAudioOutputPath) - let systemAudioWriter = try AVAssetWriter(url: systemAudioURL, fileType: .m4a) - let systemAudioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 160_000)) - systemAudioInput.expectsMediaDataInRealTime = true - - guard systemAudioWriter.canAdd(systemAudioInput) else { - throw NSError(domain: "RecordlyCapture", code: 12, userInfo: [NSLocalizedDescriptionKey: "Unable to add system audio writer input"]) - } - - systemAudioWriter.add(systemAudioInput) - self.systemAudioWriter = systemAudioWriter - self.systemAudioInput = systemAudioInput - - guard systemAudioWriter.startWriting() else { - throw NSError(domain: "RecordlyCapture", code: 13, userInfo: [NSLocalizedDescriptionKey: systemAudioWriter.error?.localizedDescription ?? "Unable to start system audio writing"]) - } - - systemAudioWriter.startSession(atSourceTime: .zero) - } - - if writesMicrophoneToSeparateTrack { - guard let microphoneOutputPath = config.microphoneOutputPath, !microphoneOutputPath.isEmpty else { - throw NSError(domain: "RecordlyCapture", code: 14, userInfo: [NSLocalizedDescriptionKey: "Missing microphone output path for microphone capture"]) - } - - let microphoneURL = URL(fileURLWithPath: microphoneOutputPath) - microphoneOutputURL = microphoneURL - let microphoneWriter = try AVAssetWriter(url: microphoneURL, fileType: .m4a) - let microphoneInput = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 128_000)) - microphoneInput.expectsMediaDataInRealTime = true - - guard microphoneWriter.canAdd(microphoneInput) else { - throw NSError(domain: "RecordlyCapture", code: 15, userInfo: [NSLocalizedDescriptionKey: "Unable to add microphone writer input"]) - } - - microphoneWriter.add(microphoneInput) - self.microphoneOnlyWriter = microphoneWriter - self.microphoneOnlyInput = microphoneInput - - guard microphoneWriter.startWriting() else { - throw NSError(domain: "RecordlyCapture", code: 16, userInfo: [NSLocalizedDescriptionKey: microphoneWriter.error?.localizedDescription ?? "Unable to start microphone audio writing"]) - } - - microphoneWriter.startSession(atSourceTime: .zero) - } - - let stream = SCStream(filter: filter, configuration: streamConfig, delegate: self) - self.stream = stream - try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue) - if capturesSystemAudio { - try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: queue) - } - if capturesMicrophone { - guard let microphoneOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) else { - throw NSError( - domain: "RecordlyCapture", - code: 17, - userInfo: [NSLocalizedDescriptionKey: "Microphone stream output type is unavailable"] - ) - } - try stream.addStreamOutput(self, type: microphoneOutputType, sampleHandlerQueue: queue) - } - try await stream.startCapture() - - guard assetWriter.startWriting() else { - throw NSError(domain: "RecordlyCapture", code: 8, userInfo: [NSLocalizedDescriptionKey: assetWriter.error?.localizedDescription ?? "Unable to start video writing"]) - } - - assetWriter.startSession(atSourceTime: .zero) - sessionStarted = true - isRecording = true - isPaused = false - pauseStartedHostTime = nil - pendingResumeAdjustment = false - accumulatedPausedDuration = .zero - frameCount = 0 - firstSampleTime = .zero - lastVideoPresentationTime = .zero - lastVideoDuration = .zero - startWindowValidationIfNeeded() - print("Recording started") - fflush(stdout) - } - - func stopCapture() async throws -> String { - guard isRecording else { - throw NSError(domain: "RecordlyCapture", code: 9, userInfo: [NSLocalizedDescriptionKey: "No recording in progress"]) - } - - return try await finishCapture() - } - - func pauseCapture() { - guard isRecording, !isPaused else { return } - isPaused = true - pauseStartedHostTime = CMClockGetTime(CMClockGetHostTimeClock()) - pendingResumeAdjustment = false - } - - func resumeCapture() { - guard isRecording, isPaused else { return } - isPaused = false - pendingResumeAdjustment = true - } - - func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) { - guard sessionStarted, sampleBuffer.isValid, isRecording else { return } - guard let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return } - - if outputType == .screen { - guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]], - let attachment = attachments.first, - let statusRawValue = attachment[SCStreamFrameInfo.status] as? Int, - let status = SCFrameStatus(rawValue: statusRawValue), - status == .complete else { - return - } - - guard let videoInput = videoInput, videoInput.isReadyForMoreMediaData else { return } - - if firstSampleTime == .zero { - firstSampleTime = sampleBuffer.presentationTimeStamp - } - - lastSampleBuffer = sampleBuffer - let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp) - if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { - videoInput.append(retimedSampleBuffer) - lastVideoPresentationTime = presentationTime - lastVideoDuration = sampleBuffer.duration - frameCount += 1 - } - return - } - - if outputType == .audio { - guard let systemAudioInput else { return } - appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput, firstSampleTime: &firstSystemAudioSampleTime, presentationTime: presentationTime) - // Also write system audio to the inline video track - if let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData { - appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, firstSampleTime: &firstInlineAudioSampleTime, presentationTime: presentationTime) - } - return - } - - if outputType.rawValue == microphoneOutputTypeRawValue { - if let microphoneOnlyInput { - appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, firstSampleTime: &firstMicrophoneSampleTime, presentationTime: presentationTime) - } - // Write mic to inline video track only if there's no system audio (avoids double-writing) - if !capturesSystemAudio, let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData { - appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, firstSampleTime: &firstInlineAudioSampleTime, presentationTime: presentationTime) - } - return - } - - return - } - - func stream(_ stream: SCStream, didStopWithError error: Error) { - fputs("Error: \(error.localizedDescription)\n", stderr) - fflush(stderr) - } - - private func finishCapture() async throws -> String { - windowValidationTask?.cancel() - windowValidationTask = nil - trackedWindowId = nil - - if let activeStream = stream { - do { - try await activeStream.stopCapture() - } catch { - // Stream may have already been stopped by the system — continue with file finalization - } - } - stream = nil - isRecording = false - - if let originalBuffer = lastSampleBuffer, let videoInput = videoInput { - let additionalTime = lastVideoPresentationTime + frameDuration(for: originalBuffer) - let timing = CMSampleTimingInfo(duration: originalBuffer.duration, presentationTimeStamp: additionalTime, decodeTimeStamp: originalBuffer.decodeTimeStamp) - if let additionalSampleBuffer = try? CMSampleBuffer(copying: originalBuffer, withNewTiming: [timing]) { - videoInput.append(additionalSampleBuffer) - } - } - - let videoEndTime = lastVideoPresentationTime + (lastSampleBuffer.map { frameDuration(for: $0) } ?? .zero) - let endTime = resolvedCaptureEndTime(videoEndTime: videoEndTime) - assetWriter?.endSession(atSourceTime: endTime) - videoInput?.markAsFinished() - inlineAudioInput?.markAsFinished() - await assetWriter?.finishWriting() - - systemAudioInput?.markAsFinished() - await systemAudioWriter?.finishWriting() - - microphoneOnlyInput?.markAsFinished() - await microphoneOnlyWriter?.finishWriting() - - let path = outputURL?.path ?? "" - assetWriter = nil - videoInput = nil - systemAudioWriter = nil - systemAudioInput = nil - microphoneOnlyWriter = nil - microphoneOnlyInput = nil - inlineAudioInput = nil - outputURL = nil - microphoneOutputURL = nil - sessionStarted = false - firstSampleTime = .zero - firstSystemAudioSampleTime = nil - firstMicrophoneSampleTime = nil - firstInlineAudioSampleTime = nil - lastSampleBuffer = nil - lastVideoPresentationTime = .zero - lastVideoDuration = .zero - lastInlineAudioPresentationTime = .invalid - lastInlineAudioDuration = .zero - frameCount = 0 - isPaused = false - pauseStartedHostTime = nil - pendingResumeAdjustment = false - accumulatedPausedDuration = .zero - capturesSystemAudio = false - capturesMicrophone = false - writesSystemAudioToSeparateTrack = false - writesMicrophoneToSeparateTrack = false - return path - } - - private func adjustedPresentationTime(for sampleBuffer: CMSampleBuffer, outputType: SCStreamOutputType) -> CMTime? { - if isPaused { - return nil - } - - let sampleTime = sampleBuffer.presentationTimeStamp - if pendingResumeAdjustment, let pauseStartedHostTime { - let pauseGap = sampleTime - pauseStartedHostTime - if pauseGap > .zero { - accumulatedPausedDuration = accumulatedPausedDuration + pauseGap - } - self.pauseStartedHostTime = nil - pendingResumeAdjustment = false - } - - if outputType == .screen { - if firstSampleTime == .zero { - firstSampleTime = sampleTime - } - } - - // Use video's first sample time as the common time base for ALL tracks. - // This ensures audio files contain leading silence when audio hardware - // delivers its first sample after the first video frame (e.g. iPhone mic - // over Continuity Camera can lag 1-2 seconds behind). - if firstSampleTime == .zero { - // Video hasn't started yet — drop this audio sample to avoid - // negative timestamps. - return nil - } - - return max(.zero, sampleTime - firstSampleTime - accumulatedPausedDuration) - } - - private func frameDuration(for sampleBuffer: CMSampleBuffer) -> CMTime { - if sampleBuffer.duration.isValid && sampleBuffer.duration > .zero { - return sampleBuffer.duration - } - - if lastVideoDuration.isValid && lastVideoDuration > .zero { - return lastVideoDuration - } - - return CMTime(value: 1, timescale: CMTimeScale(targetCaptureFPS)) - } - - private func latestInlineAudioEndTime() -> CMTime { - guard lastInlineAudioPresentationTime.isValid else { - return .invalid - } - - if lastInlineAudioDuration.isValid && lastInlineAudioDuration > .zero { - return lastInlineAudioPresentationTime + lastInlineAudioDuration - } - - return lastInlineAudioPresentationTime - } - - private func resolvedCaptureEndTime(videoEndTime: CMTime) -> CMTime { - let inlineAudioEndTime = latestInlineAudioEndTime() - guard inlineAudioEndTime.isValid else { - return videoEndTime - } - - if CMTimeCompare(inlineAudioEndTime, videoEndTime) <= 0 { - return videoEndTime - } - - // Prevent a stray inline-audio timestamp from forcing finishWriting - // to finalize an arbitrarily long tail. - let tailExtension = CMTimeSubtract(inlineAudioEndTime, videoEndTime) - return videoEndTime + CMTimeMinimum(tailExtension, maxInlineAudioTailExtension) - } - - private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, firstSampleTime: inout CMTime?, presentationTime: CMTime) { - guard input.isReadyForMoreMediaData else { return } - - if firstSampleTime == nil { - firstSampleTime = presentationTime - } - - // presentationTime is already relative to the video's first frame - // (computed by adjustedPresentationTime), so use it directly. - let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp) - if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { - let appended = input.append(retimedSampleBuffer) - if appended, input === inlineAudioInput { - lastInlineAudioPresentationTime = presentationTime - lastInlineAudioDuration = sampleBuffer.duration - } - } - } - - private static func audioOutputSettings(bitRate: Int) -> [String: Any] { - [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: 48_000, - AVNumberOfChannelsKey: 2, - AVEncoderBitRateKey: bitRate, - ] - } - - private static func resolveMicrophoneCaptureDeviceID(config: CaptureConfig) -> String? { - let audioDevices = AVCaptureDevice.devices(for: .audio) - - if let microphoneLabel = config.microphoneLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneLabel.isEmpty { - if let matchedDevice = audioDevices.first(where: { $0.localizedName == microphoneLabel }) { - return matchedDevice.uniqueID - } - } - - if let microphoneDeviceId = config.microphoneDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneDeviceId.isEmpty { - if audioDevices.contains(where: { $0.uniqueID == microphoneDeviceId }) { - return microphoneDeviceId - } - } - - return nil - } - - private func supportsNativeMicrophoneCapture(streamConfig: SCStreamConfiguration) -> Bool { - let supportsConfigSelector = streamConfig.responds(to: Selector(("setCaptureMicrophone:"))) - let supportsDeviceSelector = streamConfig.responds(to: Selector(("setMicrophoneCaptureDeviceID:"))) - let supportsOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) != nil - return supportsConfigSelector && supportsDeviceSelector && supportsOutputType - } - - private func startWindowValidationIfNeeded() { - guard let trackedWindowId else { - windowValidationTask?.cancel() - windowValidationTask = nil - return - } - - windowValidationTask?.cancel() - windowValidationTask = Task.detached(priority: .utility) { [weak self] in - guard let self else { return } - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 500_000_000) - if Task.isCancelled { return } - guard self.isRecording else { return } - - do { - let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) - let windowStillAvailable = availableContent.windows.contains(where: { $0.windowID == trackedWindowId }) - if !windowStillAvailable { - print("WINDOW_UNAVAILABLE") - fflush(stdout) - let outputPath = try await self.finishCapture() - print("Recording stopped. Output path: \(outputPath)") - fflush(stdout) - exit(0) - } - } catch { - continue - } - } - } - } - - private static func scaleFactor(for displayId: CGDirectDisplayID) -> Int { - guard let mode = CGDisplayCopyDisplayMode(displayId) else { - return 1 - } - return max(1, mode.pixelWidth / max(1, mode.width)) - } -} - -final class RecorderService { - private let recorder = ScreenCaptureRecorder() - private let queue = DispatchQueue(label: "recordly.screencapturekit.commands") - private let completionGroup = DispatchGroup() - - func start(configJSON: String) { - completionGroup.enter() - queue.async { - Task { - do { - try await self.recorder.startCapture(configJSON: configJSON) - } catch { - fputs("Error starting capture: \(error.localizedDescription)\n", stderr) - fflush(stderr) - self.completionGroup.leave() - } - } - } - } - - func stop() { - queue.async { - Task { - do { - let outputPath = try await self.recorder.stopCapture() - print("Recording stopped. Output path: \(outputPath)") - fflush(stdout) - self.completionGroup.leave() - } catch { - fputs("Error stopping capture: \(error.localizedDescription)\n", stderr) - fflush(stderr) - self.completionGroup.leave() - } - } - } - } - - func pause() { - queue.async { - self.recorder.pauseCapture() - } - } - - func resume() { - queue.async { - self.recorder.resumeCapture() - } - } - - func waitUntilFinished() { - completionGroup.wait() - } -} - -guard CommandLine.arguments.count >= 2 else { - fputs("Missing config JSON\n", stderr) +private func exitWithError(_ message: String) -> Never { + fputs("\(message)\n", stderr) fflush(stderr) exit(1) } -// Force CoreGraphics Services initialization on the main thread. -// Without this, SCContentFilter(desktopIndependentWindow:) crashes with -// CGS_REQUIRE_INIT because CGS is never initialised in a CLI tool. -let _ = CGMainDisplayID() +private func requestScreenRecordingPermissionIfNeeded() { + if CGPreflightScreenCaptureAccess() { + return + } -// Pre-flight check: ensure screen recording permission is granted before -// attempting capture. On macOS 15+, a one-session grant may expire after the -// parent app restarts. CGRequestScreenCaptureAccess() will trigger the -// system-level permission dialog (or open System Settings) when not yet granted. -if !CGPreflightScreenCaptureAccess() { let granted = CGRequestScreenCaptureAccess() if !granted { - fputs("SCREEN_RECORDING_PERMISSION_DENIED\n", stderr) - fflush(stderr) - exit(1) + exitWithError("SCREEN_RECORDING_PERMISSION_DENIED") } } -// Pre-flight check for microphone access when mic capture is requested. -if let configData = CommandLine.arguments[1].data(using: .utf8), - let config = try? JSONDecoder().decode(CaptureConfig.self, from: configData), - config.capturesMicrophone == true { +private func requestMicrophonePermissionIfNeeded(configJSON: String) { + guard let configData = configJSON.data(using: .utf8), + let config = try? JSONDecoder().decode(CaptureConfig.self, from: configData), + config.capturesMicrophone == true else { + return + } + switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: break case .notDetermined: - let sem = DispatchSemaphore(value: 0) - AVCaptureDevice.requestAccess(for: .audio) { _ in sem.signal() } - sem.wait() + let semaphore = DispatchSemaphore(value: 0) + AVCaptureDevice.requestAccess(for: .audio) { _ in semaphore.signal() } + semaphore.wait() if AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { - fputs("MICROPHONE_PERMISSION_DENIED\n", stderr) - fflush(stderr) - exit(1) + exitWithError("MICROPHONE_PERMISSION_DENIED") } default: - fputs("MICROPHONE_PERMISSION_DENIED\n", stderr) - fflush(stderr) - exit(1) + exitWithError("MICROPHONE_PERMISSION_DENIED") } } -let service = RecorderService() -service.start(configJSON: CommandLine.arguments[1]) - -DispatchQueue.global(qos: .utility).async { - while let input = readLine(strippingNewline: true)?.lowercased() { - if input == "pause" { - service.pause() - continue +@main +struct ScreenCaptureKitRecorderMain { + static func main() { + guard CommandLine.arguments.count >= 2 else { + exitWithError("Missing config JSON") } - if input == "resume" { - service.resume() - continue + let configJSON = CommandLine.arguments[1] + + // Force CoreGraphics Services initialization on the main thread. + _ = CGMainDisplayID() + + requestScreenRecordingPermissionIfNeeded() + requestMicrophonePermissionIfNeeded(configJSON: configJSON) + + let service = RecorderService() + service.start(configJSON: configJSON) + + DispatchQueue.global(qos: .utility).async { + while let input = readLine(strippingNewline: true)?.lowercased() { + if input == "pause" { + service.pause() + continue + } + + if input == "resume" { + service.resume() + continue + } + + if input == "stop" { + service.stop() + break + } + } } - if input == "stop" { - service.stop() - break - } + service.waitUntilFinished() } -} - -service.waitUntilFinished() - +} \ No newline at end of file diff --git a/electron/native/ScreenCaptureKitRecorder/RecorderService.swift b/electron/native/ScreenCaptureKitRecorder/RecorderService.swift new file mode 100644 index 00000000..54e281b5 --- /dev/null +++ b/electron/native/ScreenCaptureKitRecorder/RecorderService.swift @@ -0,0 +1,55 @@ +import Foundation + +final class RecorderService { + private let recorder = ScreenCaptureRecorder() + private let queue = DispatchQueue(label: "recordly.screencapturekit.commands") + private let completionGroup = DispatchGroup() + + func start(configJSON: String) { + completionGroup.enter() + queue.async { + Task { + do { + try await self.recorder.startCapture(configJSON: configJSON) + } catch { + fputs("Error starting capture: \(error.localizedDescription)\n", stderr) + fflush(stderr) + self.completionGroup.leave() + } + } + } + } + + func stop() { + queue.async { + Task { + do { + let outputPath = try await self.recorder.stopCapture() + print("Recording stopped. Output path: \(outputPath)") + fflush(stdout) + self.completionGroup.leave() + } catch { + fputs("Error stopping capture: \(error.localizedDescription)\n", stderr) + fflush(stderr) + self.completionGroup.leave() + } + } + } + } + + func pause() { + queue.async { + self.recorder.pauseCapture() + } + } + + func resume() { + queue.async { + self.recorder.resumeCapture() + } + } + + func waitUntilFinished() { + completionGroup.wait() + } +} \ No newline at end of file diff --git a/electron/native/ScreenCaptureKitRecorder/ScreenCaptureRecorder+Stream.swift b/electron/native/ScreenCaptureKitRecorder/ScreenCaptureRecorder+Stream.swift new file mode 100644 index 00000000..0f1e8eb8 --- /dev/null +++ b/electron/native/ScreenCaptureKitRecorder/ScreenCaptureRecorder+Stream.swift @@ -0,0 +1,207 @@ +import Foundation +import ScreenCaptureKit +import AVFoundation + +extension ScreenCaptureRecorder { + func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) { + guard sessionStarted, sampleBuffer.isValid, isRecording else { return } + guard let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return } + + if outputType == .screen { + guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]], + let attachment = attachments.first, + let statusRawValue = attachment[SCStreamFrameInfo.status] as? Int, + let status = SCFrameStatus(rawValue: statusRawValue), + status == .complete else { + return + } + + guard let videoInput = videoInput, videoInput.isReadyForMoreMediaData else { return } + + if firstSampleTime == .zero { + firstSampleTime = sampleBuffer.presentationTimeStamp + } + + lastSampleBuffer = sampleBuffer + let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp) + if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { + videoInput.append(retimedSampleBuffer) + lastVideoPresentationTime = presentationTime + lastVideoDuration = sampleBuffer.duration + frameCount += 1 + } + return + } + + if outputType == .audio { + guard let systemAudioInput else { return } + appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput, firstSampleTime: &firstSystemAudioSampleTime, presentationTime: presentationTime) + if let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData { + appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, firstSampleTime: &firstInlineAudioSampleTime, presentationTime: presentationTime) + } + return + } + + if outputType.rawValue == microphoneOutputTypeRawValue { + if let microphoneOnlyInput { + appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, firstSampleTime: &firstMicrophoneSampleTime, presentationTime: presentationTime) + } + if !capturesSystemAudio, let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData { + appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, firstSampleTime: &firstInlineAudioSampleTime, presentationTime: presentationTime) + } + } + } + + func stream(_ stream: SCStream, didStopWithError error: Error) { + fputs("Error: \(error.localizedDescription)\n", stderr) + fflush(stderr) + } + + func finishCapture() async throws -> String { + windowValidationTask?.cancel() + windowValidationTask = nil + trackedWindowId = nil + + if let activeStream = stream { + do { + try await activeStream.stopCapture() + } catch { + } + } + stream = nil + isRecording = false + + if let originalBuffer = lastSampleBuffer, let videoInput = videoInput { + let additionalTime = lastVideoPresentationTime + frameDuration(for: originalBuffer) + let timing = CMSampleTimingInfo(duration: originalBuffer.duration, presentationTimeStamp: additionalTime, decodeTimeStamp: originalBuffer.decodeTimeStamp) + if let additionalSampleBuffer = try? CMSampleBuffer(copying: originalBuffer, withNewTiming: [timing]) { + videoInput.append(additionalSampleBuffer) + } + } + + let videoEndTime = lastVideoPresentationTime + (lastSampleBuffer.map { frameDuration(for: $0) } ?? .zero) + let endTime = resolvedCaptureEndTime(videoEndTime: videoEndTime) + assetWriter?.endSession(atSourceTime: endTime) + videoInput?.markAsFinished() + inlineAudioInput?.markAsFinished() + await assetWriter?.finishWriting() + + systemAudioInput?.markAsFinished() + await systemAudioWriter?.finishWriting() + + microphoneOnlyInput?.markAsFinished() + await microphoneOnlyWriter?.finishWriting() + + let path = outputURL?.path ?? "" + assetWriter = nil + videoInput = nil + systemAudioWriter = nil + systemAudioInput = nil + microphoneOnlyWriter = nil + microphoneOnlyInput = nil + inlineAudioInput = nil + outputURL = nil + microphoneOutputURL = nil + sessionStarted = false + firstSampleTime = .zero + firstSystemAudioSampleTime = nil + firstMicrophoneSampleTime = nil + firstInlineAudioSampleTime = nil + lastSampleBuffer = nil + lastVideoPresentationTime = .zero + lastVideoDuration = .zero + lastInlineAudioPresentationTime = .invalid + lastInlineAudioDuration = .zero + frameCount = 0 + isPaused = false + pauseStartedHostTime = nil + pendingResumeAdjustment = false + accumulatedPausedDuration = .zero + capturesSystemAudio = false + capturesMicrophone = false + writesSystemAudioToSeparateTrack = false + writesMicrophoneToSeparateTrack = false + return path + } + + private func adjustedPresentationTime(for sampleBuffer: CMSampleBuffer, outputType: SCStreamOutputType) -> CMTime? { + if isPaused { + return nil + } + + let sampleTime = sampleBuffer.presentationTimeStamp + if pendingResumeAdjustment, let pauseStartedHostTime { + let pauseGap = sampleTime - pauseStartedHostTime + if pauseGap > .zero { + accumulatedPausedDuration = accumulatedPausedDuration + pauseGap + } + self.pauseStartedHostTime = nil + pendingResumeAdjustment = false + } + + if outputType == .screen, firstSampleTime == .zero { + firstSampleTime = sampleTime + } + + if firstSampleTime == .zero { + return nil + } + + return max(.zero, sampleTime - firstSampleTime - accumulatedPausedDuration) + } + + private func frameDuration(for sampleBuffer: CMSampleBuffer) -> CMTime { + if sampleBuffer.duration.isValid && sampleBuffer.duration > .zero { + return sampleBuffer.duration + } + + if lastVideoDuration.isValid && lastVideoDuration > .zero { + return lastVideoDuration + } + + return CMTime(value: 1, timescale: CMTimeScale(targetCaptureFPS)) + } + + private func latestInlineAudioEndTime() -> CMTime { + guard lastInlineAudioPresentationTime.isValid else { + return .invalid + } + + if lastInlineAudioDuration.isValid && lastInlineAudioDuration > .zero { + return lastInlineAudioPresentationTime + lastInlineAudioDuration + } + + return lastInlineAudioPresentationTime + } + + private func resolvedCaptureEndTime(videoEndTime: CMTime) -> CMTime { + let inlineAudioEndTime = latestInlineAudioEndTime() + guard inlineAudioEndTime.isValid else { + return videoEndTime + } + + if CMTimeCompare(inlineAudioEndTime, videoEndTime) <= 0 { + return videoEndTime + } + + let tailExtension = CMTimeSubtract(inlineAudioEndTime, videoEndTime) + return videoEndTime + CMTimeMinimum(tailExtension, maxInlineAudioTailExtension) + } + + private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, firstSampleTime: inout CMTime?, presentationTime: CMTime) { + guard input.isReadyForMoreMediaData else { return } + + if firstSampleTime == nil { + firstSampleTime = presentationTime + } + + let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp) + if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { + let appended = input.append(retimedSampleBuffer) + if appended, input === inlineAudioInput { + lastInlineAudioPresentationTime = presentationTime + lastInlineAudioDuration = sampleBuffer.duration + } + } + } +} \ No newline at end of file diff --git a/electron/native/ScreenCaptureKitRecorder/ScreenCaptureRecorder.swift b/electron/native/ScreenCaptureKitRecorder/ScreenCaptureRecorder.swift new file mode 100644 index 00000000..676196bb --- /dev/null +++ b/electron/native/ScreenCaptureKitRecorder/ScreenCaptureRecorder.swift @@ -0,0 +1,373 @@ +import Foundation +import ScreenCaptureKit +import AVFoundation +import CoreGraphics + +struct CaptureConfig: Codable { + let fps: Int? + let displayId: CGDirectDisplayID? + let windowId: UInt32? + let outputPath: String? + let capturesSystemAudio: Bool? + let capturesMicrophone: Bool? + let systemAudioOutputPath: String? + let microphoneDeviceId: String? + let microphoneLabel: String? + let microphoneOutputPath: String? +} + +let targetCaptureFPS = 60 +let maxInlineAudioTailExtension = CMTime(seconds: 2.0, preferredTimescale: 600) + +final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { + private let queue = DispatchQueue(label: "recordly.screencapturekit.video") + var assetWriter: AVAssetWriter? + var videoInput: AVAssetWriterInput? + var systemAudioWriter: AVAssetWriter? + var systemAudioInput: AVAssetWriterInput? + var microphoneOnlyWriter: AVAssetWriter? + var microphoneOnlyInput: AVAssetWriterInput? + var stream: SCStream? + var firstSampleTime: CMTime = .zero + var firstSystemAudioSampleTime: CMTime? + var firstMicrophoneSampleTime: CMTime? + var lastSampleBuffer: CMSampleBuffer? + var lastVideoPresentationTime: CMTime = .zero + var lastVideoDuration: CMTime = .zero + var lastInlineAudioPresentationTime: CMTime = .invalid + var lastInlineAudioDuration: CMTime = .zero + var isRecording = false + var isPaused = false + var pauseStartedHostTime: CMTime? + var pendingResumeAdjustment = false + var accumulatedPausedDuration: CMTime = .zero + var sessionStarted = false + var frameCount = 0 + var outputURL: URL? + var microphoneOutputURL: URL? + var trackedWindowId: UInt32? + var windowValidationTask: Task? + var inlineAudioInput: AVAssetWriterInput? + var firstInlineAudioSampleTime: CMTime? + var capturesSystemAudio = false + var capturesMicrophone = false + var writesSystemAudioToSeparateTrack = false + var writesMicrophoneToSeparateTrack = false + + let microphoneOutputTypeRawValue = 2 + + func startCapture(configJSON: String) async throws { + guard !isRecording else { + throw NSError(domain: "RecordlyCapture", code: 1, userInfo: [NSLocalizedDescriptionKey: "Recording is already in progress"]) + } + + guard let data = configJSON.data(using: .utf8) else { + throw NSError(domain: "RecordlyCapture", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON input"]) + } + + let config = try JSONDecoder().decode(CaptureConfig.self, from: data) + let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + let streamConfig = SCStreamConfiguration() + capturesSystemAudio = config.capturesSystemAudio ?? false + capturesMicrophone = config.capturesMicrophone ?? false + if capturesMicrophone && !supportsNativeMicrophoneCapture(streamConfig: streamConfig) { + fputs("MICROPHONE_CAPTURE_UNAVAILABLE\n", stderr) + fflush(stderr) + capturesMicrophone = false + } + writesSystemAudioToSeparateTrack = capturesSystemAudio + writesMicrophoneToSeparateTrack = capturesSystemAudio && capturesMicrophone + if capturesMicrophone && !capturesSystemAudio { + writesMicrophoneToSeparateTrack = true + } + let requestedFPS = max(targetCaptureFPS, config.fps ?? targetCaptureFPS) + streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(requestedFPS)) + streamConfig.queueDepth = 6 + streamConfig.pixelFormat = kCVPixelFormatType_32BGRA + streamConfig.showsCursor = false + streamConfig.capturesAudio = capturesSystemAudio || capturesMicrophone + streamConfig.sampleRate = 48000 + streamConfig.channelCount = 2 + streamConfig.excludesCurrentProcessAudio = true + + if capturesMicrophone { + streamConfig.setValue(true, forKey: "captureMicrophone") + if let microphoneDeviceId = Self.resolveMicrophoneCaptureDeviceID(config: config) { + streamConfig.setValue(microphoneDeviceId, forKey: "microphoneCaptureDeviceID") + } + } + + let filter: SCContentFilter + let outputWidth: Int + let outputHeight: Int + + if let windowId = config.windowId { + trackedWindowId = windowId + guard let window = availableContent.windows.first(where: { $0.windowID == windowId }) else { + throw NSError(domain: "RecordlyCapture", code: 3, userInfo: [NSLocalizedDescriptionKey: "Window not found"]) + } + + filter = SCContentFilter(desktopIndependentWindow: window) + + let candidateDisplay = availableContent.displays.first(where: { + $0.frame.intersects(window.frame) || $0.frame.contains(CGPoint(x: window.frame.midX, y: window.frame.midY)) + }) + let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: candidateDisplay?.displayID ?? CGMainDisplayID()) + outputWidth = max(2, Int(window.frame.width) * scaleFactor) + outputHeight = max(2, Int(window.frame.height) * scaleFactor) + if #available(macOS 14.0, *) { + streamConfig.ignoreShadowsSingleWindow = true + } + streamConfig.width = outputWidth + streamConfig.height = outputHeight + } else { + trackedWindowId = nil + let displayId = config.displayId ?? CGMainDisplayID() + guard let display = availableContent.displays.first(where: { $0.displayID == displayId }) else { + throw NSError(domain: "RecordlyCapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "Display not found"]) + } + + filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: []) + let displayBounds = CGDisplayBounds(display.displayID) + let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: display.displayID) + outputWidth = max(2, Int(displayBounds.width) * scaleFactor) + outputHeight = max(2, Int(displayBounds.height) * scaleFactor) + streamConfig.width = outputWidth + streamConfig.height = outputHeight + } + + let destinationURL: URL + if let outputPath = config.outputPath, !outputPath.isEmpty { + destinationURL = URL(fileURLWithPath: outputPath) + } else { + destinationURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent("output_\(Int(Date().timeIntervalSince1970)).mp4") + } + + outputURL = destinationURL + let outputFileType: AVFileType = destinationURL.pathExtension.lowercased() == "mp4" ? .mp4 : .mov + assetWriter = try AVAssetWriter(url: destinationURL, fileType: outputFileType) + microphoneOutputURL = nil + firstSystemAudioSampleTime = nil + firstMicrophoneSampleTime = nil + + guard let assistant = AVOutputSettingsAssistant(preset: .preset3840x2160) else { + throw NSError(domain: "RecordlyCapture", code: 5, userInfo: [NSLocalizedDescriptionKey: "Unable to create output settings assistant"]) + } + + assistant.sourceVideoFormat = try CMVideoFormatDescription( + videoCodecType: .h264, + width: outputWidth, + height: outputHeight + ) + + guard var outputSettings = assistant.videoSettings else { + throw NSError(domain: "RecordlyCapture", code: 6, userInfo: [NSLocalizedDescriptionKey: "Output settings unavailable"]) + } + + outputSettings[AVVideoWidthKey] = outputWidth + outputSettings[AVVideoHeightKey] = outputHeight + + let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings) + videoInput.expectsMediaDataInRealTime = true + + guard let assetWriter = assetWriter, assetWriter.canAdd(videoInput) else { + throw NSError(domain: "RecordlyCapture", code: 7, userInfo: [NSLocalizedDescriptionKey: "Unable to add video writer input"]) + } + + assetWriter.add(videoInput) + self.videoInput = videoInput + + if capturesSystemAudio || capturesMicrophone { + let inlineAudio = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 192_000)) + inlineAudio.expectsMediaDataInRealTime = true + if assetWriter.canAdd(inlineAudio) { + assetWriter.add(inlineAudio) + self.inlineAudioInput = inlineAudio + } + } + + if writesSystemAudioToSeparateTrack { + guard let systemAudioOutputPath = config.systemAudioOutputPath, !systemAudioOutputPath.isEmpty else { + throw NSError(domain: "RecordlyCapture", code: 11, userInfo: [NSLocalizedDescriptionKey: "Missing system audio output path for audio capture"]) + } + + let systemAudioURL = URL(fileURLWithPath: systemAudioOutputPath) + let systemAudioWriter = try AVAssetWriter(url: systemAudioURL, fileType: .m4a) + let systemAudioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 160_000)) + systemAudioInput.expectsMediaDataInRealTime = true + + guard systemAudioWriter.canAdd(systemAudioInput) else { + throw NSError(domain: "RecordlyCapture", code: 12, userInfo: [NSLocalizedDescriptionKey: "Unable to add system audio writer input"]) + } + + systemAudioWriter.add(systemAudioInput) + self.systemAudioWriter = systemAudioWriter + self.systemAudioInput = systemAudioInput + + guard systemAudioWriter.startWriting() else { + throw NSError(domain: "RecordlyCapture", code: 13, userInfo: [NSLocalizedDescriptionKey: systemAudioWriter.error?.localizedDescription ?? "Unable to start system audio writing"]) + } + + systemAudioWriter.startSession(atSourceTime: .zero) + } + + if writesMicrophoneToSeparateTrack { + guard let microphoneOutputPath = config.microphoneOutputPath, !microphoneOutputPath.isEmpty else { + throw NSError(domain: "RecordlyCapture", code: 14, userInfo: [NSLocalizedDescriptionKey: "Missing microphone output path for microphone capture"]) + } + + let microphoneURL = URL(fileURLWithPath: microphoneOutputPath) + microphoneOutputURL = microphoneURL + let microphoneWriter = try AVAssetWriter(url: microphoneURL, fileType: .m4a) + let microphoneInput = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 128_000)) + microphoneInput.expectsMediaDataInRealTime = true + + guard microphoneWriter.canAdd(microphoneInput) else { + throw NSError(domain: "RecordlyCapture", code: 15, userInfo: [NSLocalizedDescriptionKey: "Unable to add microphone writer input"]) + } + + microphoneWriter.add(microphoneInput) + self.microphoneOnlyWriter = microphoneWriter + self.microphoneOnlyInput = microphoneInput + + guard microphoneWriter.startWriting() else { + throw NSError(domain: "RecordlyCapture", code: 16, userInfo: [NSLocalizedDescriptionKey: microphoneWriter.error?.localizedDescription ?? "Unable to start microphone audio writing"]) + } + + microphoneWriter.startSession(atSourceTime: .zero) + } + + let stream = SCStream(filter: filter, configuration: streamConfig, delegate: self) + self.stream = stream + try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue) + if capturesSystemAudio { + try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: queue) + } + if capturesMicrophone { + guard let microphoneOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) else { + throw NSError( + domain: "RecordlyCapture", + code: 17, + userInfo: [NSLocalizedDescriptionKey: "Microphone stream output type is unavailable"] + ) + } + try stream.addStreamOutput(self, type: microphoneOutputType, sampleHandlerQueue: queue) + } + try await stream.startCapture() + + guard assetWriter.startWriting() else { + throw NSError(domain: "RecordlyCapture", code: 8, userInfo: [NSLocalizedDescriptionKey: assetWriter.error?.localizedDescription ?? "Unable to start video writing"]) + } + + assetWriter.startSession(atSourceTime: .zero) + sessionStarted = true + isRecording = true + isPaused = false + pauseStartedHostTime = nil + pendingResumeAdjustment = false + accumulatedPausedDuration = .zero + frameCount = 0 + firstSampleTime = .zero + lastVideoPresentationTime = .zero + lastVideoDuration = .zero + startWindowValidationIfNeeded() + print("Recording started") + fflush(stdout) + } + + func stopCapture() async throws -> String { + guard isRecording else { + throw NSError(domain: "RecordlyCapture", code: 9, userInfo: [NSLocalizedDescriptionKey: "No recording in progress"]) + } + + return try await finishCapture() + } + + func pauseCapture() { + guard isRecording, !isPaused else { return } + isPaused = true + pauseStartedHostTime = CMClockGetTime(CMClockGetHostTimeClock()) + pendingResumeAdjustment = false + } + + func resumeCapture() { + guard isRecording, isPaused else { return } + isPaused = false + pendingResumeAdjustment = true + } + + private static func audioOutputSettings(bitRate: Int) -> [String: Any] { + [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 48_000, + AVNumberOfChannelsKey: 2, + AVEncoderBitRateKey: bitRate, + ] + } + + private static func resolveMicrophoneCaptureDeviceID(config: CaptureConfig) -> String? { + let audioDevices = AVCaptureDevice.devices(for: .audio) + + if let microphoneLabel = config.microphoneLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneLabel.isEmpty { + if let matchedDevice = audioDevices.first(where: { $0.localizedName == microphoneLabel }) { + return matchedDevice.uniqueID + } + } + + if let microphoneDeviceId = config.microphoneDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneDeviceId.isEmpty { + if audioDevices.contains(where: { $0.uniqueID == microphoneDeviceId }) { + return microphoneDeviceId + } + } + + return nil + } + + private func supportsNativeMicrophoneCapture(streamConfig: SCStreamConfiguration) -> Bool { + let supportsConfigSelector = streamConfig.responds(to: Selector(("setCaptureMicrophone:"))) + let supportsDeviceSelector = streamConfig.responds(to: Selector(("setMicrophoneCaptureDeviceID:"))) + let supportsOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) != nil + return supportsConfigSelector && supportsDeviceSelector && supportsOutputType + } + + private func startWindowValidationIfNeeded() { + guard let trackedWindowId else { + windowValidationTask?.cancel() + windowValidationTask = nil + return + } + + windowValidationTask?.cancel() + windowValidationTask = Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 500_000_000) + if Task.isCancelled { return } + guard self.isRecording else { return } + + do { + let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + let windowStillAvailable = availableContent.windows.contains(where: { $0.windowID == trackedWindowId }) + if !windowStillAvailable { + print("WINDOW_UNAVAILABLE") + fflush(stdout) + let outputPath = try await self.finishCapture() + print("Recording stopped. Output path: \(outputPath)") + fflush(stdout) + exit(0) + } + } catch { + continue + } + } + } + } + + private static func scaleFactor(for displayId: CGDirectDisplayID) -> Int { + guard let mode = CGDisplayCopyDisplayMode(displayId) else { + return 1 + } + return max(1, mode.pixelWidth / max(1, mode.width)) + } +} \ No newline at end of file diff --git a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper index 91b54570..d1eb40d8 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper differ diff --git a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper index 3696e45d..86893573 100755 Binary files a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper differ diff --git a/electron/preload.ts b/electron/preload.ts index 4ae5d8bf..2c495e7b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,6 @@ import { contextBridge, ipcRenderer } from "electron"; +import { createExtensionsBridge } from "./preloadExtensionsBridge"; +import { createUpdateBridge } from "./preloadUpdateBridge"; type NativeVideoExportWriteResult = { success: boolean; error?: string }; @@ -425,91 +427,7 @@ contextBridge.exposeInMainWorld("electronAPI", { openProjectsDirectory: () => { return ipcRenderer.invoke("open-projects-directory"); }, - installDownloadedUpdate: () => { - return ipcRenderer.invoke("install-downloaded-update"); - }, - downloadAvailableUpdate: () => { - return ipcRenderer.invoke("download-available-update"); - }, - deferDownloadedUpdate: (delayMs?: number) => { - return ipcRenderer.invoke("defer-downloaded-update", delayMs); - }, - dismissUpdateToast: () => { - return ipcRenderer.invoke("dismiss-update-toast"); - }, - skipUpdateVersion: () => { - return ipcRenderer.invoke("skip-update-version"); - }, - getCurrentUpdateToastPayload: () => { - return ipcRenderer.invoke("get-current-update-toast-payload"); - }, - getUpdateStatusSummary: () => { - return ipcRenderer.invoke("get-update-status-summary"); - }, - previewUpdateToast: () => { - return ipcRenderer.invoke("preview-update-toast"); - }, - checkForAppUpdates: () => { - 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, - ) => { - 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, - ) => callback(payload); - ipcRenderer.on("update-toast-state", listener); - return () => ipcRenderer.removeListener("update-toast-state", listener); - }, - onUpdateReadyToast: ( - callback: (payload: { - version: string; - detail: string; - delayMs: number; - isPreview?: boolean; - }) => void, - ) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: { version: string; detail: string; delayMs: number; isPreview?: boolean }, - ) => callback(payload); - ipcRenderer.on("update-ready-toast", listener); - return () => ipcRenderer.removeListener("update-ready-toast", listener); - }, - onMenuLoadProject: (callback: () => void) => { - const listener = () => callback(); - ipcRenderer.on("menu-load-project", listener); - return () => ipcRenderer.removeListener("menu-load-project", listener); - }, - onMenuSaveProject: (callback: () => void) => { - const listener = () => callback(); - ipcRenderer.on("menu-save-project", listener); - return () => ipcRenderer.removeListener("menu-save-project", listener); - }, - onMenuSaveProjectAs: (callback: () => void) => { - const listener = () => callback(); - ipcRenderer.on("menu-save-project-as", listener); - return () => ipcRenderer.removeListener("menu-save-project-as", listener); - }, + ...createUpdateBridge(ipcRenderer), getPlatform: () => { return ipcRenderer.invoke("get-platform"); }, @@ -569,35 +487,5 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("countdown-tick", listener); return () => ipcRenderer.removeListener("countdown-tick", listener); }, - - // ── Extensions ────────────────────────────────────────────────────── - extensionsDiscover: () => ipcRenderer.invoke("extensions:discover"), - extensionsList: () => ipcRenderer.invoke("extensions:list"), - extensionsGet: (id: string) => ipcRenderer.invoke("extensions:get", id), - extensionsEnable: (id: string) => ipcRenderer.invoke("extensions:enable", id), - extensionsDisable: (id: string) => ipcRenderer.invoke("extensions:disable", id), - extensionsInstallFromFolder: () => ipcRenderer.invoke("extensions:install-from-folder"), - extensionsUninstall: (id: string) => ipcRenderer.invoke("extensions:uninstall", id), - extensionsGetDirectory: () => ipcRenderer.invoke("extensions:get-directory"), - extensionsOpenDirectory: () => ipcRenderer.invoke("extensions:open-directory"), - - // ── Extensions — Marketplace ──────────────────────────────────────── - extensionsMarketplaceSearch: (params: { - query?: string; - tags?: string[]; - sort?: string; - page?: number; - pageSize?: number; - }) => ipcRenderer.invoke("extensions:marketplace-search", params), - extensionsMarketplaceGet: (id: string) => ipcRenderer.invoke("extensions:marketplace-get", id), - extensionsMarketplaceInstall: (extensionId: string, downloadUrl: string) => - ipcRenderer.invoke("extensions:marketplace-install", extensionId, downloadUrl), - extensionsMarketplaceSubmit: (extensionId: string) => - ipcRenderer.invoke("extensions:marketplace-submit", extensionId), - - // ── Extensions — Admin Review ─────────────────────────────────────── - extensionsReviewsList: (params: { status?: string; page?: number; pageSize?: number }) => - ipcRenderer.invoke("extensions:reviews-list", params), - extensionsReviewUpdate: (reviewId: string, status: string, notes?: string) => - ipcRenderer.invoke("extensions:review-update", reviewId, status, notes), + ...createExtensionsBridge(ipcRenderer), }); diff --git a/electron/preloadExtensionsBridge.ts b/electron/preloadExtensionsBridge.ts new file mode 100644 index 00000000..6de55106 --- /dev/null +++ b/electron/preloadExtensionsBridge.ts @@ -0,0 +1,31 @@ +import type { IpcRenderer } from "electron"; + +export function createExtensionsBridge(ipcRenderer: IpcRenderer) { + return { + extensionsDiscover: () => ipcRenderer.invoke("extensions:discover"), + extensionsList: () => ipcRenderer.invoke("extensions:list"), + extensionsGet: (id: string) => ipcRenderer.invoke("extensions:get", id), + extensionsEnable: (id: string) => ipcRenderer.invoke("extensions:enable", id), + extensionsDisable: (id: string) => ipcRenderer.invoke("extensions:disable", id), + extensionsInstallFromFolder: () => ipcRenderer.invoke("extensions:install-from-folder"), + extensionsUninstall: (id: string) => ipcRenderer.invoke("extensions:uninstall", id), + extensionsGetDirectory: () => ipcRenderer.invoke("extensions:get-directory"), + extensionsOpenDirectory: () => ipcRenderer.invoke("extensions:open-directory"), + extensionsMarketplaceSearch: (params: { + query?: string; + tags?: string[]; + sort?: string; + page?: number; + pageSize?: number; + }) => ipcRenderer.invoke("extensions:marketplace-search", params), + extensionsMarketplaceGet: (id: string) => ipcRenderer.invoke("extensions:marketplace-get", id), + extensionsMarketplaceInstall: (extensionId: string, downloadUrl: string) => + ipcRenderer.invoke("extensions:marketplace-install", extensionId, downloadUrl), + extensionsMarketplaceSubmit: (extensionId: string) => + ipcRenderer.invoke("extensions:marketplace-submit", extensionId), + extensionsReviewsList: (params: { status?: string; page?: number; pageSize?: number }) => + ipcRenderer.invoke("extensions:reviews-list", params), + extensionsReviewUpdate: (reviewId: string, status: string, notes?: string) => + ipcRenderer.invoke("extensions:review-update", reviewId, status, notes), + }; +} \ No newline at end of file diff --git a/electron/preloadUpdateBridge.ts b/electron/preloadUpdateBridge.ts new file mode 100644 index 00000000..3f589838 --- /dev/null +++ b/electron/preloadUpdateBridge.ts @@ -0,0 +1,54 @@ +import type { IpcRenderer, IpcRendererEvent } from "electron"; + +export function createUpdateBridge(ipcRenderer: IpcRenderer) { + return { + installDownloadedUpdate: () => ipcRenderer.invoke("install-downloaded-update"), + downloadAvailableUpdate: () => ipcRenderer.invoke("download-available-update"), + deferDownloadedUpdate: (delayMs?: number) => + ipcRenderer.invoke("defer-downloaded-update", delayMs), + dismissUpdateToast: () => ipcRenderer.invoke("dismiss-update-toast"), + skipUpdateVersion: () => ipcRenderer.invoke("skip-update-version"), + getCurrentUpdateToastPayload: () => ipcRenderer.invoke("get-current-update-toast-payload"), + getUpdateStatusSummary: () => ipcRenderer.invoke("get-update-status-summary"), + previewUpdateToast: () => ipcRenderer.invoke("preview-update-toast"), + checkForAppUpdates: () => ipcRenderer.invoke("check-for-app-updates"), + onUpdateToastStateChanged: ( + callback: (payload: UpdateToastState | null) => void, + ) => { + const listener = (_event: IpcRendererEvent, payload: UpdateToastState | null) => + callback(payload); + ipcRenderer.on("update-toast-state", listener); + return () => ipcRenderer.removeListener("update-toast-state", listener); + }, + onUpdateReadyToast: ( + callback: (payload: { + version: string; + detail: string; + delayMs: number; + isPreview?: boolean; + }) => void, + ) => { + const listener = ( + _event: IpcRendererEvent, + payload: { version: string; detail: string; delayMs: number; isPreview?: boolean }, + ) => callback(payload); + ipcRenderer.on("update-ready-toast", listener); + return () => ipcRenderer.removeListener("update-ready-toast", listener); + }, + onMenuLoadProject: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-load-project", listener); + return () => ipcRenderer.removeListener("menu-load-project", listener); + }, + onMenuSaveProject: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-save-project", listener); + return () => ipcRenderer.removeListener("menu-save-project", listener); + }, + onMenuSaveProjectAs: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-save-project-as", listener); + return () => ipcRenderer.removeListener("menu-save-project-as", listener); + }, + }; +} \ No newline at end of file diff --git a/electron/updater.ts b/electron/updater.ts index c81ba7bb..715c3f51 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -1,289 +1,64 @@ -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 { USER_DATA_PATH } from "./appPaths"; +import { + AUTO_UPDATES_DISABLED, + DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT, + DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS, + DEV_UPDATE_PREVIEW_VERSION, + DISMISSED_READY_REMINDER_DELAY_MS, + UPDATE_REMINDER_DELAY_MS, + UPDATER_LOG_PATH, + canUseAutoUpdates, + clearDeferredReminderTimer, + clearDevPreviewProgressTimer, + clearVisibleUpdateToast, + configureUpdateFeed, + createAutoCheckErrorToastPayload, + createDownloadedUpdateToastPayload, + createDownloadingUpdateToastPayload, + createUpdateErrorToastPayload, + emitUpdateToastState, + getReminderPayload, + isAutoUpdateFeatureEnabled, + setUpdateStatusSummary, + shouldSurfaceAutomaticCheckErrors, + showMessageBox, + type GetMainWindow, + type UpdateToastSender, + updaterState, + writeUpdaterLog, +} from "./updaterShared"; +import { + showAvailableUpdateDialog, + showDownloadedUpdateDialog, + showNoUpdatesDialog, + showUpdateErrorDialog, +} from "./updaterDialogs"; +import { registerAutoUpdaterEventHandlers } from "./updaterEventHandlers"; -const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; -export const UPDATE_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000; -const DISMISSED_READY_REMINDER_DELAY_MS = 5 * 60 * 1000; -const AUTO_UPDATES_DISABLED = process.env.RECORDLY_DISABLE_AUTO_UPDATES === "1"; -const AUTO_UPDATE_ERROR_TOASTS_DISABLED = - process.env.RECORDLY_DISABLE_AUTO_UPDATE_ERROR_TOASTS === "1"; -const UPDATE_FEED_URL_OVERRIDE = process.env.RECORDLY_UPDATE_FEED_URL?.trim() ?? ""; -const UPDATER_LOG_PATH = - process.env.RECORDLY_UPDATER_LOG_PATH?.trim() || path.join(USER_DATA_PATH, "updater.log"); -const DEV_UPDATE_PREVIEW_VERSION = "9.9.9"; -const DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS = 300; -const DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT = 20; - -export type UpdateToastPhase = "available" | "downloading" | "ready" | "error"; - -export type UpdateStatusKind = - | "idle" - | "checking" - | "up-to-date" - | "available" - | "downloading" - | "ready" - | "error"; - -export interface UpdateStatusSummary { - status: UpdateStatusKind; - currentVersion: string; - availableVersion: string | null; - detail?: string; -} - -export interface UpdateToastPayload { - version: string; - detail: string; - phase: UpdateToastPhase; - delayMs: number; - isPreview?: boolean; - progressPercent?: number; - primaryAction?: "download-update" | "install-update" | "retry-check"; -} - -type UpdateToastSender = ( - channel: "update-toast-state", - payload: UpdateToastPayload | null, -) => boolean; - -let updaterInitialized = false; -let updateCheckInProgress = false; -let manualCheckRequested = false; -let periodicCheckTimer: NodeJS.Timeout | null = null; -let deferredReminderTimer: NodeJS.Timeout | null = null; -let devPreviewProgressTimer: NodeJS.Timeout | null = null; -let currentToastPayload: UpdateToastPayload | null = null; -let availableVersion: string | null = null; -let pendingDownloadedVersion: string | null = null; -let downloadInProgress = false; -let downloadToastDismissed = false; -let skippedVersion: string | null = null; -let updateCheckErrorHandled = false; -let activeUpdateToastSender: UpdateToastSender | undefined; -let updateStatusSummary: UpdateStatusSummary = { - status: "idle", - currentVersion: app.getVersion(), - availableVersion: null, -}; - -function setUpdateStatusSummary(summary: Partial) { - updateStatusSummary = { - ...updateStatusSummary, - currentVersion: app.getVersion(), - ...summary, - }; -} - -function summarizeError(error: unknown) { - if (error instanceof Error) { - return error.stack || `${error.name}: ${error.message}`; - } - - return String(error); -} - -function writeUpdaterLog(message: string, detail?: unknown) { - try { - fs.mkdirSync(path.dirname(UPDATER_LOG_PATH), { recursive: true }); - const suffix = detail === undefined ? "" : ` ${summarizeError(detail)}`; - fs.appendFileSync( - UPDATER_LOG_PATH, - `${new Date().toISOString()} ${message}${suffix}\n`, - "utf8", - ); - } catch (logError) { - console.error("Failed to write updater log:", logError); - } -} - -function createAutoCheckErrorToastPayload(): UpdateToastPayload { - return { - version: app.getVersion(), - phase: "error", - detail: "Recordly could not check for updates automatically. Retry now, or inspect updater.log in your user data folder.", - delayMs: UPDATE_REMINDER_DELAY_MS, - primaryAction: "retry-check", - }; -} - -function shouldSurfaceAutomaticCheckErrors() { - return !AUTO_UPDATE_ERROR_TOASTS_DISABLED; -} - -function configureUpdateFeed() { - if (!UPDATE_FEED_URL_OVERRIDE) { - writeUpdaterLog("Using published GitHub update feed."); - return; - } - - autoUpdater.setFeedURL({ - provider: "generic", - url: UPDATE_FEED_URL_OVERRIDE, - channel: "latest", - }); - writeUpdaterLog(`Using overridden update feed: ${UPDATE_FEED_URL_OVERRIDE}`); -} - -function canUseAutoUpdates() { - return !AUTO_UPDATES_DISABLED && app.isPackaged && !process.mas; -} - -export function isAutoUpdateFeatureEnabled() { - return !AUTO_UPDATES_DISABLED; -} - -function getDialogWindow(getMainWindow: () => BrowserWindow | null) { - const window = getMainWindow(); - return window && !window.isDestroyed() ? window : undefined; -} - -function showMessageBox( - getMainWindow: () => BrowserWindow | null, - options: MessageBoxOptions, -): Promise { - const window = getDialogWindow(getMainWindow); - return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options); -} - -function clearDeferredReminderTimer() { - if (deferredReminderTimer) { - clearTimeout(deferredReminderTimer); - deferredReminderTimer = null; - } -} - -function clearDevPreviewProgressTimer() { - if (devPreviewProgressTimer) { - clearInterval(devPreviewProgressTimer); - devPreviewProgressTimer = null; - } -} - -function emitUpdateToastState( - sendToRenderer: UpdateToastSender | undefined, - payload: UpdateToastPayload | null, -) { - currentToastPayload = payload; - if (!sendToRenderer) { - return false; - } - - return sendToRenderer("update-toast-state", payload); -} - -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.", - delayMs: UPDATE_REMINDER_DELAY_MS, - primaryAction: "download-update", - }; -} - -function createDownloadingUpdateToastPayload( - version: string, - progressPercent = 0, -): UpdateToastPayload { - const normalizedProgress = Math.max(0, Math.min(100, progressPercent)); - return { - version, - phase: "downloading", - detail: - normalizedProgress >= 100 - ? "Finishing the update download. You can keep using Recordly while this completes." - : `Downloading the update in the foreground: ${normalizedProgress.toFixed(0)}% complete.`, - delayMs: UPDATE_REMINDER_DELAY_MS, - progressPercent: normalizedProgress, - }; -} - -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.", - delayMs: UPDATE_REMINDER_DELAY_MS, - primaryAction: "install-update", - }; -} - -function createUpdateErrorToastPayload(version: string, error: unknown): UpdateToastPayload { - return { - version, - phase: "error", - detail: `The update download failed. ${String(error)}`, - delayMs: UPDATE_REMINDER_DELAY_MS, - primaryAction: "download-update", - }; -} - -function getReminderPayload(): UpdateToastPayload | null { - if (pendingDownloadedVersion) { - return createDownloadedUpdateToastPayload(pendingDownloadedVersion); - } - - if (availableVersion && !downloadInProgress) { - return createAvailableUpdateToastPayload(availableVersion); - } - - return null; -} - -function clearVisibleUpdateToast(sendToRenderer?: UpdateToastSender) { - emitUpdateToastState(sendToRenderer, null); -} - -export function getCurrentUpdateToastPayload() { - return currentToastPayload; -} - -export function getUpdaterLogPath() { - return UPDATER_LOG_PATH; -} - -export function getUpdateStatusSummary() { - return updateStatusSummary; -} - -async function showNoUpdatesDialog(getMainWindow: () => BrowserWindow | null) { - await showMessageBox(getMainWindow, { - type: "info", - title: "No Updates Available", - message: "Recordly is up to date.", - detail: `You are running version ${app.getVersion()}.`, - }); -} - -async function showUpdateErrorDialog(getMainWindow: () => BrowserWindow | null, error: unknown) { - await showMessageBox(getMainWindow, { - type: "error", - title: "Update Check Failed", - message: "Recordly could not check for updates.", - detail: String(error), - }); -} +export { UPDATE_REMINDER_DELAY_MS } from "./updaterShared"; +export type { + UpdateStatusKind, + UpdateStatusSummary, + UpdateToastPayload, + UpdateToastPhase, +} from "./updaterShared"; +export { isAutoUpdateFeatureEnabled }; function resetDevPreviewState(sendToRenderer?: UpdateToastSender) { clearDevPreviewProgressTimer(); - availableVersion = null; - pendingDownloadedVersion = null; - downloadInProgress = false; - downloadToastDismissed = false; - skippedVersion = null; + updaterState.availableVersion = null; + updaterState.pendingDownloadedVersion = null; + updaterState.downloadInProgress = false; + updaterState.downloadToastDismissed = false; + updaterState.skippedVersion = null; clearVisibleUpdateToast(sendToRenderer); } function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) { - availableVersion = DEV_UPDATE_PREVIEW_VERSION; - pendingDownloadedVersion = null; - downloadInProgress = true; - downloadToastDismissed = false; + updaterState.availableVersion = DEV_UPDATE_PREVIEW_VERSION; + updaterState.pendingDownloadedVersion = null; + updaterState.downloadInProgress = true; + updaterState.downloadToastDismissed = false; clearDeferredReminderTimer(); clearDevPreviewProgressTimer(); @@ -293,13 +68,13 @@ function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) { isPreview: true, }); - devPreviewProgressTimer = setInterval(() => { + updaterState.devPreviewProgressTimer = setInterval(() => { progressPercent = Math.min(100, progressPercent + DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT); if (progressPercent >= 100) { clearDevPreviewProgressTimer(); - downloadInProgress = false; - pendingDownloadedVersion = DEV_UPDATE_PREVIEW_VERSION; + updaterState.downloadInProgress = false; + updaterState.pendingDownloadedVersion = DEV_UPDATE_PREVIEW_VERSION; emitUpdateToastState(sendToRenderer, { ...createDownloadedUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION), isPreview: true, @@ -308,7 +83,7 @@ function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) { return; } - if (downloadToastDismissed) { + if (updaterState.downloadToastDismissed) { return; } @@ -321,22 +96,34 @@ function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) { return { success: true }; } +export function getCurrentUpdateToastPayload() { + return updaterState.currentToastPayload; +} + +export function getUpdaterLogPath() { + return UPDATER_LOG_PATH; +} + +export function getUpdateStatusSummary() { + return updaterState.updateStatusSummary; +} + export function dismissUpdateToast( - getMainWindow: () => BrowserWindow | null, + getMainWindow: GetMainWindow, sendToRenderer?: UpdateToastSender, ) { - if (currentToastPayload?.isPreview) { + if (updaterState.currentToastPayload?.isPreview) { resetDevPreviewState(sendToRenderer); return { success: true }; } - if (downloadInProgress) { - downloadToastDismissed = true; + if (updaterState.downloadInProgress) { + updaterState.downloadToastDismissed = true; clearVisibleUpdateToast(sendToRenderer); return { success: true }; } - if (currentToastPayload?.phase === "ready") { + if (updaterState.currentToastPayload?.phase === "ready") { return deferUpdateReminder( getMainWindow, sendToRenderer, @@ -344,7 +131,10 @@ export function dismissUpdateToast( ); } - if (currentToastPayload?.phase === "available" || currentToastPayload?.phase === "error") { + if ( + updaterState.currentToastPayload?.phase === "available" || + updaterState.currentToastPayload?.phase === "error" + ) { return deferUpdateReminder(getMainWindow, sendToRenderer, UPDATE_REMINDER_DELAY_MS); } @@ -353,69 +143,75 @@ export function dismissUpdateToast( } export function installDownloadedUpdateNow(sendToRenderer?: UpdateToastSender) { - if (currentToastPayload?.isPreview) { + if (updaterState.currentToastPayload?.isPreview) { resetDevPreviewState(sendToRenderer); return; } clearDeferredReminderTimer(); - downloadToastDismissed = false; + updaterState.downloadToastDismissed = false; clearVisibleUpdateToast(sendToRenderer); - setUpdateStatusSummary({ status: "ready", availableVersion: pendingDownloadedVersion }); + setUpdateStatusSummary({ + status: "ready", + availableVersion: updaterState.pendingDownloadedVersion, + }); writeUpdaterLog("Installing downloaded update."); autoUpdater.quitAndInstall(); } export async function downloadAvailableUpdate(sendToRenderer?: UpdateToastSender) { - if (currentToastPayload?.isPreview) { + if (updaterState.currentToastPayload?.isPreview) { return simulateDevPreviewDownload(sendToRenderer); } - if (!availableVersion) { + if (!updaterState.availableVersion) { return { success: false, message: "No update is ready to download." }; } - if (pendingDownloadedVersion === availableVersion) { + if (updaterState.pendingDownloadedVersion === updaterState.availableVersion) { return { success: false, message: "This update has already been downloaded." }; } - if (downloadInProgress) { + if (updaterState.downloadInProgress) { return { success: false, message: "This update is already downloading." }; } clearDeferredReminderTimer(); - downloadInProgress = true; - downloadToastDismissed = false; + updaterState.downloadInProgress = true; + updaterState.downloadToastDismissed = false; setUpdateStatusSummary({ status: "downloading", - availableVersion, - detail: `Downloading Recordly ${availableVersion}`, + availableVersion: updaterState.availableVersion, + detail: `Downloading Recordly ${updaterState.availableVersion}`, }); - emitUpdateToastState(sendToRenderer, createDownloadingUpdateToastPayload(availableVersion, 0)); - writeUpdaterLog(`Starting update download for ${availableVersion}.`); + emitUpdateToastState( + sendToRenderer, + createDownloadingUpdateToastPayload(updaterState.availableVersion, 0), + ); + writeUpdaterLog(`Starting update download for ${updaterState.availableVersion}.`); try { await autoUpdater.downloadUpdate(); - writeUpdaterLog(`Update download requested for ${availableVersion}.`); + writeUpdaterLog(`Update download requested for ${updaterState.availableVersion}.`); return { success: true }; } catch (error) { - downloadInProgress = false; + updaterState.downloadInProgress = false; setUpdateStatusSummary({ status: "error", - availableVersion, + availableVersion: updaterState.availableVersion, detail: String(error), }); - writeUpdaterLog(`Update download failed for ${availableVersion}.`, error); + writeUpdaterLog(`Update download failed for ${updaterState.availableVersion}.`, error); emitUpdateToastState( sendToRenderer, - createUpdateErrorToastPayload(availableVersion, error), + createUpdateErrorToastPayload(updaterState.availableVersion ?? "unknown", error), ); return { success: false, message: String(error) }; } } export function deferUpdateReminder( - getMainWindow: () => BrowserWindow | null, + getMainWindow: GetMainWindow, sendToRenderer?: UpdateToastSender, delayMs = UPDATE_REMINDER_DELAY_MS, ) { @@ -426,7 +222,7 @@ export function deferUpdateReminder( clearDeferredReminderTimer(); clearVisibleUpdateToast(sendToRenderer); - deferredReminderTimer = setTimeout(() => { + updaterState.deferredReminderTimer = setTimeout(() => { const nextPayload = getReminderPayload(); if (!nextPayload) { return; @@ -437,31 +233,50 @@ export function deferUpdateReminder( } if (nextPayload.phase === "ready") { - void showDownloadedUpdateDialog(getMainWindow, nextPayload.version); + void showDownloadedUpdateDialog( + getMainWindow, + nextPayload.version, + { + downloadAvailableUpdate, + deferUpdateReminder, + skipAvailableUpdateVersion, + installDownloadedUpdateNow, + }, + ); return; } - void showAvailableUpdateDialog(getMainWindow, nextPayload.version, sendToRenderer); + void showAvailableUpdateDialog( + getMainWindow, + nextPayload.version, + sendToRenderer, + { + downloadAvailableUpdate, + deferUpdateReminder, + skipAvailableUpdateVersion, + installDownloadedUpdateNow, + }, + ); }, delayMs); return { success: true }; } export function skipAvailableUpdateVersion(sendToRenderer?: UpdateToastSender) { - const versionToSkip = pendingDownloadedVersion ?? availableVersion; + const versionToSkip = updaterState.pendingDownloadedVersion ?? updaterState.availableVersion; if (!versionToSkip) { return { success: false, message: "No update is available to skip." }; } - skippedVersion = versionToSkip; - if (pendingDownloadedVersion === versionToSkip) { - pendingDownloadedVersion = null; + updaterState.skippedVersion = versionToSkip; + if (updaterState.pendingDownloadedVersion === versionToSkip) { + updaterState.pendingDownloadedVersion = null; } - if (availableVersion === versionToSkip) { - availableVersion = null; + if (updaterState.availableVersion === versionToSkip) { + updaterState.availableVersion = null; } - downloadInProgress = false; - downloadToastDismissed = false; + updaterState.downloadInProgress = false; + updaterState.downloadToastDismissed = false; clearDeferredReminderTimer(); clearVisibleUpdateToast(sendToRenderer); @@ -471,10 +286,10 @@ export function skipAvailableUpdateVersion(sendToRenderer?: UpdateToastSender) { export function previewUpdateToast(sendToRenderer: UpdateToastSender) { clearDeferredReminderTimer(); clearDevPreviewProgressTimer(); - availableVersion = DEV_UPDATE_PREVIEW_VERSION; - pendingDownloadedVersion = null; - downloadInProgress = false; - downloadToastDismissed = false; + updaterState.availableVersion = DEV_UPDATE_PREVIEW_VERSION; + updaterState.pendingDownloadedVersion = null; + updaterState.downloadInProgress = false; + updaterState.downloadToastDismissed = false; return emitUpdateToastState(sendToRenderer, { version: DEV_UPDATE_PREVIEW_VERSION, phase: "available", @@ -484,97 +299,13 @@ export function previewUpdateToast(sendToRenderer: UpdateToastSender) { }); } -async function showAvailableUpdateDialog( - getMainWindow: () => BrowserWindow | null, - version: string, - sendToRenderer?: UpdateToastSender, -) { - const result = await showMessageBox(getMainWindow, { - type: "info", - title: "Update Available", - message: `Recordly ${version} is available.`, - detail: "Download now, remind me in 3 hours, or skip this version.", - buttons: ["Download Update", "Remind Me in 3 Hours", "Skip This Version"], - defaultId: 0, - cancelId: 1, - noLink: true, - }); - - if (result.response === 0) { - await downloadAvailableUpdate(sendToRenderer); - return; - } - - if (result.response === 1) { - deferUpdateReminder(getMainWindow, sendToRenderer, UPDATE_REMINDER_DELAY_MS); - return; - } - - skipAvailableUpdateVersion(sendToRenderer); -} - -async function showDownloadedUpdateDialog( - getMainWindow: () => BrowserWindow | null, - version: string, - options?: { isPreview?: boolean }, -) { - const isPreview = Boolean(options?.isPreview); - const result = await showMessageBox(getMainWindow, { - type: "info", - title: "Update Ready", - message: isPreview - ? `Recordly ${version} is ready to install.` - : `Recordly ${version} has been downloaded.`, - detail: isPreview - ? "Development preview of the native update prompt. No real update will be installed." - : "Install now, remind me in 3 hours, or skip this version.", - buttons: ["Install Update", "Remind Me in 3 Hours", "Skip This Version"], - defaultId: 0, - cancelId: 1, - noLink: true, - }); - - if (result.response === 0) { - if (isPreview) { - await showMessageBox(getMainWindow, { - type: "info", - title: "Preview Only", - message: "No real update was installed.", - detail: "This was only a manual development preview of the update prompt.", - }); - return; - } - - clearDeferredReminderTimer(); - setImmediate(() => { - installDownloadedUpdateNow(); - }); - return; - } - - if (result.response === 1) { - if (isPreview) { - return; - } - - deferUpdateReminder(getMainWindow, undefined, UPDATE_REMINDER_DELAY_MS); - return; - } - - if (isPreview) { - return; - } - - skipAvailableUpdateVersion(); -} - export async function checkForAppUpdates( - getMainWindow: () => BrowserWindow | null, + getMainWindow: GetMainWindow, options?: { manual?: boolean }, ) { if (!canUseAutoUpdates()) { writeUpdaterLog( - `Skipped update check because auto-updates are unavailable. packaged=${app.isPackaged} mas=${process.mas ? "yes" : "no"} disabled=${AUTO_UPDATES_DISABLED ? "yes" : "no"}`, + `Skipped update check because auto-updates are unavailable. packaged=${process.env.NODE_ENV === "production" ? "yes" : "no"} mas=${process.mas ? "yes" : "no"} disabled=${AUTO_UPDATES_DISABLED ? "yes" : "no"}`, ); if (options?.manual) { await showMessageBox(getMainWindow, { @@ -589,7 +320,7 @@ export async function checkForAppUpdates( return; } - if (updateCheckInProgress) { + if (updaterState.updateCheckInProgress) { writeUpdaterLog("Skipped update check because a previous check is still running."); if (options?.manual) { await showMessageBox(getMainWindow, { @@ -601,39 +332,41 @@ export async function checkForAppUpdates( return; } - manualCheckRequested = Boolean(options?.manual); - updateCheckInProgress = true; - updateCheckErrorHandled = false; + updaterState.manualCheckRequested = Boolean(options?.manual); + updaterState.updateCheckInProgress = true; + updaterState.updateCheckErrorHandled = false; setUpdateStatusSummary({ status: "checking", detail: "Checking for updates..." }); - writeUpdaterLog(`Starting ${manualCheckRequested ? "manual" : "automatic"} update check.`); + writeUpdaterLog( + `Starting ${updaterState.manualCheckRequested ? "manual" : "automatic"} update check.`, + ); try { await autoUpdater.checkForUpdates(); writeUpdaterLog("Update check request completed."); } catch (error) { - updateCheckInProgress = false; - const shouldReport = manualCheckRequested; - manualCheckRequested = false; + updaterState.updateCheckInProgress = false; + const shouldReport = updaterState.manualCheckRequested; + updaterState.manualCheckRequested = false; setUpdateStatusSummary({ status: "error", - availableVersion, + availableVersion: updaterState.availableVersion, detail: String(error), }); writeUpdaterLog("Update check failed.", error); console.error("Auto-update check failed:", error); - if (shouldReport && !updateCheckErrorHandled) { + if (shouldReport && !updaterState.updateCheckErrorHandled) { await showUpdateErrorDialog(getMainWindow, error); - } else if (!updateCheckErrorHandled && shouldSurfaceAutomaticCheckErrors()) { - emitUpdateToastState(activeUpdateToastSender, createAutoCheckErrorToastPayload()); + } else if (!updaterState.updateCheckErrorHandled && shouldSurfaceAutomaticCheckErrors()) { + emitUpdateToastState(updaterState.activeUpdateToastSender, createAutoCheckErrorToastPayload()); } } } export function setupAutoUpdates( - getMainWindow: () => BrowserWindow | null, + getMainWindow: GetMainWindow, sendToRenderer: UpdateToastSender, ) { - if (updaterInitialized) { + if (updaterState.updaterInitialized) { return; } @@ -642,162 +375,34 @@ export function setupAutoUpdates( return; } - updaterInitialized = true; - activeUpdateToastSender = sendToRenderer; + updaterState.updaterInitialized = true; + updaterState.activeUpdateToastSender = sendToRenderer; configureUpdateFeed(); autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; writeUpdaterLog(`Updater initialized. logPath=${UPDATER_LOG_PATH}`); - autoUpdater.on("checking-for-update", () => { - setUpdateStatusSummary({ - status: "checking", - availableVersion: null, - detail: "Checking for updates...", - }); - writeUpdaterLog("electron-updater emitted checking-for-update."); - }); - - autoUpdater.on("update-available", (info) => { - writeUpdaterLog(`Update available: version=${info.version}`); - updateCheckInProgress = false; - availableVersion = info.version; - pendingDownloadedVersion = null; - downloadInProgress = false; - downloadToastDismissed = false; - setUpdateStatusSummary({ - status: "available", - availableVersion: info.version, - detail: `Recordly ${info.version} is available.`, - }); - if (skippedVersion === info.version) { - manualCheckRequested = false; - return; - } - - const payload = createAvailableUpdateToastPayload(info.version); - if (emitUpdateToastState(sendToRenderer, payload)) { - manualCheckRequested = false; - return; - } - - if (manualCheckRequested) { - void showAvailableUpdateDialog(getMainWindow, info.version, sendToRenderer); - manualCheckRequested = false; - } - }); - - autoUpdater.on("update-not-available", () => { - writeUpdaterLog("No update available."); - updateCheckInProgress = false; - availableVersion = null; - pendingDownloadedVersion = null; - downloadInProgress = false; - downloadToastDismissed = false; - setUpdateStatusSummary({ - status: "up-to-date", - availableVersion: null, - detail: `Recordly ${app.getVersion()} is up to date.`, - }); - clearVisibleUpdateToast(sendToRenderer); - const shouldReport = manualCheckRequested; - manualCheckRequested = false; - if (shouldReport) { - void showNoUpdatesDialog(getMainWindow); - } - }); - - autoUpdater.on("download-progress", (progress) => { - if (!availableVersion) { - return; - } - - downloadInProgress = true; - setUpdateStatusSummary({ - status: "downloading", - availableVersion, - detail: `Downloading Recordly ${availableVersion}`, - }); - writeUpdaterLog( - `Download progress for ${availableVersion}: ${progress.percent.toFixed(1)}%`, - ); - if (downloadToastDismissed) { - return; - } - - emitUpdateToastState( - sendToRenderer, - createDownloadingUpdateToastPayload(availableVersion, progress.percent), - ); - }); - - autoUpdater.on("error", (error) => { - updateCheckInProgress = false; - const shouldReport = manualCheckRequested; - manualCheckRequested = false; - if (!downloadInProgress) { - updateCheckErrorHandled = true; - } - setUpdateStatusSummary({ - status: "error", - availableVersion, - detail: String(error), - }); - writeUpdaterLog("electron-updater emitted error.", error); - console.error("Auto-updater error:", error); - if (downloadInProgress && availableVersion) { - downloadInProgress = false; - downloadToastDismissed = false; - emitUpdateToastState( - sendToRenderer, - createUpdateErrorToastPayload(availableVersion, error), - ); - } - if (shouldReport) { - void showUpdateErrorDialog(getMainWindow, error); - } else if (shouldSurfaceAutomaticCheckErrors()) { - emitUpdateToastState(sendToRenderer, createAutoCheckErrorToastPayload()); - } - }); - - autoUpdater.on("update-downloaded", (info) => { - writeUpdaterLog(`Update downloaded: version=${info.version}`); - updateCheckInProgress = false; - manualCheckRequested = false; - downloadInProgress = false; - downloadToastDismissed = false; - if (skippedVersion === info.version) { - return; - } - availableVersion = info.version; - pendingDownloadedVersion = info.version; - setUpdateStatusSummary({ - status: "ready", - availableVersion: info.version, - detail: `Recordly ${info.version} is ready to install.`, - }); - clearDeferredReminderTimer(); - - if ( - emitUpdateToastState(sendToRenderer, createDownloadedUpdateToastPayload(info.version)) - ) { - return; - } - - void showDownloadedUpdateDialog(getMainWindow, info.version); - }); - - void checkForAppUpdates(getMainWindow); - periodicCheckTimer = setInterval(() => { - void checkForAppUpdates(getMainWindow); - }, UPDATE_CHECK_INTERVAL_MS); - - app.on("before-quit", () => { - clearDeferredReminderTimer(); - clearDevPreviewProgressTimer(); - if (periodicCheckTimer) { - clearInterval(periodicCheckTimer); - periodicCheckTimer = null; - } - }); -} + registerAutoUpdaterEventHandlers( + getMainWindow, + sendToRenderer, + { + showNoUpdatesDialog, + showUpdateErrorDialog, + showAvailableUpdateDialog: (windowGetter, version, renderer) => + showAvailableUpdateDialog(windowGetter, version, renderer, { + downloadAvailableUpdate, + deferUpdateReminder, + skipAvailableUpdateVersion, + installDownloadedUpdateNow, + }), + showDownloadedUpdateDialog: (windowGetter, version) => + showDownloadedUpdateDialog(windowGetter, version, { + downloadAvailableUpdate, + deferUpdateReminder, + skipAvailableUpdateVersion, + installDownloadedUpdateNow, + }), + }, + checkForAppUpdates, + ); +} \ No newline at end of file diff --git a/electron/updaterDialogs.ts b/electron/updaterDialogs.ts new file mode 100644 index 00000000..200df1f6 --- /dev/null +++ b/electron/updaterDialogs.ts @@ -0,0 +1,116 @@ +import { + showMessageBox, + type GetMainWindow, + type UpdateToastSender, + UPDATE_REMINDER_DELAY_MS, +} from "./updaterShared"; + +interface UpdaterDialogActions { + downloadAvailableUpdate: (sendToRenderer?: UpdateToastSender) => Promise; + deferUpdateReminder: ( + getMainWindow: GetMainWindow, + sendToRenderer?: UpdateToastSender, + delayMs?: number, + ) => unknown; + skipAvailableUpdateVersion: (sendToRenderer?: UpdateToastSender) => unknown; + installDownloadedUpdateNow: (sendToRenderer?: UpdateToastSender) => void; +} + +export async function showNoUpdatesDialog(getMainWindow: GetMainWindow) { + await showMessageBox(getMainWindow, { + type: "info", + title: "No Updates Available", + message: "Recordly is up to date.", + detail: "You are already running the latest version.", + }); +} + +export async function showUpdateErrorDialog(getMainWindow: GetMainWindow, error: unknown) { + await showMessageBox(getMainWindow, { + type: "error", + title: "Update Check Failed", + message: "Recordly could not check for updates.", + detail: String(error), + }); +} + +export async function showAvailableUpdateDialog( + getMainWindow: GetMainWindow, + version: string, + sendToRenderer: UpdateToastSender | undefined, + actions: UpdaterDialogActions, +) { + const result = await showMessageBox(getMainWindow, { + type: "info", + title: "Update Available", + message: `Recordly ${version} is available.`, + detail: "Download now, remind me in 3 hours, or skip this version.", + buttons: ["Download Update", "Remind Me in 3 Hours", "Skip This Version"], + defaultId: 0, + cancelId: 1, + noLink: true, + }); + + if (result.response === 0) { + await actions.downloadAvailableUpdate(sendToRenderer); + return; + } + + if (result.response === 1) { + actions.deferUpdateReminder(getMainWindow, sendToRenderer, UPDATE_REMINDER_DELAY_MS); + return; + } + + actions.skipAvailableUpdateVersion(sendToRenderer); +} + +export async function showDownloadedUpdateDialog( + getMainWindow: GetMainWindow, + version: string, + actions: UpdaterDialogActions, + options?: { isPreview?: boolean }, +) { + const isPreview = Boolean(options?.isPreview); + const result = await showMessageBox(getMainWindow, { + type: "info", + title: "Update Ready", + message: isPreview + ? `Recordly ${version} is ready to install.` + : `Recordly ${version} has been downloaded.`, + detail: isPreview + ? "Development preview of the native update prompt. No real update will be installed." + : "Install now, remind me in 3 hours, or skip this version.", + buttons: ["Install Update", "Remind Me in 3 Hours", "Skip This Version"], + defaultId: 0, + cancelId: 1, + noLink: true, + }); + + if (result.response === 0) { + if (isPreview) { + await showMessageBox(getMainWindow, { + type: "info", + title: "Preview Only", + message: "No real update was installed.", + detail: "This was only a manual development preview of the update prompt.", + }); + return; + } + + setImmediate(() => { + actions.installDownloadedUpdateNow(); + }); + return; + } + + if (result.response === 1) { + if (!isPreview) { + actions.deferUpdateReminder(getMainWindow, undefined, UPDATE_REMINDER_DELAY_MS); + } + return; + } + + if (!isPreview) { + actions.skipAvailableUpdateVersion(); + } +} \ No newline at end of file diff --git a/electron/updaterEventHandlers.ts b/electron/updaterEventHandlers.ts new file mode 100644 index 00000000..69f7b4f8 --- /dev/null +++ b/electron/updaterEventHandlers.ts @@ -0,0 +1,188 @@ +import { app } from "electron"; +import { autoUpdater } from "electron-updater"; +import { + clearDeferredReminderTimer, + clearDevPreviewProgressTimer, + clearVisibleUpdateToast, + createAutoCheckErrorToastPayload, + createAvailableUpdateToastPayload, + createDownloadedUpdateToastPayload, + createDownloadingUpdateToastPayload, + createUpdateErrorToastPayload, + emitUpdateToastState, + getUpdateCheckIntervalMs, + setUpdateStatusSummary, + shouldSurfaceAutomaticCheckErrors, + type GetMainWindow, + type UpdateToastSender, + updaterState, + writeUpdaterLog, +} from "./updaterShared"; + +interface UpdaterEventHandlerDialogs { + showNoUpdatesDialog: (getMainWindow: GetMainWindow) => Promise; + showUpdateErrorDialog: (getMainWindow: GetMainWindow, error: unknown) => Promise; + showAvailableUpdateDialog: ( + getMainWindow: GetMainWindow, + version: string, + sendToRenderer: UpdateToastSender | undefined, + ) => Promise; + showDownloadedUpdateDialog: (getMainWindow: GetMainWindow, version: string) => Promise; +} + +export function registerAutoUpdaterEventHandlers( + getMainWindow: GetMainWindow, + sendToRenderer: UpdateToastSender, + dialogs: UpdaterEventHandlerDialogs, + checkForAppUpdates: (getMainWindow: GetMainWindow, options?: { manual?: boolean }) => Promise, +) { + autoUpdater.on("checking-for-update", () => { + setUpdateStatusSummary({ + status: "checking", + availableVersion: null, + detail: "Checking for updates...", + }); + writeUpdaterLog("electron-updater emitted checking-for-update."); + }); + + autoUpdater.on("update-available", (info) => { + writeUpdaterLog(`Update available: version=${info.version}`); + updaterState.updateCheckInProgress = false; + updaterState.availableVersion = info.version; + updaterState.pendingDownloadedVersion = null; + updaterState.downloadInProgress = false; + updaterState.downloadToastDismissed = false; + setUpdateStatusSummary({ + status: "available", + availableVersion: info.version, + detail: `Recordly ${info.version} is available.`, + }); + if (updaterState.skippedVersion === info.version) { + updaterState.manualCheckRequested = false; + return; + } + + const payload = createAvailableUpdateToastPayload(info.version); + if (emitUpdateToastState(sendToRenderer, payload)) { + updaterState.manualCheckRequested = false; + return; + } + + if (updaterState.manualCheckRequested) { + void dialogs.showAvailableUpdateDialog(getMainWindow, info.version, sendToRenderer); + updaterState.manualCheckRequested = false; + } + }); + + autoUpdater.on("update-not-available", () => { + writeUpdaterLog("No update available."); + updaterState.updateCheckInProgress = false; + updaterState.availableVersion = null; + updaterState.pendingDownloadedVersion = null; + updaterState.downloadInProgress = false; + updaterState.downloadToastDismissed = false; + setUpdateStatusSummary({ + status: "up-to-date", + availableVersion: null, + detail: `Recordly ${app.getVersion()} is up to date.`, + }); + clearVisibleUpdateToast(sendToRenderer); + const shouldReport = updaterState.manualCheckRequested; + updaterState.manualCheckRequested = false; + if (shouldReport) { + void dialogs.showNoUpdatesDialog(getMainWindow); + } + }); + + autoUpdater.on("download-progress", (progress) => { + if (!updaterState.availableVersion) { + return; + } + + updaterState.downloadInProgress = true; + setUpdateStatusSummary({ + status: "downloading", + availableVersion: updaterState.availableVersion, + detail: `Downloading Recordly ${updaterState.availableVersion}`, + }); + writeUpdaterLog( + `Download progress for ${updaterState.availableVersion}: ${progress.percent.toFixed(1)}%`, + ); + if (updaterState.downloadToastDismissed) { + return; + } + + emitUpdateToastState( + sendToRenderer, + createDownloadingUpdateToastPayload(updaterState.availableVersion, progress.percent), + ); + }); + + autoUpdater.on("error", (error) => { + updaterState.updateCheckInProgress = false; + const shouldReport = updaterState.manualCheckRequested; + updaterState.manualCheckRequested = false; + if (!updaterState.downloadInProgress) { + updaterState.updateCheckErrorHandled = true; + } + setUpdateStatusSummary({ + status: "error", + availableVersion: updaterState.availableVersion, + detail: String(error), + }); + writeUpdaterLog("electron-updater emitted error.", error); + console.error("Auto-updater error:", error); + if (updaterState.downloadInProgress && updaterState.availableVersion) { + updaterState.downloadInProgress = false; + updaterState.downloadToastDismissed = false; + emitUpdateToastState( + sendToRenderer, + createUpdateErrorToastPayload(updaterState.availableVersion, error), + ); + } + if (shouldReport) { + void dialogs.showUpdateErrorDialog(getMainWindow, error); + } else if (shouldSurfaceAutomaticCheckErrors()) { + emitUpdateToastState(sendToRenderer, createAutoCheckErrorToastPayload()); + } + }); + + autoUpdater.on("update-downloaded", (info) => { + writeUpdaterLog(`Update downloaded: version=${info.version}`); + updaterState.updateCheckInProgress = false; + updaterState.manualCheckRequested = false; + updaterState.downloadInProgress = false; + updaterState.downloadToastDismissed = false; + if (updaterState.skippedVersion === info.version) { + return; + } + updaterState.availableVersion = info.version; + updaterState.pendingDownloadedVersion = info.version; + setUpdateStatusSummary({ + status: "ready", + availableVersion: info.version, + detail: `Recordly ${info.version} is ready to install.`, + }); + clearDeferredReminderTimer(); + + if (emitUpdateToastState(sendToRenderer, createDownloadedUpdateToastPayload(info.version))) { + return; + } + + void dialogs.showDownloadedUpdateDialog(getMainWindow, info.version); + }); + + void checkForAppUpdates(getMainWindow); + updaterState.periodicCheckTimer = setInterval(() => { + void checkForAppUpdates(getMainWindow); + }, getUpdateCheckIntervalMs()); + + app.on("before-quit", () => { + clearDeferredReminderTimer(); + clearDevPreviewProgressTimer(); + if (updaterState.periodicCheckTimer) { + clearInterval(updaterState.periodicCheckTimer); + updaterState.periodicCheckTimer = null; + } + }); +} \ No newline at end of file diff --git a/electron/updaterShared.ts b/electron/updaterShared.ts new file mode 100644 index 00000000..c1caa8eb --- /dev/null +++ b/electron/updaterShared.ts @@ -0,0 +1,269 @@ +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 { USER_DATA_PATH } from "./appPaths"; + +const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; +export const UPDATE_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000; +export const DISMISSED_READY_REMINDER_DELAY_MS = 5 * 60 * 1000; +export const AUTO_UPDATES_DISABLED = process.env.RECORDLY_DISABLE_AUTO_UPDATES === "1"; +const AUTO_UPDATE_ERROR_TOASTS_DISABLED = + process.env.RECORDLY_DISABLE_AUTO_UPDATE_ERROR_TOASTS === "1"; +const UPDATE_FEED_URL_OVERRIDE = process.env.RECORDLY_UPDATE_FEED_URL?.trim() ?? ""; +export const UPDATER_LOG_PATH = + process.env.RECORDLY_UPDATER_LOG_PATH?.trim() || path.join(USER_DATA_PATH, "updater.log"); +export const DEV_UPDATE_PREVIEW_VERSION = "9.9.9"; +export const DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS = 300; +export const DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT = 20; + +export type UpdateToastPhase = "available" | "downloading" | "ready" | "error"; + +export type UpdateStatusKind = + | "idle" + | "checking" + | "up-to-date" + | "available" + | "downloading" + | "ready" + | "error"; + +export interface UpdateStatusSummary { + status: UpdateStatusKind; + currentVersion: string; + availableVersion: string | null; + detail?: string; +} + +export interface UpdateToastPayload { + version: string; + detail: string; + phase: UpdateToastPhase; + delayMs: number; + isPreview?: boolean; + progressPercent?: number; + primaryAction?: "download-update" | "install-update" | "retry-check"; +} + +export type UpdateToastSender = ( + channel: "update-toast-state", + payload: UpdateToastPayload | null, +) => boolean; + +export type GetMainWindow = () => BrowserWindow | null; + +export interface UpdaterState { + updaterInitialized: boolean; + updateCheckInProgress: boolean; + manualCheckRequested: boolean; + periodicCheckTimer: NodeJS.Timeout | null; + deferredReminderTimer: NodeJS.Timeout | null; + devPreviewProgressTimer: NodeJS.Timeout | null; + currentToastPayload: UpdateToastPayload | null; + availableVersion: string | null; + pendingDownloadedVersion: string | null; + downloadInProgress: boolean; + downloadToastDismissed: boolean; + skippedVersion: string | null; + updateCheckErrorHandled: boolean; + activeUpdateToastSender?: UpdateToastSender; + updateStatusSummary: UpdateStatusSummary; +} + +export const updaterState: UpdaterState = { + updaterInitialized: false, + updateCheckInProgress: false, + manualCheckRequested: false, + periodicCheckTimer: null, + deferredReminderTimer: null, + devPreviewProgressTimer: null, + currentToastPayload: null, + availableVersion: null, + pendingDownloadedVersion: null, + downloadInProgress: false, + downloadToastDismissed: false, + skippedVersion: null, + updateCheckErrorHandled: false, + activeUpdateToastSender: undefined, + updateStatusSummary: { + status: "idle", + currentVersion: app.getVersion(), + availableVersion: null, + }, +}; + +export function getUpdateCheckIntervalMs() { + return UPDATE_CHECK_INTERVAL_MS; +} + +export function setUpdateStatusSummary(summary: Partial) { + updaterState.updateStatusSummary = { + ...updaterState.updateStatusSummary, + currentVersion: app.getVersion(), + ...summary, + }; +} + +export function summarizeError(error: unknown) { + if (error instanceof Error) { + return error.stack || `${error.name}: ${error.message}`; + } + + return String(error); +} + +export function writeUpdaterLog(message: string, detail?: unknown) { + try { + fs.mkdirSync(path.dirname(UPDATER_LOG_PATH), { recursive: true }); + const suffix = detail === undefined ? "" : ` ${summarizeError(detail)}`; + fs.appendFileSync( + UPDATER_LOG_PATH, + `${new Date().toISOString()} ${message}${suffix}\n`, + "utf8", + ); + } catch (logError) { + console.error("Failed to write updater log:", logError); + } +} + +export function createAutoCheckErrorToastPayload(): UpdateToastPayload { + return { + version: app.getVersion(), + phase: "error", + detail: "Recordly could not check for updates automatically. Retry now, or inspect updater.log in your user data folder.", + delayMs: UPDATE_REMINDER_DELAY_MS, + primaryAction: "retry-check", + }; +} + +export function shouldSurfaceAutomaticCheckErrors() { + return !AUTO_UPDATE_ERROR_TOASTS_DISABLED; +} + +export function configureUpdateFeed() { + if (!UPDATE_FEED_URL_OVERRIDE) { + writeUpdaterLog("Using published GitHub update feed."); + return; + } + + autoUpdater.setFeedURL({ + provider: "generic", + url: UPDATE_FEED_URL_OVERRIDE, + channel: "latest", + }); + writeUpdaterLog(`Using overridden update feed: ${UPDATE_FEED_URL_OVERRIDE}`); +} + +export function canUseAutoUpdates() { + return !AUTO_UPDATES_DISABLED && app.isPackaged && !process.mas; +} + +export function isAutoUpdateFeatureEnabled() { + return !AUTO_UPDATES_DISABLED; +} + +export function getDialogWindow(getMainWindow: GetMainWindow) { + const window = getMainWindow(); + return window && !window.isDestroyed() ? window : undefined; +} + +export function showMessageBox( + getMainWindow: GetMainWindow, + options: MessageBoxOptions, +): Promise { + const window = getDialogWindow(getMainWindow); + return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options); +} + +export function clearDeferredReminderTimer() { + if (updaterState.deferredReminderTimer) { + clearTimeout(updaterState.deferredReminderTimer); + updaterState.deferredReminderTimer = null; + } +} + +export function clearDevPreviewProgressTimer() { + if (updaterState.devPreviewProgressTimer) { + clearInterval(updaterState.devPreviewProgressTimer); + updaterState.devPreviewProgressTimer = null; + } +} + +export function emitUpdateToastState( + sendToRenderer: UpdateToastSender | undefined, + payload: UpdateToastPayload | null, +) { + updaterState.currentToastPayload = payload; + if (!sendToRenderer) { + return false; + } + + return sendToRenderer("update-toast-state", payload); +} + +export 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.", + delayMs: UPDATE_REMINDER_DELAY_MS, + primaryAction: "download-update", + }; +} + +export function createDownloadingUpdateToastPayload( + version: string, + progressPercent = 0, +): UpdateToastPayload { + const normalizedProgress = Math.max(0, Math.min(100, progressPercent)); + return { + version, + phase: "downloading", + detail: + normalizedProgress >= 100 + ? "Finishing the update download. You can keep using Recordly while this completes." + : `Downloading the update in the foreground: ${normalizedProgress.toFixed(0)}% complete.`, + delayMs: UPDATE_REMINDER_DELAY_MS, + progressPercent: normalizedProgress, + }; +} + +export 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.", + delayMs: UPDATE_REMINDER_DELAY_MS, + primaryAction: "install-update", + }; +} + +export function createUpdateErrorToastPayload( + version: string, + error: unknown, +): UpdateToastPayload { + return { + version, + phase: "error", + detail: `The update download failed. ${String(error)}`, + delayMs: UPDATE_REMINDER_DELAY_MS, + primaryAction: "download-update", + }; +} + +export function getReminderPayload(): UpdateToastPayload | null { + if (updaterState.pendingDownloadedVersion) { + return createDownloadedUpdateToastPayload(updaterState.pendingDownloadedVersion); + } + + if (updaterState.availableVersion && !updaterState.downloadInProgress) { + return createAvailableUpdateToastPayload(updaterState.availableVersion); + } + + return null; +} + +export function clearVisibleUpdateToast(sendToRenderer?: UpdateToastSender) { + emitUpdateToastState(sendToRenderer, null); +} \ No newline at end of file diff --git a/electron/windowShared.ts b/electron/windowShared.ts new file mode 100644 index 00000000..876993b8 --- /dev/null +++ b/electron/windowShared.ts @@ -0,0 +1,46 @@ +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { app, type BrowserWindow } from "electron"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const nodeRequire = createRequire(import.meta.url); + +const APP_ROOT = path.join(__dirname, ".."); + +export const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; +export const RENDERER_DIST = path.join(APP_ROOT, "dist"); +export const PRELOAD_PATH = path.join(__dirname, "preload.mjs"); +export const WINDOW_ICON_PATH = path.join( + process.env.VITE_PUBLIC || RENDERER_DIST, + "app-icons", + "recordly-512.png", +); + +export 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; +} + +export function loadRendererWindow( + window: BrowserWindow, + windowType: string, + query: Record = {}, +) { + const fullQuery = { windowType, ...query }; + + if (VITE_DEV_SERVER_URL) { + const searchParams = new URLSearchParams(fullQuery); + void window.loadURL(`${VITE_DEV_SERVER_URL}?${searchParams.toString()}`); + return; + } + + void window.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: fullQuery, + }); +} \ No newline at end of file diff --git a/electron/windows.ts b/electron/windows.ts index 819c2b5c..081fccd9 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,937 +1,16 @@ -import fs from "node:fs"; -import { createRequire } from "node:module"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { app, BrowserWindow, ipcMain } from "electron"; -import { USER_DATA_PATH } from "./appPaths"; -import { getPackagedRendererBaseUrl } from "./rendererServer"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const nodeRequire = createRequire(import.meta.url); - -const APP_ROOT = path.join(__dirname, ".."); -const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; -const RENDERER_DIST = path.join(APP_ROOT, "dist"); -const WINDOW_ICON_PATH = path.join( - process.env.VITE_PUBLIC || RENDERER_DIST, - "app-icons", - "recordly-512.png", -); - -let hudOverlayWindow: BrowserWindow | null = null; -let hudOverlayHiddenFromCapture = true; -let hudOverlayCaptureProtectionLoaded = false; -let countdownWindow: BrowserWindow | null = null; -let updateToastWindow: BrowserWindow | null = null; - -const HUD_OVERLAY_SETTINGS_FILE = path.join(USER_DATA_PATH, "hud-overlay-settings.json"); -const HUD_BOTTOM_CLEARANCE_CM = 3.5; -const DIP_PER_INCH = 96; -const CM_PER_INCH = 2.54; -const HUD_EDGE_MARGIN_DIP = 16; -const HUD_SHADOW_BLEED_DIP = 36; -const HUD_MIN_WINDOW_WIDTH = 560; -const HUD_COMPACT_HEIGHT = 96; -const HUD_MIN_EXPANDED_HEIGHT = 520 + HUD_SHADOW_BLEED_DIP; -const UPDATE_TOAST_WIDTH = 420; -const UPDATE_TOAST_HEIGHT = 212; -const UPDATE_TOAST_GAP_DIP = 18; - -let hudOverlayExpanded = false; -let hudOverlayCompactWidth = HUD_MIN_WINDOW_WIDTH; -let hudOverlayCompactHeight = HUD_COMPACT_HEIGHT; -let hudOverlayExpandedHeight = HUD_MIN_EXPANDED_HEIGHT; - -function getEditorWindowQuery(): Record { - const query: Record = { - windowType: "editor", - }; - - if (process.env.RECORDLY_SMOKE_EXPORT === "1") { - query.smokeExport = "1"; - if (process.env.RECORDLY_SMOKE_EXPORT_INPUT) { - query.smokeInput = process.env.RECORDLY_SMOKE_EXPORT_INPUT; - } - if (process.env.RECORDLY_SMOKE_EXPORT_OUTPUT) { - query.smokeOutput = process.env.RECORDLY_SMOKE_EXPORT_OUTPUT; - } - if (process.env.RECORDLY_SMOKE_EXPORT_USE_NATIVE === "1") { - query.smokeUseNativeExport = "1"; - } - if (process.env.RECORDLY_SMOKE_EXPORT_ENCODING_MODE) { - query.smokeEncodingMode = process.env.RECORDLY_SMOKE_EXPORT_ENCODING_MODE; - } - if (process.env.RECORDLY_SMOKE_EXPORT_SHADOW_INTENSITY) { - query.smokeShadowIntensity = process.env.RECORDLY_SMOKE_EXPORT_SHADOW_INTENSITY; - } - if (process.env.RECORDLY_SMOKE_EXPORT_WEBCAM_INPUT) { - query.smokeWebcamInput = process.env.RECORDLY_SMOKE_EXPORT_WEBCAM_INPUT; - } - if (process.env.RECORDLY_SMOKE_EXPORT_WEBCAM_SHADOW) { - query.smokeWebcamShadow = process.env.RECORDLY_SMOKE_EXPORT_WEBCAM_SHADOW; - } - if (process.env.RECORDLY_SMOKE_EXPORT_WEBCAM_SIZE) { - query.smokeWebcamSize = process.env.RECORDLY_SMOKE_EXPORT_WEBCAM_SIZE; - } - if (process.env.RECORDLY_SMOKE_EXPORT_PIPELINE) { - query.smokePipelineModel = process.env.RECORDLY_SMOKE_EXPORT_PIPELINE; - } - if (process.env.RECORDLY_SMOKE_EXPORT_BACKEND) { - query.smokeBackendPreference = process.env.RECORDLY_SMOKE_EXPORT_BACKEND; - } - if (process.env.RECORDLY_SMOKE_EXPORT_MAX_ENCODE_QUEUE) { - query.smokeMaxEncodeQueue = process.env.RECORDLY_SMOKE_EXPORT_MAX_ENCODE_QUEUE; - } - if (process.env.RECORDLY_SMOKE_EXPORT_MAX_DECODE_QUEUE) { - query.smokeMaxDecodeQueue = process.env.RECORDLY_SMOKE_EXPORT_MAX_DECODE_QUEUE; - } - if (process.env.RECORDLY_SMOKE_EXPORT_MAX_PENDING_FRAMES) { - query.smokeMaxPendingFrames = process.env.RECORDLY_SMOKE_EXPORT_MAX_PENDING_FRAMES; - } - } - - return query; -} - -function isHudOverlayCaptureProtectionSupported(): boolean { - return process.platform !== "linux"; -} - -function getWindowsBuildNumber(): number | null { - if (process.platform !== "win32") { - return null; - } - - const build = Number.parseInt(os.release().split(".")[2] ?? "", 10); - return Number.isFinite(build) ? build : null; -} - -export function isHudOverlayMousePassthroughSupported(): boolean { - if (process.platform === "linux") { - return false; - } - - const build = getWindowsBuildNumber(); - if (build !== null && build < 22000) { - return false; - } - - return true; -} - -function loadHudOverlayCaptureProtectionSetting(): boolean { - if (hudOverlayCaptureProtectionLoaded) { - return hudOverlayHiddenFromCapture; - } - - hudOverlayCaptureProtectionLoaded = true; - - try { - if (!fs.existsSync(HUD_OVERLAY_SETTINGS_FILE)) { - return hudOverlayHiddenFromCapture; - } - - const raw = fs.readFileSync(HUD_OVERLAY_SETTINGS_FILE, "utf-8"); - const parsed = JSON.parse(raw) as { hiddenFromCapture?: unknown }; - if (typeof parsed.hiddenFromCapture === "boolean") { - hudOverlayHiddenFromCapture = parsed.hiddenFromCapture; - } - } catch { - // Ignore settings read failures and fall back to defaults. - } - - return hudOverlayHiddenFromCapture; -} - -function persistHudOverlayCaptureProtectionSetting(enabled: boolean): void { - try { - fs.writeFileSync( - HUD_OVERLAY_SETTINGS_FILE, - JSON.stringify({ hiddenFromCapture: enabled }, null, 2), - "utf-8", - ); - } catch { - // Ignore settings write failures and keep runtime state working. - } -} - -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; -} - -function getHudOverlayDisplay() { - const hudWindow = getHudOverlayWindow(); - if (hudWindow) { - return getScreen().getDisplayMatching(hudWindow.getBounds()); - } - return getScreen().getPrimaryDisplay(); -} - -function getHudOverlayBounds(expanded: boolean) { - const { bounds, workArea } = getHudOverlayDisplay(); - const maxWindowWidth = Math.max(HUD_MIN_WINDOW_WIDTH, workArea.width - HUD_EDGE_MARGIN_DIP * 2); - const windowWidth = Math.min( - maxWindowWidth, - Math.max(HUD_MIN_WINDOW_WIDTH, Math.round(hudOverlayCompactWidth)), - ); - const maxWindowHeight = Math.max(HUD_COMPACT_HEIGHT, workArea.height - HUD_EDGE_MARGIN_DIP * 2); - const desiredHeight = expanded - ? Math.max(HUD_MIN_EXPANDED_HEIGHT, Math.round(hudOverlayExpandedHeight)) - : Math.max(HUD_COMPACT_HEIGHT, Math.round(hudOverlayCompactHeight)); - const windowHeight = Math.min(maxWindowHeight, desiredHeight); - const bottomClearanceDip = Math.round((HUD_BOTTOM_CLEARANCE_CM / CM_PER_INCH) * DIP_PER_INCH); - const screenBottom = bounds.y + bounds.height; - const workAreaBottom = workArea.y + workArea.height; - const preferredBottom = screenBottom - bottomClearanceDip; - const maximumSafeBottom = workAreaBottom - HUD_EDGE_MARGIN_DIP; - const windowBottom = Math.min(preferredBottom, maximumSafeBottom); - - const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); - const y = Math.max(workArea.y + HUD_EDGE_MARGIN_DIP, Math.floor(windowBottom - windowHeight)); - - return { - x, - y, - width: windowWidth, - height: windowHeight, - }; -} - -function applyHudOverlayBounds(expanded: boolean) { - if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { - return; - } - - hudOverlayExpanded = expanded; - - const computed = getHudOverlayBounds(expanded); - - if (hudUserPosition) { - // Resize in-place at the user's dragged position, clamped so the - // window stays fully within the current display's work area. - const { workArea } = getHudOverlayDisplay(); - const x = Math.max( - workArea.x, - Math.min(hudUserPosition.x, workArea.x + workArea.width - computed.width), - ); - const y = Math.max( - workArea.y, - Math.min(hudUserPosition.y, workArea.y + workArea.height - computed.height), - ); - hudOverlayWindow.setBounds({ x, y, width: computed.width, height: computed.height }, false); - } else { - hudOverlayWindow.setBounds(computed, false); - } - - positionUpdateToastWindow(); - if (!hudOverlayWindow.isVisible()) { - return; - } - hudOverlayWindow.moveTop(); -} - -function getUpdateToastBounds() { - const hudWindow = getHudOverlayWindow(); - if (hudWindow) { - const hudBounds = hudWindow.getBounds(); - const display = getScreen().getDisplayMatching(hudBounds); - const x = Math.round(hudBounds.x + (hudBounds.width - UPDATE_TOAST_WIDTH) / 2); - const y = Math.max( - display.workArea.y + HUD_EDGE_MARGIN_DIP, - hudBounds.y - UPDATE_TOAST_HEIGHT - UPDATE_TOAST_GAP_DIP, - ); - - return { - x, - y, - width: UPDATE_TOAST_WIDTH, - height: UPDATE_TOAST_HEIGHT, - }; - } - - const primaryDisplay = getScreen().getPrimaryDisplay(); - const { workArea } = primaryDisplay; - return { - x: Math.round(workArea.x + (workArea.width - UPDATE_TOAST_WIDTH) / 2), - y: workArea.y + HUD_EDGE_MARGIN_DIP, - width: UPDATE_TOAST_WIDTH, - height: UPDATE_TOAST_HEIGHT, - }; -} - -function positionUpdateToastWindow() { - if (!updateToastWindow || updateToastWindow.isDestroyed()) { - return; - } - - updateToastWindow.setBounds(getUpdateToastBounds(), false); - updateToastWindow.moveTop(); -} - -ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => { - if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - if (!isHudOverlayMousePassthroughSupported()) { - hudOverlayWindow.setIgnoreMouseEvents(false); - return; - } - - if (ignore) { - hudOverlayWindow.setIgnoreMouseEvents(true, { forward: true }); - return; - } - - hudOverlayWindow.setIgnoreMouseEvents(false); - } -}); - -// When the user drags the HUD, remember their chosen position so that -// subsequent size changes (e.g. idle → recording UI swap) resize in-place -// instead of snapping back to the default centered location. -let hudUserPosition: { x: number; y: number } | null = null; -let hudDragOffset: { x: number; y: number } | null = null; -let hudDragLastCursor: { x: number; y: number } | null = null; -let hudDragFixedSize: { width: number; height: number } | null = null; - -ipcMain.on("hud-overlay-drag", (_event, phase: string, screenX: number, screenY: number) => { - if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) return; - - if (phase === "start") { - const bounds = hudOverlayWindow.getBounds(); - hudDragOffset = { x: screenX - bounds.x, y: screenY - bounds.y }; - hudDragLastCursor = { x: screenX, y: screenY }; - hudDragFixedSize = { width: bounds.width, height: bounds.height }; - } else if (phase === "move" && hudDragOffset) { - if ( - hudDragLastCursor && - hudDragLastCursor.x === screenX && - hudDragLastCursor.y === screenY - ) { - return; - } - - hudDragLastCursor = { x: screenX, y: screenY }; - const targetX = Math.round(screenX - hudDragOffset.x); - const targetY = Math.round(screenY - hudDragOffset.y); - const fixedWidth = hudDragFixedSize?.width ?? hudOverlayWindow.getBounds().width; - const fixedHeight = hudDragFixedSize?.height ?? hudOverlayWindow.getBounds().height; - hudOverlayWindow.setBounds( - { - x: targetX, - y: targetY, - width: fixedWidth, - height: fixedHeight, - }, - false, - ); - } else if (phase === "end") { - const finalBounds = hudOverlayWindow.getBounds(); - hudUserPosition = { x: finalBounds.x, y: finalBounds.y }; - - hudDragOffset = null; - hudDragLastCursor = null; - hudDragFixedSize = null; - } -}); - -ipcMain.on("hud-overlay-hide", () => { - if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - hudOverlayWindow.minimize(); - } -}); - -ipcMain.on("set-hud-overlay-expanded", (_event, expanded: boolean) => { - applyHudOverlayBounds(Boolean(expanded)); -}); - -ipcMain.on("set-hud-overlay-compact-width", (_event, width: number) => { - if (!Number.isFinite(width)) { - return; - } - - const maxWindowWidth = Math.max( - HUD_MIN_WINDOW_WIDTH, - getHudOverlayDisplay().workArea.width - HUD_EDGE_MARGIN_DIP * 2, - ); - const nextWidth = Math.min(maxWindowWidth, Math.max(HUD_MIN_WINDOW_WIDTH, Math.round(width))); - - if (nextWidth === hudOverlayCompactWidth) { - return; - } - - hudOverlayCompactWidth = nextWidth; - applyHudOverlayBounds(hudOverlayExpanded); -}); - -ipcMain.on("set-hud-overlay-measured-height", (_event, height: number, expanded: boolean) => { - if (!Number.isFinite(height)) { - return; - } - - const maxWindowHeight = Math.max( - HUD_COMPACT_HEIGHT, - getHudOverlayDisplay().workArea.height - HUD_EDGE_MARGIN_DIP * 2, - ); - const nextHeight = Math.min(maxWindowHeight, Math.max(HUD_COMPACT_HEIGHT, Math.round(height))); - - if (expanded) { - if (nextHeight === hudOverlayExpandedHeight) { - return; - } - hudOverlayExpandedHeight = Math.max(HUD_MIN_EXPANDED_HEIGHT, nextHeight); - } else { - if (nextHeight === hudOverlayCompactHeight) { - return; - } - hudOverlayCompactHeight = nextHeight; - } - - applyHudOverlayBounds(hudOverlayExpanded); -}); - -ipcMain.handle("get-hud-overlay-capture-protection", () => { - const enabled = loadHudOverlayCaptureProtectionSetting(); - - return { - success: true, - enabled, - }; -}); - -ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean) => { - loadHudOverlayCaptureProtectionSetting(); - hudOverlayHiddenFromCapture = Boolean(enabled); - persistHudOverlayCaptureProtectionSetting(hudOverlayHiddenFromCapture); - - if ( - isHudOverlayCaptureProtectionSupported() && - hudOverlayWindow && - !hudOverlayWindow.isDestroyed() - ) { - hudOverlayWindow.setContentProtection(hudOverlayHiddenFromCapture); - } - - return { - success: true, - enabled: hudOverlayHiddenFromCapture, - }; -}); - -export function createHudOverlayWindow(): BrowserWindow { - loadHudOverlayCaptureProtectionSetting(); - const initialBounds = getHudOverlayBounds(false); - - const win = new BrowserWindow({ - width: initialBounds.width, - height: initialBounds.height, - minWidth: HUD_MIN_WINDOW_WIDTH, - minHeight: HUD_COMPACT_HEIGHT, - maxHeight: Math.max( - HUD_COMPACT_HEIGHT, - getHudOverlayDisplay().workArea.height - HUD_EDGE_MARGIN_DIP * 2, - ), - x: initialBounds.x, - y: initialBounds.y, - frame: false, - transparent: true, - resizable: false, - alwaysOnTop: true, - skipTaskbar: true, - hasShadow: false, - show: false, - webPreferences: { - preload: path.join(__dirname, "preload.mjs"), - nodeIntegration: false, - contextIsolation: true, - webSecurity: false, - backgroundThrottling: false, - }, - }); - - if (isHudOverlayCaptureProtectionSupported()) { - win.setContentProtection(hudOverlayHiddenFromCapture); - } - - if (isHudOverlayMousePassthroughSupported()) { - win.setIgnoreMouseEvents(true, { forward: true }); - } - - // On Windows 11+, focus changes (e.g. showing a native notification) can break - // setIgnoreMouseEvents forwarding on a transparent always-on-top window, making - // it permanently click-through without hover detection. Re-initialise the - // pass-through-with-forwarding state whenever the window gains focus by toggling - // the flag off then back on so the native WS_EX_TRANSPARENT flag is fully reset. - // On Windows 10 (build < 22000) passthrough is disabled entirely, so skip this. - if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { - win.on("focus", () => { - if (!win.isDestroyed()) { - win.setIgnoreMouseEvents(false); - setTimeout(() => { - if (!win.isDestroyed()) { - win.setIgnoreMouseEvents(true, { forward: true }); - } - }, 50); - } - }); - } - - win.webContents.on("did-finish-load", () => { - win?.webContents.send("main-process-message", new Date().toLocaleString()); - setTimeout(() => { - if (!win.isDestroyed()) { - win.show(); - win.moveTop(); - if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { - win.setIgnoreMouseEvents(false); - setTimeout(() => { - if (!win.isDestroyed()) { - win.setIgnoreMouseEvents(true, { forward: true }); - } - }, 50); - } - } - }, 100); - }); - - // Safety net: on Linux the renderer may fail to fire did-finish-load - // (e.g. GPU/VAAPI errors). Show the window after ready-to-show as fallback. - win.once("ready-to-show", () => { - setTimeout(() => { - if (!win.isDestroyed() && !win.isVisible()) { - win.show(); - win.moveTop(); - } - }, 500); - }); - - hudOverlayWindow = win; - - // Reset the user's saved HUD position when displays change so the bar - // doesn't end up stranded off-screen after a monitor is disconnected. - const screen = getScreen(); - const handleDisplayRemoved = () => { - hudUserPosition = null; - }; - const handleDisplayMetricsChanged = () => { - if (hudUserPosition) { - const displays = screen.getAllDisplays(); - const onScreen = displays.some( - (d) => - hudUserPosition!.x >= d.workArea.x && - hudUserPosition!.x < d.workArea.x + d.workArea.width && - hudUserPosition!.y >= d.workArea.y && - hudUserPosition!.y < d.workArea.y + d.workArea.height, - ); - if (!onScreen) { - hudUserPosition = null; - } - } - applyHudOverlayBounds(hudOverlayExpanded); - }; - screen.on("display-removed", handleDisplayRemoved); - screen.on("display-metrics-changed", handleDisplayMetricsChanged); - - win.on("closed", () => { - screen.removeListener("display-removed", handleDisplayRemoved); - screen.removeListener("display-metrics-changed", handleDisplayMetricsChanged); - if (hudOverlayWindow === win) { - hudOverlayWindow = null; - } - }); - - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL + "?windowType=hud-overlay"); - } else { - win.loadFile(path.join(RENDERER_DIST, "index.html"), { - query: { windowType: "hud-overlay" }, - }); - } - - return win; -} - -export function getHudOverlayWindow(): BrowserWindow | null { - return hudOverlayWindow && !hudOverlayWindow.isDestroyed() ? hudOverlayWindow : null; -} - -export function createUpdateToastWindow(): BrowserWindow { - const initialBounds = getUpdateToastBounds(); - const parentWindow = - process.platform === "darwin" && hudOverlayWindow && !hudOverlayWindow.isDestroyed() - ? hudOverlayWindow - : undefined; - const useTransparentToastWindow = process.platform !== "win32"; - - const win = new BrowserWindow({ - width: initialBounds.width, - height: initialBounds.height, - x: initialBounds.x, - y: initialBounds.y, - frame: false, - transparent: useTransparentToastWindow, - resizable: false, - alwaysOnTop: true, - skipTaskbar: true, - hasShadow: false, - show: false, - focusable: true, - ...(parentWindow ? { parent: parentWindow } : {}), - backgroundColor: useTransparentToastWindow ? "#00000000" : "#101418", - webPreferences: { - preload: path.join(__dirname, "preload.mjs"), - nodeIntegration: false, - contextIsolation: true, - backgroundThrottling: false, - }, - }); - - if (process.platform === "darwin") { - win.setAlwaysOnTop(true, "status"); - } - - win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - updateToastWindow = win; - - win.on("closed", () => { - if (updateToastWindow === win) { - updateToastWindow = null; - } - }); - - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL + "?windowType=update-toast"); - } else { - win.loadFile(path.join(RENDERER_DIST, "index.html"), { - query: { windowType: "update-toast" }, - }); - } - - return win; -} - -export function getUpdateToastWindow(): BrowserWindow | null { - return updateToastWindow && !updateToastWindow.isDestroyed() ? updateToastWindow : null; -} - -export function showUpdateToastWindow(): BrowserWindow { - const win = getUpdateToastWindow() ?? createUpdateToastWindow(); - positionUpdateToastWindow(); - if (!win.isVisible()) { - if (process.platform === "win32") { - win.show(); - win.moveTop(); - } else { - win.showInactive(); - } - } else { - win.moveTop(); - } - - return win; -} - -export function hideUpdateToastWindow(): void { - if (!updateToastWindow || updateToastWindow.isDestroyed()) { - return; - } - - updateToastWindow.hide(); -} - -function loadPackagedEditorWindow(win: BrowserWindow) { - const query = getEditorWindowQuery(); - const queryString = new URLSearchParams(query).toString(); - const indexHtmlPath = path.join(RENDERER_DIST, "index.html"); - const packagedRendererBaseUrl = getPackagedRendererBaseUrl(); - const webContents = win.webContents; - - const loadFromFile = () => { - if (win.isDestroyed()) { - return; - } - - console.log("[editor-window] load-file", indexHtmlPath); - void win.loadFile(indexHtmlPath, { query }); - }; - - if (!packagedRendererBaseUrl) { - loadFromFile(); - return; - } - - const targetUrl = `${packagedRendererBaseUrl}/?${queryString}`; - let settled = false; - let timeoutId: NodeJS.Timeout | null = setTimeout(() => { - fallbackToFile("load-timeout"); - }, 5000); - - const clearTimeoutIfNeeded = () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - }; - - const detachLoadListeners = () => { - clearTimeoutIfNeeded(); - if (webContents.isDestroyed()) { - return; - } - - webContents.removeListener("did-fail-load", handleDidFailLoad); - webContents.removeListener("did-finish-load", handleDidFinishLoad); - }; - - const fallbackToFile = (reason: string, details?: Record) => { - if (settled || win.isDestroyed()) { - return; - } - - settled = true; - detachLoadListeners(); - console.warn("[editor-window] packaged renderer URL failed, falling back to file", { - reason, - targetUrl, - ...details, - }); - loadFromFile(); - }; - - const handleDidFailLoad = ( - _event: Electron.Event, - errorCode: number, - errorDescription: string, - validatedURL: string, - isMainFrame: boolean, - ) => { - if (!isMainFrame || validatedURL !== targetUrl) { - return; - } - - fallbackToFile("did-fail-load", { - errorCode, - errorDescription, - validatedURL, - }); - }; - - const handleDidFinishLoad = () => { - if (webContents.getURL() !== targetUrl) { - return; - } - - settled = true; - detachLoadListeners(); - }; - - webContents.on("did-fail-load", handleDidFailLoad); - webContents.on("did-finish-load", handleDidFinishLoad); - win.once("closed", clearTimeoutIfNeeded); - - console.log("[editor-window] load-url", targetUrl); - void win.loadURL(targetUrl).catch((error) => { - fallbackToFile("load-url-rejected", { - error: error instanceof Error ? error.message : String(error), - }); - }); -} - -export function createEditorWindow(): BrowserWindow { - const isMac = process.platform === "darwin"; - const { workArea, workAreaSize } = getScreen().getPrimaryDisplay(); - const initialWidth = isMac ? Math.round(workAreaSize.width * 0.85) : workArea.width; - const initialHeight = isMac ? Math.round(workAreaSize.height * 0.85) : workArea.height; - - const win = new BrowserWindow({ - width: initialWidth, - height: initialHeight, - ...(!isMac && { - x: workArea.x, - y: workArea.y, - }), - minWidth: 800, - minHeight: 600, - ...(process.platform !== "darwin" && { - icon: WINDOW_ICON_PATH, - }), - ...(isMac && { - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 12, y: 12 }, - }), - autoHideMenuBar: !isMac, - transparent: false, - resizable: true, - alwaysOnTop: false, - skipTaskbar: false, - title: "Recordly", - show: false, - backgroundColor: "#000000", - webPreferences: { - preload: path.join(__dirname, "preload.mjs"), - nodeIntegration: false, - contextIsolation: true, - webSecurity: false, - backgroundThrottling: false, - }, - }); - - win.once("ready-to-show", () => { - console.log("[editor-window] ready-to-show"); - win.show(); - }); - - win.webContents.on("did-finish-load", () => { - console.log("[editor-window] did-finish-load", win.webContents.getURL()); - win?.webContents.send("main-process-message", new Date().toLocaleString()); - // Fallback for Linux/Wayland where `ready-to-show` may not fire reliably. - if (!win.isDestroyed() && !win.isVisible()) { - console.log("[editor-window] forcing show after did-finish-load"); - win.show(); - } - }); - - win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => { - console.error("[editor-window] did-fail-load", { - errorCode, - errorDescription, - validatedURL, - }); - }); - - win.webContents.on("render-process-gone", (_event, details) => { - console.error("[editor-window] render-process-gone", details); - }); - - win.on("show", () => { - console.log("[editor-window] show"); - }); - - win.on("focus", () => { - console.log("[editor-window] focus"); - }); - - if (VITE_DEV_SERVER_URL) { - const query = new URLSearchParams(getEditorWindowQuery()); - win.loadURL(`${VITE_DEV_SERVER_URL}?${query.toString()}`); - } else { - loadPackagedEditorWindow(win); - } - - return win; -} - -export function createSourceSelectorWindow(): BrowserWindow { - const { width, height } = getScreen().getPrimaryDisplay().workAreaSize; - - const win = new BrowserWindow({ - width: 620, - height: 420, - minHeight: 350, - maxHeight: 500, - x: Math.round((width - 620) / 2), - y: Math.round((height - 420) / 2), - frame: false, - resizable: false, - alwaysOnTop: true, - transparent: true, - show: false, - ...(process.platform !== "darwin" && { - icon: WINDOW_ICON_PATH, - }), - backgroundColor: "#00000000", - webPreferences: { - preload: path.join(__dirname, "preload.mjs"), - nodeIntegration: false, - contextIsolation: true, - }, - }); - - win.webContents.on("did-finish-load", () => { - setTimeout(() => { - if (!win.isDestroyed()) { - win.show(); - } - }, 100); - }); - - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector"); - } else { - win.loadFile(path.join(RENDERER_DIST, "index.html"), { - query: { windowType: "source-selector" }, - }); - } - - return win; -} - -export function createCountdownWindow(): BrowserWindow { - const primaryDisplay = getScreen().getPrimaryDisplay(); - const { width, height } = primaryDisplay.workAreaSize; - - const windowSize = 200; - const x = Math.floor((width - windowSize) / 2); - const y = Math.floor((height - windowSize) / 2); - - const win = new BrowserWindow({ - width: windowSize, - height: windowSize, - x: x, - y: y, - frame: false, - transparent: true, - resizable: false, - alwaysOnTop: true, - skipTaskbar: true, - hasShadow: false, - focusable: true, - show: false, - webPreferences: { - preload: path.join(__dirname, "preload.mjs"), - nodeIntegration: false, - contextIsolation: true, - }, - }); - - countdownWindow = win; - - win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - - win.webContents.on("did-finish-load", () => { - if (!win.isDestroyed()) { - win.show(); - } - }); - - win.on("closed", () => { - if (countdownWindow === win) { - countdownWindow = null; - } - }); - - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL + "?windowType=countdown"); - } else { - win.loadFile(path.join(RENDERER_DIST, "index.html"), { - query: { windowType: "countdown" }, - }); - } - - return win; -} - -export function getCountdownWindow(): BrowserWindow | null { - return countdownWindow; -} - -export function closeCountdownWindow(): void { - if (countdownWindow && !countdownWindow.isDestroyed()) { - countdownWindow.close(); - countdownWindow = null; - } -} +export { + createCountdownWindow, + createEditorWindow, + createSourceSelectorWindow, + getCountdownWindow, + closeCountdownWindow, +} from "./editorWindows"; +export { + createHudOverlayWindow, + createUpdateToastWindow, + getHudOverlayWindow, + getUpdateToastWindow, + hideUpdateToastWindow, + isHudOverlayMousePassthroughSupported, + showUpdateToastWindow, +} from "./hudWindows"; diff --git a/scripts/benchmark-export-queues.mjs b/scripts/benchmark-export-queues.mjs index 92bd72ee..5f23f4db 100644 --- a/scripts/benchmark-export-queues.mjs +++ b/scripts/benchmark-export-queues.mjs @@ -1,843 +1,107 @@ -import { execFile, spawn } from "node:child_process"; -import { once } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; + import electron from "electron"; import ffmpegStatic from "ffmpeg-static"; -const execFileAsync = promisify(execFile); -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(__dirname, ".."); -const mainEntry = path.join(repoRoot, "dist-electron", "main.js"); -const rendererEntry = path.join(repoRoot, "dist", "index.html"); +import { buildBenchmarkRequests, createBenchmarkConfig } from "./benchmark-export-queues/config.mjs"; +import { createFixtureVideo, ensureBuildArtifacts } from "./benchmark-export-queues/fixtures.mjs"; +import { + calculateDelta, + printBackendDetailTable, + printDeltaTable, + printRequestedConfigTable, + printTimingSummaryTable, +} from "./benchmark-export-queues/reporting.mjs"; +import { runBenchmarkRequest } from "./benchmark-export-queues/runner.mjs"; -const width = parseEvenInteger(process.env.RECORDLY_BENCH_EXPORT_WIDTH ?? "1280", "Width"); -const height = parseEvenInteger(process.env.RECORDLY_BENCH_EXPORT_HEIGHT ?? "720", "Height"); -const frameRate = parsePositiveInteger(process.env.RECORDLY_BENCH_EXPORT_FPS ?? "60", "Frame rate"); -const durationSeconds = parsePositiveInteger( - process.env.RECORDLY_BENCH_EXPORT_DURATION ?? "15", - "Duration", -); -const timeoutMs = parsePositiveInteger( - process.env.RECORDLY_BENCH_EXPORT_TIMEOUT_MS ?? "180000", - "Timeout", -); -const runsPerVariant = parsePositiveInteger(process.env.RECORDLY_BENCH_EXPORT_RUNS ?? "2", "Runs"); -const useNativeExport = process.env.RECORDLY_BENCH_EXPORT_USE_NATIVE === "1"; -const useWebcamOverlay = process.env.RECORDLY_BENCH_EXPORT_ENABLE_WEBCAM === "1"; -const exportEncodingMode = parseExportEncodingMode( - process.env.RECORDLY_BENCH_EXPORT_ENCODING_MODE ?? null, -); -const exportShadowIntensity = parseExportShadowIntensity( - process.env.RECORDLY_BENCH_EXPORT_SHADOW_INTENSITY ?? null, -); -const webcamWidth = parseEvenInteger( - process.env.RECORDLY_BENCH_EXPORT_WEBCAM_WIDTH ?? "640", - "Webcam width", -); -const webcamHeight = parseEvenInteger( - process.env.RECORDLY_BENCH_EXPORT_WEBCAM_HEIGHT ?? "360", - "Webcam height", -); -const webcamShadowIntensity = parseExportShadowIntensity( - process.env.RECORDLY_BENCH_EXPORT_WEBCAM_SHADOW ?? null, -); -const webcamSize = parseExportWebcamSize(process.env.RECORDLY_BENCH_EXPORT_WEBCAM_SIZE ?? null); -const MODERN_BACKEND_SWEEP = ["auto", "webcodecs", "breeze"]; -const exportPipeline = parseExportPipeline(process.env.RECORDLY_BENCH_EXPORT_PIPELINE ?? null); -const exportBackend = parseExportBackend(process.env.RECORDLY_BENCH_EXPORT_BACKEND ?? null); -const exportBackendList = parseExportBackendList( - process.env.RECORDLY_BENCH_EXPORT_BACKENDS ?? null, -); - -const VARIANT_PRESETS = { - adaptive: { name: "adaptive" }, - baseline: { name: "baseline", maxEncodeQueue: 120, maxDecodeQueue: 10, maxPendingFrames: 24 }, - tuned: { name: "tuned", maxEncodeQueue: 240, maxDecodeQueue: 12, maxPendingFrames: 32 }, -}; - -const variantNameList = parseBenchmarkVariantList( - process.env.RECORDLY_BENCH_EXPORT_VARIANTS ?? null, -); - -const variants = variantNameList - ? variantNameList.map((variantName) => VARIANT_PRESETS[variantName]) - : [VARIANT_PRESETS.baseline, VARIANT_PRESETS.tuned]; - -function collectUniqueStrings(values) { - return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))]; -} - -function parsePositiveInteger(rawValue, label) { - const parsed = Number.parseInt(rawValue, 10); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new Error(`${label} must be a positive integer`); - } - - return parsed; -} - -function parseEvenInteger(rawValue, label) { - const parsed = parsePositiveInteger(rawValue, label); - if (parsed % 2 !== 0) { - throw new Error(`${label} must be even`); - } - - return parsed; -} - -function parseExportPipeline(rawValue) { - if (rawValue === null || rawValue === "") { - return null; - } - - if (rawValue === "legacy" || rawValue === "modern") { - return rawValue; - } - - throw new Error("RECORDLY_BENCH_EXPORT_PIPELINE must be 'legacy' or 'modern'"); -} - -function parseExportBackend(rawValue) { - if (rawValue === null || rawValue === "") { - return null; - } - - if (rawValue === "auto" || rawValue === "webcodecs" || rawValue === "breeze") { - return rawValue; - } - - throw new Error("RECORDLY_BENCH_EXPORT_BACKEND must be 'auto', 'webcodecs', or 'breeze'"); -} - -function parseExportBackendList(rawValue) { - if (rawValue === null || rawValue === "") { - return null; - } - - if (rawValue === "all") { - return [...MODERN_BACKEND_SWEEP]; - } - - const values = rawValue - .split(",") - .map((value) => value.trim()) - .filter((value) => value.length > 0) - .map((value) => parseExportBackend(value)) - .filter((value) => value !== null); - - if (values.length === 0) { - throw new Error( - "RECORDLY_BENCH_EXPORT_BACKENDS must include at least one of: auto, webcodecs, breeze", - ); - } - - return [...new Set(values)]; -} - -function parseBenchmarkVariantList(rawValue) { - if (rawValue === null || rawValue === "") { - return null; - } - - const values = rawValue - .split(",") - .map((value) => value.trim()) - .filter((value) => value.length > 0); - - if (values.length === 0) { - throw new Error( - "RECORDLY_BENCH_EXPORT_VARIANTS must include at least one of: adaptive, baseline, tuned", - ); - } - - for (const value of values) { - if (!(value in VARIANT_PRESETS)) { - throw new Error( - "RECORDLY_BENCH_EXPORT_VARIANTS must include only: adaptive, baseline, tuned", - ); - } - } - - return [...new Set(values)]; -} - -function parseExportEncodingMode(rawValue) { - if (rawValue === null || rawValue === "") { - return null; - } - - if (rawValue === "fast" || rawValue === "balanced" || rawValue === "quality") { - return rawValue; - } - - throw new Error("RECORDLY_BENCH_EXPORT_ENCODING_MODE must be 'fast', 'balanced', or 'quality'"); -} - -function parseExportShadowIntensity(rawValue) { - if (rawValue === null || rawValue === "") { - return null; - } - - const parsed = Number.parseFloat(rawValue); - if (!Number.isFinite(parsed) || parsed < 0) { - throw new Error("RECORDLY_BENCH_EXPORT_SHADOW_INTENSITY must be a non-negative number"); - } - - return parsed; -} - -function parseExportWebcamSize(rawValue) { - if (rawValue === null || rawValue === "") { - return null; - } - - const parsed = Number.parseFloat(rawValue); - if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) { - throw new Error("RECORDLY_BENCH_EXPORT_WEBCAM_SIZE must be a number between 0 and 100"); - } - - return parsed; -} - -function summarizeSmokeProgress(progressSamples) { - if (!Array.isArray(progressSamples) || progressSamples.length === 0) { - return null; - } - - const extractingSamples = progressSamples.filter( - (sample) => - sample?.phase === "extracting" && - typeof sample?.currentFrame === "number" && - sample.currentFrame > 1, - ); - const fpsSource = extractingSamples.length > 0 ? extractingSamples : progressSamples; - const renderFpsSamples = fpsSource - .map((sample) => sample?.renderFps) - .filter((value) => typeof value === "number" && Number.isFinite(value)); - const firstSample = progressSamples[0] ?? null; - const lastSample = progressSamples.at(-1) ?? null; - const firstExtractingSample = extractingSamples[0] ?? null; - const lastExtractingSample = extractingSamples.at(-1) ?? null; - - return { - samples: progressSamples.length, - extractingSamples: extractingSamples.length, - firstElapsedMs: typeof firstSample?.elapsedMs === "number" ? firstSample.elapsedMs : null, - lastElapsedMs: typeof lastSample?.elapsedMs === "number" ? lastSample.elapsedMs : null, - firstExtractingElapsedMs: - typeof firstExtractingSample?.elapsedMs === "number" - ? firstExtractingSample.elapsedMs - : null, - lastExtractingElapsedMs: - typeof lastExtractingSample?.elapsedMs === "number" - ? lastExtractingSample.elapsedMs - : null, - firstRenderFps: renderFpsSamples[0] ?? null, - lastRenderFps: renderFpsSamples.at(-1) ?? null, - minRenderFps: renderFpsSamples.length > 0 ? Math.min(...renderFpsSamples) : null, - maxRenderFps: renderFpsSamples.length > 0 ? Math.max(...renderFpsSamples) : null, - }; -} - -async function ensureBuildArtifacts() { - await fs.access(mainEntry); - await fs.access(rendererEntry); -} - -async function createFixtureVideo( - ffmpegPath, - targetPath, - { - fixtureWidth = width, - fixtureHeight = height, - includeAudio = true, - videoFilter = `testsrc2=size=${fixtureWidth}x${fixtureHeight}:rate=${frameRate}`, - } = {}, -) { - const args = ["-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", videoFilter]; - - if (includeAudio) { - args.push( - "-f", - "lavfi", - "-i", - "sine=frequency=880:sample_rate=48000", - "-c:a", - "aac", - "-b:a", - "128k", - ); - } else { - args.push("-an"); - } - - args.push( - "-t", - String(durationSeconds), - "-c:v", - "libx264", - "-preset", - "veryfast", - "-pix_fmt", - "yuv420p", - "-movflags", - "+faststart", - targetPath, - ); - - await execFileAsync(ffmpegPath, args, { - timeout: 60_000, - maxBuffer: 20 * 1024 * 1024, - }); -} - -function parseDurationSeconds(ffmpegOutput) { - const match = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i); - if (!match) { - return null; - } - - return ( - Number.parseInt(match[1], 10) * 3600 + - Number.parseInt(match[2], 10) * 60 + - Number.parseFloat(match[3]) - ); -} - -async function inspectOutput(ffmpegPath, targetPath) { - try { - const { stderr } = await execFileAsync( - ffmpegPath, - ["-hide_banner", "-i", targetPath, "-f", "null", "-"], - { - timeout: 30_000, - maxBuffer: 20 * 1024 * 1024, - }, - ); - return parseDurationSeconds(stderr); - } catch (error) { - return parseDurationSeconds(String(error?.stderr ?? "")); - } -} - -async function readSmokeExportReport(outputPath) { - const reportPath = `${outputPath}.report.json`; - - try { - const reportContent = await fs.readFile(reportPath, "utf8"); - return { - reportPath, - report: JSON.parse(reportContent), - }; - } catch { - return null; - } -} - -function buildBenchmarkRequests() { - if (exportBackendList) { - return exportBackendList.map((backend) => ({ - pipeline: exportPipeline, - backend, - label: backend, - slug: backend, - })); - } - - if (exportBackend) { - return [ - { - pipeline: exportPipeline, - backend: exportBackend, - label: exportBackend, - slug: exportBackend, - }, - ]; - } - - if (exportPipeline === "modern") { - return MODERN_BACKEND_SWEEP.map((backend) => ({ - pipeline: exportPipeline, - backend, - label: backend, - slug: backend, - })); - } - - return [ - { - pipeline: exportPipeline, - backend: null, - label: "default", - slug: "default", - }, - ]; -} - -function formatTableCell(value) { - if (Array.isArray(value)) { - return value.length > 0 ? value.join(", ") : "-"; - } - - if (value === null || value === undefined || value === "") { - return "-"; - } - - return String(value).replace(/\s+/g, " ").trim(); -} - -function printTable(title, columns, rows) { - if (!Array.isArray(rows) || rows.length === 0) { - return; - } - - const formattedRows = rows.map((row) => - columns.map((column) => formatTableCell(column.getValue(row))), - ); - const widths = columns.map((column, columnIndex) => { - const headerWidth = column.header.length; - const rowWidth = Math.max(...formattedRows.map((row) => row[columnIndex].length)); - return Math.max(headerWidth, rowWidth); - }); - const divider = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`; - - console.log(`[benchmark-export-queues] ${title}`); +function logBenchmarkConfig(config, benchmarkRequests) { + console.log("[benchmark-export-queues] Config"); console.log( - `| ${columns - .map((column, columnIndex) => column.header.padEnd(widths[columnIndex])) - .join(" | ")} |`, + JSON.stringify({ + width: config.width, + height: config.height, + frameRate: config.frameRate, + durationSeconds: config.durationSeconds, + timeoutMs: config.timeoutMs, + runsPerVariant: config.runsPerVariant, + requestedPipeline: config.exportPipeline, + requestedBackend: config.exportBackend, + requestedBackends: benchmarkRequests.map((request) => request.label), + backendSweepEnabled: benchmarkRequests.length > 1, + requestedEncodingMode: config.exportEncodingMode, + requestedShadowIntensity: config.exportShadowIntensity, + webcamEnabled: config.useWebcamOverlay, + requestedWebcamShadowIntensity: config.webcamShadowIntensity, + requestedWebcamSize: config.webcamSize, + }), ); - console.log(divider); - for (const row of formattedRows) { - console.log( - `| ${row.map((value, columnIndex) => value.padEnd(widths[columnIndex])).join(" | ")} |`, - ); - } + printRequestedConfigTable(config, benchmarkRequests); } -function formatMs(value) { - return typeof value === "number" && Number.isFinite(value) ? `${Math.round(value)} ms` : "-"; -} - -function formatDeltaMs(value) { - if (typeof value !== "number" || !Number.isFinite(value)) { - return "-"; - } - - const roundedValue = Math.round(value); - return `${roundedValue > 0 ? "+" : ""}${roundedValue} ms`; -} - -function formatPercent(value) { - return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(1)}%` : "-"; -} - -function formatSeconds(value) { - return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(2)} s` : "-"; -} - -function formatMegabytes(value) { - return typeof value === "number" && Number.isFinite(value) - ? `${(value / (1024 * 1024)).toFixed(2)} MB` - : "-"; -} - -function formatBoolean(value) { - return value ? "Yes" : "No"; -} - -function calculateDelta(referenceValue, nextValue) { - if ( - typeof referenceValue !== "number" || - !Number.isFinite(referenceValue) || - typeof nextValue !== "number" || - !Number.isFinite(nextValue) - ) { - return { deltaMs: null, deltaPercent: null }; - } - - return { - deltaMs: nextValue - referenceValue, - deltaPercent: - referenceValue > 0 ? ((nextValue - referenceValue) / referenceValue) * 100 : null, - }; -} - -function buildRequestedConfigRows(benchmarkRequests) { - const rows = [ - { key: "Width", value: width }, - { key: "Height", value: height }, - { key: "Frame rate", value: `${frameRate} FPS` }, - { key: "Duration", value: `${durationSeconds} s` }, - { key: "Timeout", value: formatMs(timeoutMs) }, - { key: "Runs per variant", value: runsPerVariant }, - { key: "Pipeline", value: exportPipeline ?? "default" }, - { key: "Requested backends", value: benchmarkRequests.map((request) => request.label) }, - { key: "Backend sweep", value: formatBoolean(benchmarkRequests.length > 1) }, - { key: "Encoding mode", value: exportEncodingMode ?? "default" }, - { key: "Shadow intensity", value: exportShadowIntensity ?? "default" }, - { key: "Webcam enabled", value: formatBoolean(useWebcamOverlay) }, - { key: "Experimental native override", value: formatBoolean(useNativeExport) }, - ]; - - if (useWebcamOverlay) { - rows.push( - { key: "Webcam width", value: webcamWidth }, - { key: "Webcam height", value: webcamHeight }, - { key: "Webcam shadow", value: webcamShadowIntensity ?? "default" }, - { key: "Webcam size", value: webcamSize ?? "default" }, - ); - } - - return rows; -} - -function printRequestedConfigTable(benchmarkRequests) { - printTable( - "Requested config", - [ - { header: "Setting", getValue: (row) => row.key }, - { header: "Value", getValue: (row) => row.value }, - ], - buildRequestedConfigRows(benchmarkRequests), - ); -} - -function buildTimingTableRows(benchmarkResults) { - return benchmarkResults.flatMap((result) => - result.summaries.map((summary) => ({ - backend: result.request.backend ?? "default", - pipeline: result.request.pipeline ?? "default", - variant: summary.variant.name, - averageElapsedMs: summary.averageElapsedMs, - medianElapsedMs: summary.medianElapsedMs, - averageSmokeElapsedMs: summary.averageSmokeElapsedMs, - minElapsedMs: summary.minElapsedMs, - maxElapsedMs: summary.maxElapsedMs, - averageOutputDurationSeconds: summary.averageOutputDurationSeconds, - averageSizeBytes: summary.averageSizeBytes, - webcamEnabled: summary.webcamEnabled, - })), - ); -} - -function printTimingSummaryTable(benchmarkResults) { - printTable( - "Timing summary", - [ - { header: "Pipeline", getValue: (row) => row.pipeline }, - { header: "Backend", getValue: (row) => row.backend }, - { header: "Variant", getValue: (row) => row.variant }, - { header: "Avg total", getValue: (row) => formatMs(row.averageElapsedMs) }, - { header: "Median total", getValue: (row) => formatMs(row.medianElapsedMs) }, - { header: "Avg export", getValue: (row) => formatMs(row.averageSmokeElapsedMs) }, - { header: "Min", getValue: (row) => formatMs(row.minElapsedMs) }, - { header: "Max", getValue: (row) => formatMs(row.maxElapsedMs) }, - { - header: "Avg output", - getValue: (row) => formatSeconds(row.averageOutputDurationSeconds), - }, - { header: "Avg size", getValue: (row) => formatMegabytes(row.averageSizeBytes) }, - { header: "Webcam", getValue: (row) => formatBoolean(row.webcamEnabled) }, - ], - buildTimingTableRows(benchmarkResults), - ); -} - -function buildBackendDetailTableRows(benchmarkResults) { - return benchmarkResults.flatMap((result) => - result.summaries.map((summary) => ({ - backend: result.request.backend ?? "default", - pipeline: result.request.pipeline ?? "default", - variant: summary.variant.name, - encodeQueue: summary.variant.maxEncodeQueue, - decodeQueue: summary.variant.maxDecodeQueue, - pendingFrames: summary.variant.maxPendingFrames, - observedRenderBackends: summary.observedRenderBackends, - observedEncodeBackends: summary.observedEncodeBackends, - observedEncoders: summary.observedEncoders, - })), - ); -} - -function printBackendDetailTable(benchmarkResults) { - printTable( - "Observed backends", - [ - { header: "Pipeline", getValue: (row) => row.pipeline }, - { header: "Backend", getValue: (row) => row.backend }, - { header: "Variant", getValue: (row) => row.variant }, - { header: "Encode Q", getValue: (row) => row.encodeQueue }, - { header: "Decode Q", getValue: (row) => row.decodeQueue }, - { header: "Pending", getValue: (row) => row.pendingFrames }, - { header: "Render", getValue: (row) => row.observedRenderBackends }, - { header: "Encode", getValue: (row) => row.observedEncodeBackends }, - { header: "Encoder", getValue: (row) => row.observedEncoders }, - ], - buildBackendDetailTableRows(benchmarkResults), - ); -} - -function buildDeltaTableRows(benchmarkResults) { - return benchmarkResults - .map((result) => { - const baseline = result.summaries.find( - (summary) => summary.variant.name === "baseline", - ); - const tuned = result.summaries.find((summary) => summary.variant.name === "tuned"); - if (!baseline || !tuned) { - return null; - } - - const averageDelta = calculateDelta(baseline.averageElapsedMs, tuned.averageElapsedMs); - const medianDelta = calculateDelta(baseline.medianElapsedMs, tuned.medianElapsedMs); - const exportDelta = calculateDelta( - baseline.averageSmokeElapsedMs, - tuned.averageSmokeElapsedMs, - ); - - return { - pipeline: result.request.pipeline ?? "default", - backend: result.request.backend ?? "default", - averageDeltaMs: averageDelta.deltaMs, - averageDeltaPercent: averageDelta.deltaPercent, - medianDeltaMs: medianDelta.deltaMs, - medianDeltaPercent: medianDelta.deltaPercent, - exportDeltaMs: exportDelta.deltaMs, - exportDeltaPercent: exportDelta.deltaPercent, - }; - }) - .filter(Boolean); -} - -function printDeltaTable(benchmarkResults) { - printTable( - "Tuned vs baseline", - [ - { header: "Pipeline", getValue: (row) => row.pipeline }, - { header: "Backend", getValue: (row) => row.backend }, - { - header: "Avg delta", - getValue: (row) => - `${formatDeltaMs(row.averageDeltaMs)} (${formatPercent(row.averageDeltaPercent)})`, - }, - { - header: "Median delta", - getValue: (row) => - `${formatDeltaMs(row.medianDeltaMs)} (${formatPercent(row.medianDeltaPercent)})`, - }, - { - header: "Export delta", - getValue: (row) => - `${formatDeltaMs(row.exportDeltaMs)} (${formatPercent(row.exportDeltaPercent)})`, - }, - ], - buildDeltaTableRows(benchmarkResults), - ); -} - -async function runVariant( - ffmpegPath, - inputPath, - webcamInputPath, - benchmarkRequest, - variant, - runIndex, -) { - const outputPath = path.join( - path.dirname(inputPath), - `${benchmarkRequest.slug}-${variant.name}-${runIndex + 1}-${Date.now()}.mp4`, - ); - const startedAt = performance.now(); - const runLabel = `${benchmarkRequest.label}/${variant.name}#${runIndex + 1}`; - const child = spawn(electron, [repoRoot], { - cwd: repoRoot, - env: { - ...process.env, - RECORDLY_SMOKE_EXPORT: "1", - RECORDLY_SMOKE_EXPORT_INPUT: inputPath, - RECORDLY_SMOKE_EXPORT_OUTPUT: outputPath, - ...(useNativeExport ? { RECORDLY_SMOKE_EXPORT_USE_NATIVE: "1" } : {}), - ...(exportEncodingMode - ? { RECORDLY_SMOKE_EXPORT_ENCODING_MODE: exportEncodingMode } - : {}), - ...(exportShadowIntensity !== null - ? { RECORDLY_SMOKE_EXPORT_SHADOW_INTENSITY: String(exportShadowIntensity) } - : {}), - ...(webcamInputPath ? { RECORDLY_SMOKE_EXPORT_WEBCAM_INPUT: webcamInputPath } : {}), - ...(webcamShadowIntensity !== null - ? { RECORDLY_SMOKE_EXPORT_WEBCAM_SHADOW: String(webcamShadowIntensity) } - : {}), - ...(webcamSize !== null - ? { RECORDLY_SMOKE_EXPORT_WEBCAM_SIZE: String(webcamSize) } - : {}), - ...(benchmarkRequest.pipeline - ? { RECORDLY_SMOKE_EXPORT_PIPELINE: benchmarkRequest.pipeline } - : {}), - ...(benchmarkRequest.backend - ? { RECORDLY_SMOKE_EXPORT_BACKEND: benchmarkRequest.backend } - : {}), - ...(typeof variant.maxEncodeQueue === "number" - ? { RECORDLY_SMOKE_EXPORT_MAX_ENCODE_QUEUE: String(variant.maxEncodeQueue) } - : {}), - ...(typeof variant.maxDecodeQueue === "number" - ? { RECORDLY_SMOKE_EXPORT_MAX_DECODE_QUEUE: String(variant.maxDecodeQueue) } - : {}), - ...(typeof variant.maxPendingFrames === "number" - ? { RECORDLY_SMOKE_EXPORT_MAX_PENDING_FRAMES: String(variant.maxPendingFrames) } - : {}), - }, - stdio: ["ignore", "pipe", "pipe"], - }); - - let combinedOutput = ""; - child.stdout.on("data", (chunk) => { - const text = chunk.toString(); - combinedOutput += text; - process.stdout.write(`[${runLabel}] ${text}`); - }); - child.stderr.on("data", (chunk) => { - const text = chunk.toString(); - combinedOutput += text; - process.stderr.write(`[${runLabel}] ${text}`); - }); - - const timeout = setTimeout(() => { - child.kill("SIGKILL"); - }, timeoutMs); - - const [exitCode, signal] = await once(child, "close"); - clearTimeout(timeout); - - if (exitCode !== 0) { - const signalText = signal ? ` (signal ${signal})` : ""; - throw new Error( - `${variant.name} run ${runIndex + 1} failed with code ${exitCode ?? "unknown"}${signalText}\n${combinedOutput.trim()}`, - ); - } - - const smokeExportReport = await readSmokeExportReport(outputPath); - let outputStats; - try { - outputStats = await fs.stat(outputPath); - } catch (error) { - const reportSuffix = smokeExportReport - ? `\n${JSON.stringify(smokeExportReport.report)}` - : ""; - throw new Error( - `${variant.name} run ${runIndex + 1} did not produce an output file: ${error instanceof Error ? error.message : String(error)}${reportSuffix}`, - ); - } - if (outputStats.size <= 0) { - const reportSuffix = smokeExportReport - ? `\n${JSON.stringify(smokeExportReport.report)}` - : ""; - throw new Error( - `${variant.name} run ${runIndex + 1} produced an empty output file${reportSuffix}`, - ); - } - - const elapsedMs = Math.round(performance.now() - startedAt); - const outputDuration = await inspectOutput(ffmpegPath, outputPath); - - return { - elapsedMs, - outputPath, - sizeBytes: outputStats.size, - outputDuration, - webcamEnabled: !!webcamInputPath, - smokeExportReport: smokeExportReport?.report ?? null, - smokeProgressSummary: summarizeSmokeProgress(smokeExportReport?.report?.progressSamples), - }; -} - -async function runBenchmarkRequest(ffmpegPath, inputPath, webcamInputPath, benchmarkRequest) { - const summaries = []; - for (const variant of variants) { - const runs = []; - for (let index = 0; index < runsPerVariant; index += 1) { +function printJsonSummary(benchmarkResults, config) { + console.log("[benchmark-export-queues] Summary"); + for (const result of benchmarkResults) { + for (const summary of result.summaries) { console.log( - `[benchmark-export-queues] Running ${benchmarkRequest.label}/${variant.name} (${index + 1}/${runsPerVariant}) with encode=${variant.maxEncodeQueue ?? "auto"} decode=${variant.maxDecodeQueue ?? "auto"} pending=${variant.maxPendingFrames ?? "auto"}`, - ); - runs.push( - await runVariant( - ffmpegPath, - inputPath, - webcamInputPath, - benchmarkRequest, - variant, - index, - ), + JSON.stringify({ + requestedPipeline: result.request.pipeline, + requestedBackend: result.request.backend, + name: summary.variant.name, + webcamEnabled: config.useWebcamOverlay, + webcamShadowIntensity: config.webcamShadowIntensity, + webcamSize: config.webcamSize, + maxEncodeQueue: summary.variant.maxEncodeQueue, + maxDecodeQueue: summary.variant.maxDecodeQueue, + maxPendingFrames: summary.variant.maxPendingFrames, + averageElapsedMs: summary.averageElapsedMs, + medianElapsedMs: summary.medianElapsedMs, + minElapsedMs: summary.minElapsedMs, + maxElapsedMs: summary.maxElapsedMs, + averageSizeBytes: summary.averageSizeBytes, + averageOutputDurationSeconds: summary.averageOutputDurationSeconds, + averageSmokeElapsedMs: summary.averageSmokeElapsedMs, + observedRenderBackends: summary.observedRenderBackends, + observedEncodeBackends: summary.observedEncodeBackends, + observedEncoders: summary.observedEncoders, + runs: summary.runs.map((run) => ({ + elapsedMs: run.elapsedMs, + sizeBytes: run.sizeBytes, + outputDuration: run.outputDuration, + smokeExportReport: run.smokeExportReport, + smokeProgressSummary: run.smokeProgressSummary, + })), + }), ); } - - const runSummary = summarizeVariantRuns(runs); - summaries.push({ - variant, - runs, - ...runSummary, - webcamEnabled: useWebcamOverlay, - }); } - - return { - request: benchmarkRequest, - summaries, - }; } -function average(values) { - return values.reduce((sum, value) => sum + value, 0) / values.length; -} +function printVariantComparisons(benchmarkResults) { + for (const result of benchmarkResults) { + if (result.summaries.length < 2) { + continue; + } -function median(values) { - if (values.length === 0) { - return 0; + const baseline = result.summaries[0]; + const tuned = result.summaries[1]; + const { deltaMs, deltaPercent } = calculateDelta( + baseline.averageElapsedMs, + tuned.averageElapsedMs, + ); + const { deltaMs: medianDeltaMs, deltaPercent: medianPercent } = calculateDelta( + baseline.medianElapsedMs, + tuned.medianElapsedMs, + ); + const backendLabel = result.request.backend ?? "default"; + console.log( + `[benchmark-export-queues] ${backendLabel} tuned vs baseline: ${deltaMs}ms (${typeof deltaPercent === "number" ? deltaPercent.toFixed(1) : "-"}%)`, + ); + console.log( + `[benchmark-export-queues] ${backendLabel} tuned vs baseline (median): ${medianDeltaMs}ms (${typeof medianPercent === "number" ? medianPercent.toFixed(1) : "-"}%)`, + ); } - - const sorted = [...values].sort((left, right) => left - right); - const middleIndex = Math.floor(sorted.length / 2); - if (sorted.length % 2 === 0) { - return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2; - } - - return sorted[middleIndex]; -} - -function summarizeVariantRuns(runs) { - const elapsedValues = runs.map((run) => run.elapsedMs); - const sizeValues = runs.map((run) => run.sizeBytes); - const outputDurationValues = runs - .map((run) => run.outputDuration) - .filter((value) => typeof value === "number" && Number.isFinite(value)); - const smokeElapsedValues = runs - .map((run) => run.smokeExportReport?.elapsedMs) - .filter((value) => typeof value === "number" && Number.isFinite(value)); - - return { - averageElapsedMs: Math.round(average(elapsedValues)), - medianElapsedMs: Math.round(median(elapsedValues)), - minElapsedMs: Math.min(...elapsedValues), - maxElapsedMs: Math.max(...elapsedValues), - averageSizeBytes: Math.round(average(sizeValues)), - averageOutputDurationSeconds: - outputDurationValues.length > 0 ? average(outputDurationValues) : null, - averageSmokeElapsedMs: - smokeElapsedValues.length > 0 ? Math.round(average(smokeElapsedValues)) : null, - observedRenderBackends: collectUniqueStrings( - runs.map((run) => run.smokeExportReport?.metrics?.renderBackend), - ), - observedEncodeBackends: collectUniqueStrings( - runs.map((run) => run.smokeExportReport?.metrics?.encodeBackend), - ), - observedEncoders: collectUniqueStrings( - runs.map((run) => run.smokeExportReport?.metrics?.encoderName), - ), - }; } async function main() { @@ -849,47 +113,35 @@ async function main() { throw new Error("The Electron binary is unavailable in this workspace"); } - await ensureBuildArtifacts(); - const benchmarkRequests = buildBenchmarkRequests(); + const config = createBenchmarkConfig(); + await ensureBuildArtifacts(config); + const benchmarkRequests = buildBenchmarkRequests(config); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-export-queue-bench-")); const inputPath = path.join(tempDir, "input.mp4"); - const webcamInputPath = useWebcamOverlay ? path.join(tempDir, "webcam.mp4") : null; + const webcamInputPath = config.useWebcamOverlay ? path.join(tempDir, "webcam.mp4") : null; try { - console.log("[benchmark-export-queues] Config"); - console.log( - JSON.stringify({ - width, - height, - frameRate, - durationSeconds, - timeoutMs, - runsPerVariant, - requestedPipeline: exportPipeline, - requestedBackend: exportBackend, - requestedBackends: benchmarkRequests.map((request) => request.label), - backendSweepEnabled: benchmarkRequests.length > 1, - requestedEncodingMode: exportEncodingMode, - requestedShadowIntensity: exportShadowIntensity, - webcamEnabled: useWebcamOverlay, - requestedWebcamShadowIntensity: webcamShadowIntensity, - requestedWebcamSize: webcamSize, - }), - ); - printRequestedConfigTable(benchmarkRequests); + logBenchmarkConfig(config, benchmarkRequests); console.log(`[benchmark-export-queues] Generating fixture video: ${inputPath}`); - await createFixtureVideo(ffmpegStatic, inputPath); + await createFixtureVideo(ffmpegStatic, inputPath, { + durationSeconds: config.durationSeconds, + frameRate: config.frameRate, + fixtureWidth: config.width, + fixtureHeight: config.height, + }); if (webcamInputPath) { console.log( `[benchmark-export-queues] Generating webcam fixture video: ${webcamInputPath}`, ); await createFixtureVideo(ffmpegStatic, webcamInputPath, { - fixtureWidth: webcamWidth, - fixtureHeight: webcamHeight, + durationSeconds: config.durationSeconds, + frameRate: config.frameRate, + fixtureWidth: config.webcamWidth, + fixtureHeight: config.webcamHeight, includeAudio: false, - videoFilter: `testsrc=size=${webcamWidth}x${webcamHeight}:rate=${frameRate}`, + videoFilter: `testsrc=size=${config.webcamWidth}x${config.webcamHeight}:rate=${config.frameRate}`, }); } @@ -897,76 +149,21 @@ async function main() { for (const benchmarkRequest of benchmarkRequests) { benchmarkResults.push( await runBenchmarkRequest( + electron, ffmpegStatic, inputPath, webcamInputPath, benchmarkRequest, + config, ), ); } - console.log("[benchmark-export-queues] Summary"); - for (const result of benchmarkResults) { - for (const summary of result.summaries) { - console.log( - JSON.stringify({ - requestedPipeline: result.request.pipeline, - requestedBackend: result.request.backend, - name: summary.variant.name, - webcamEnabled: useWebcamOverlay, - webcamShadowIntensity, - webcamSize, - maxEncodeQueue: summary.variant.maxEncodeQueue, - maxDecodeQueue: summary.variant.maxDecodeQueue, - maxPendingFrames: summary.variant.maxPendingFrames, - averageElapsedMs: summary.averageElapsedMs, - medianElapsedMs: summary.medianElapsedMs, - minElapsedMs: summary.minElapsedMs, - maxElapsedMs: summary.maxElapsedMs, - averageSizeBytes: summary.averageSizeBytes, - averageOutputDurationSeconds: summary.averageOutputDurationSeconds, - averageSmokeElapsedMs: summary.averageSmokeElapsedMs, - observedRenderBackends: summary.observedRenderBackends, - observedEncodeBackends: summary.observedEncodeBackends, - observedEncoders: summary.observedEncoders, - runs: summary.runs.map((run) => ({ - elapsedMs: run.elapsedMs, - sizeBytes: run.sizeBytes, - outputDuration: run.outputDuration, - smokeExportReport: run.smokeExportReport, - smokeProgressSummary: run.smokeProgressSummary, - })), - }), - ); - } - } + printJsonSummary(benchmarkResults, config); printTimingSummaryTable(benchmarkResults); printBackendDetailTable(benchmarkResults); printDeltaTable(benchmarkResults); - - for (const result of benchmarkResults) { - if (result.summaries.length < 2) { - continue; - } - - const baseline = result.summaries[0]; - const tuned = result.summaries[1]; - const { deltaMs, deltaPercent: percent } = calculateDelta( - baseline.averageElapsedMs, - tuned.averageElapsedMs, - ); - const { deltaMs: medianDeltaMs, deltaPercent: medianPercent } = calculateDelta( - baseline.medianElapsedMs, - tuned.medianElapsedMs, - ); - const backendLabel = result.request.backend ?? "default"; - console.log( - `[benchmark-export-queues] ${backendLabel} tuned vs baseline: ${deltaMs}ms (${typeof percent === "number" ? percent.toFixed(1) : "-"}%)`, - ); - console.log( - `[benchmark-export-queues] ${backendLabel} tuned vs baseline (median): ${medianDeltaMs}ms (${typeof medianPercent === "number" ? medianPercent.toFixed(1) : "-"}%)`, - ); - } + printVariantComparisons(benchmarkResults); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } @@ -977,4 +174,4 @@ main().catch((error) => { `[benchmark-export-queues] ${error instanceof Error ? error.message : String(error)}`, ); process.exitCode = 1; -}); +}); \ No newline at end of file diff --git a/scripts/benchmark-export-queues/config.mjs b/scripts/benchmark-export-queues/config.mjs new file mode 100644 index 00000000..4e87c3d8 --- /dev/null +++ b/scripts/benchmark-export-queues/config.mjs @@ -0,0 +1,253 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const MODERN_BACKEND_SWEEP = ["auto", "webcodecs", "breeze"]; +const VARIANT_PRESETS = { + adaptive: { name: "adaptive" }, + baseline: { name: "baseline", maxEncodeQueue: 120, maxDecodeQueue: 10, maxPendingFrames: 24 }, + tuned: { name: "tuned", maxEncodeQueue: 240, maxDecodeQueue: 12, maxPendingFrames: 32 }, +}; + +function parsePositiveInteger(rawValue, label) { + const parsed = Number.parseInt(rawValue, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer`); + } + + return parsed; +} + +function parseEvenInteger(rawValue, label) { + const parsed = parsePositiveInteger(rawValue, label); + if (parsed % 2 !== 0) { + throw new Error(`${label} must be even`); + } + + return parsed; +} + +function parseExportPipeline(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + if (rawValue === "legacy" || rawValue === "modern") { + return rawValue; + } + + throw new Error("RECORDLY_BENCH_EXPORT_PIPELINE must be 'legacy' or 'modern'"); +} + +function parseExportBackend(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + if (rawValue === "auto" || rawValue === "webcodecs" || rawValue === "breeze") { + return rawValue; + } + + throw new Error("RECORDLY_BENCH_EXPORT_BACKEND must be 'auto', 'webcodecs', or 'breeze'"); +} + +function parseExportBackendList(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + if (rawValue === "all") { + return [...MODERN_BACKEND_SWEEP]; + } + + const values = rawValue + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map((value) => parseExportBackend(value)) + .filter((value) => value !== null); + + if (values.length === 0) { + throw new Error( + "RECORDLY_BENCH_EXPORT_BACKENDS must include at least one of: auto, webcodecs, breeze", + ); + } + + return [...new Set(values)]; +} + +function parseBenchmarkVariantList(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + const values = rawValue + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + + if (values.length === 0) { + throw new Error( + "RECORDLY_BENCH_EXPORT_VARIANTS must include at least one of: adaptive, baseline, tuned", + ); + } + + for (const value of values) { + if (!(value in VARIANT_PRESETS)) { + throw new Error( + "RECORDLY_BENCH_EXPORT_VARIANTS must include only: adaptive, baseline, tuned", + ); + } + } + + return [...new Set(values)]; +} + +function parseExportEncodingMode(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + if (rawValue === "fast" || rawValue === "balanced" || rawValue === "quality") { + return rawValue; + } + + throw new Error("RECORDLY_BENCH_EXPORT_ENCODING_MODE must be 'fast', 'balanced', or 'quality'"); +} + +function parseExportShadowIntensity(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + const parsed = Number.parseFloat(rawValue); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error("RECORDLY_BENCH_EXPORT_SHADOW_INTENSITY must be a non-negative number"); + } + + return parsed; +} + +function parseExportWebcamSize(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + const parsed = Number.parseFloat(rawValue); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) { + throw new Error("RECORDLY_BENCH_EXPORT_WEBCAM_SIZE must be a number between 0 and 100"); + } + + return parsed; +} + +export function createBenchmarkConfig(env = process.env) { + const repoRoot = path.resolve(__dirname, "..", ".."); + const mainEntry = path.join(repoRoot, "dist-electron", "main.js"); + const rendererEntry = path.join(repoRoot, "dist", "index.html"); + const width = parseEvenInteger(env.RECORDLY_BENCH_EXPORT_WIDTH ?? "1280", "Width"); + const height = parseEvenInteger(env.RECORDLY_BENCH_EXPORT_HEIGHT ?? "720", "Height"); + const frameRate = parsePositiveInteger(env.RECORDLY_BENCH_EXPORT_FPS ?? "60", "Frame rate"); + const durationSeconds = parsePositiveInteger( + env.RECORDLY_BENCH_EXPORT_DURATION ?? "15", + "Duration", + ); + const timeoutMs = parsePositiveInteger( + env.RECORDLY_BENCH_EXPORT_TIMEOUT_MS ?? "180000", + "Timeout", + ); + const runsPerVariant = parsePositiveInteger(env.RECORDLY_BENCH_EXPORT_RUNS ?? "2", "Runs"); + const useNativeExport = env.RECORDLY_BENCH_EXPORT_USE_NATIVE === "1"; + const useWebcamOverlay = env.RECORDLY_BENCH_EXPORT_ENABLE_WEBCAM === "1"; + const exportEncodingMode = parseExportEncodingMode( + env.RECORDLY_BENCH_EXPORT_ENCODING_MODE ?? null, + ); + const exportShadowIntensity = parseExportShadowIntensity( + env.RECORDLY_BENCH_EXPORT_SHADOW_INTENSITY ?? null, + ); + const webcamWidth = parseEvenInteger( + env.RECORDLY_BENCH_EXPORT_WEBCAM_WIDTH ?? "640", + "Webcam width", + ); + const webcamHeight = parseEvenInteger( + env.RECORDLY_BENCH_EXPORT_WEBCAM_HEIGHT ?? "360", + "Webcam height", + ); + const webcamShadowIntensity = parseExportShadowIntensity( + env.RECORDLY_BENCH_EXPORT_WEBCAM_SHADOW ?? null, + ); + const webcamSize = parseExportWebcamSize(env.RECORDLY_BENCH_EXPORT_WEBCAM_SIZE ?? null); + const exportPipeline = parseExportPipeline(env.RECORDLY_BENCH_EXPORT_PIPELINE ?? null); + const exportBackend = parseExportBackend(env.RECORDLY_BENCH_EXPORT_BACKEND ?? null); + const exportBackendList = parseExportBackendList(env.RECORDLY_BENCH_EXPORT_BACKENDS ?? null); + const variantNameList = parseBenchmarkVariantList( + env.RECORDLY_BENCH_EXPORT_VARIANTS ?? null, + ); + + return { + repoRoot, + mainEntry, + rendererEntry, + width, + height, + frameRate, + durationSeconds, + timeoutMs, + runsPerVariant, + useNativeExport, + useWebcamOverlay, + exportEncodingMode, + exportShadowIntensity, + webcamWidth, + webcamHeight, + webcamShadowIntensity, + webcamSize, + exportPipeline, + exportBackend, + exportBackendList, + variants: variantNameList + ? variantNameList.map((variantName) => VARIANT_PRESETS[variantName]) + : [VARIANT_PRESETS.baseline, VARIANT_PRESETS.tuned], + }; +} + +export function buildBenchmarkRequests(config) { + if (config.exportBackendList) { + return config.exportBackendList.map((backend) => ({ + pipeline: config.exportPipeline, + backend, + label: backend, + slug: backend, + })); + } + + if (config.exportBackend) { + return [ + { + pipeline: config.exportPipeline, + backend: config.exportBackend, + label: config.exportBackend, + slug: config.exportBackend, + }, + ]; + } + + if (config.exportPipeline === "modern") { + return MODERN_BACKEND_SWEEP.map((backend) => ({ + pipeline: config.exportPipeline, + backend, + label: backend, + slug: backend, + })); + } + + return [ + { + pipeline: config.exportPipeline, + backend: null, + label: "default", + slug: "default", + }, + ]; +} \ No newline at end of file diff --git a/scripts/benchmark-export-queues/fixtures.mjs b/scripts/benchmark-export-queues/fixtures.mjs new file mode 100644 index 00000000..7322117f --- /dev/null +++ b/scripts/benchmark-export-queues/fixtures.mjs @@ -0,0 +1,99 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export async function ensureBuildArtifacts(config) { + await fs.access(config.mainEntry); + await fs.access(config.rendererEntry); +} + +export async function createFixtureVideo(ffmpegPath, targetPath, options) { + const { + durationSeconds, + frameRate, + fixtureWidth, + fixtureHeight, + includeAudio = true, + videoFilter = `testsrc2=size=${fixtureWidth}x${fixtureHeight}:rate=${frameRate}`, + } = options; + const args = ["-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", videoFilter]; + + if (includeAudio) { + args.push( + "-f", + "lavfi", + "-i", + "sine=frequency=880:sample_rate=48000", + "-c:a", + "aac", + "-b:a", + "128k", + ); + } else { + args.push("-an"); + } + + args.push( + "-t", + String(durationSeconds), + "-c:v", + "libx264", + "-preset", + "veryfast", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + targetPath, + ); + + await execFileAsync(ffmpegPath, args, { + timeout: 60_000, + maxBuffer: 20 * 1024 * 1024, + }); +} + +function parseDurationSeconds(ffmpegOutput) { + const match = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i); + if (!match) { + return null; + } + + return ( + Number.parseInt(match[1], 10) * 3600 + + Number.parseInt(match[2], 10) * 60 + + Number.parseFloat(match[3]) + ); +} + +export async function inspectOutput(ffmpegPath, targetPath) { + try { + const { stderr } = await execFileAsync( + ffmpegPath, + ["-hide_banner", "-i", targetPath, "-f", "null", "-"], + { + timeout: 30_000, + maxBuffer: 20 * 1024 * 1024, + }, + ); + return parseDurationSeconds(stderr); + } catch (error) { + return parseDurationSeconds(String(error?.stderr ?? "")); + } +} + +export async function readSmokeExportReport(outputPath) { + const reportPath = `${outputPath}.report.json`; + + try { + const reportContent = await fs.readFile(reportPath, "utf8"); + return { + reportPath, + report: JSON.parse(reportContent), + }; + } catch { + return null; + } +} \ No newline at end of file diff --git a/scripts/benchmark-export-queues/reporting.mjs b/scripts/benchmark-export-queues/reporting.mjs new file mode 100644 index 00000000..297057da --- /dev/null +++ b/scripts/benchmark-export-queues/reporting.mjs @@ -0,0 +1,259 @@ +function formatTableCell(value) { + if (Array.isArray(value)) { + return value.length > 0 ? value.join(", ") : "-"; + } + + if (value === null || value === undefined || value === "") { + return "-"; + } + + return String(value).replace(/\s+/g, " ").trim(); +} + +function printTable(title, columns, rows) { + if (!Array.isArray(rows) || rows.length === 0) { + return; + } + + const formattedRows = rows.map((row) => + columns.map((column) => formatTableCell(column.getValue(row))), + ); + const widths = columns.map((column, columnIndex) => { + const headerWidth = column.header.length; + const rowWidth = Math.max(...formattedRows.map((row) => row[columnIndex].length)); + return Math.max(headerWidth, rowWidth); + }); + const divider = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`; + + console.log(`[benchmark-export-queues] ${title}`); + console.log( + `| ${columns + .map((column, columnIndex) => column.header.padEnd(widths[columnIndex])) + .join(" | ")} |`, + ); + console.log(divider); + for (const row of formattedRows) { + console.log( + `| ${row.map((value, columnIndex) => value.padEnd(widths[columnIndex])).join(" | ")} |`, + ); + } +} + +function formatMs(value) { + return typeof value === "number" && Number.isFinite(value) ? `${Math.round(value)} ms` : "-"; +} + +function formatDeltaMs(value) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return "-"; + } + + const roundedValue = Math.round(value); + return `${roundedValue > 0 ? "+" : ""}${roundedValue} ms`; +} + +function formatPercent(value) { + return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(1)}%` : "-"; +} + +function formatSeconds(value) { + return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(2)} s` : "-"; +} + +function formatMegabytes(value) { + return typeof value === "number" && Number.isFinite(value) + ? `${(value / (1024 * 1024)).toFixed(2)} MB` + : "-"; +} + +function formatBoolean(value) { + return value ? "Yes" : "No"; +} + +export function calculateDelta(referenceValue, nextValue) { + if ( + typeof referenceValue !== "number" || + !Number.isFinite(referenceValue) || + typeof nextValue !== "number" || + !Number.isFinite(nextValue) + ) { + return { deltaMs: null, deltaPercent: null }; + } + + return { + deltaMs: nextValue - referenceValue, + deltaPercent: + referenceValue > 0 ? ((nextValue - referenceValue) / referenceValue) * 100 : null, + }; +} + +function buildRequestedConfigRows(config, benchmarkRequests) { + const rows = [ + { key: "Width", value: config.width }, + { key: "Height", value: config.height }, + { key: "Frame rate", value: `${config.frameRate} FPS` }, + { key: "Duration", value: `${config.durationSeconds} s` }, + { key: "Timeout", value: formatMs(config.timeoutMs) }, + { key: "Runs per variant", value: config.runsPerVariant }, + { key: "Pipeline", value: config.exportPipeline ?? "default" }, + { key: "Requested backends", value: benchmarkRequests.map((request) => request.label) }, + { key: "Backend sweep", value: formatBoolean(benchmarkRequests.length > 1) }, + { key: "Encoding mode", value: config.exportEncodingMode ?? "default" }, + { key: "Shadow intensity", value: config.exportShadowIntensity ?? "default" }, + { key: "Webcam enabled", value: formatBoolean(config.useWebcamOverlay) }, + { key: "Experimental native override", value: formatBoolean(config.useNativeExport) }, + ]; + + if (config.useWebcamOverlay) { + rows.push( + { key: "Webcam width", value: config.webcamWidth }, + { key: "Webcam height", value: config.webcamHeight }, + { key: "Webcam shadow", value: config.webcamShadowIntensity ?? "default" }, + { key: "Webcam size", value: config.webcamSize ?? "default" }, + ); + } + + return rows; +} + +export function printRequestedConfigTable(config, benchmarkRequests) { + printTable( + "Requested config", + [ + { header: "Setting", getValue: (row) => row.key }, + { header: "Value", getValue: (row) => row.value }, + ], + buildRequestedConfigRows(config, benchmarkRequests), + ); +} + +function buildTimingTableRows(benchmarkResults) { + return benchmarkResults.flatMap((result) => + result.summaries.map((summary) => ({ + backend: result.request.backend ?? "default", + pipeline: result.request.pipeline ?? "default", + variant: summary.variant.name, + averageElapsedMs: summary.averageElapsedMs, + medianElapsedMs: summary.medianElapsedMs, + averageSmokeElapsedMs: summary.averageSmokeElapsedMs, + minElapsedMs: summary.minElapsedMs, + maxElapsedMs: summary.maxElapsedMs, + averageOutputDurationSeconds: summary.averageOutputDurationSeconds, + averageSizeBytes: summary.averageSizeBytes, + webcamEnabled: summary.webcamEnabled, + })), + ); +} + +export function printTimingSummaryTable(benchmarkResults) { + printTable( + "Timing summary", + [ + { header: "Pipeline", getValue: (row) => row.pipeline }, + { header: "Backend", getValue: (row) => row.backend }, + { header: "Variant", getValue: (row) => row.variant }, + { header: "Avg total", getValue: (row) => formatMs(row.averageElapsedMs) }, + { header: "Median total", getValue: (row) => formatMs(row.medianElapsedMs) }, + { header: "Avg export", getValue: (row) => formatMs(row.averageSmokeElapsedMs) }, + { header: "Min", getValue: (row) => formatMs(row.minElapsedMs) }, + { header: "Max", getValue: (row) => formatMs(row.maxElapsedMs) }, + { + header: "Avg output", + getValue: (row) => formatSeconds(row.averageOutputDurationSeconds), + }, + { header: "Avg size", getValue: (row) => formatMegabytes(row.averageSizeBytes) }, + { header: "Webcam", getValue: (row) => formatBoolean(row.webcamEnabled) }, + ], + buildTimingTableRows(benchmarkResults), + ); +} + +function buildBackendDetailTableRows(benchmarkResults) { + return benchmarkResults.flatMap((result) => + result.summaries.map((summary) => ({ + backend: result.request.backend ?? "default", + pipeline: result.request.pipeline ?? "default", + variant: summary.variant.name, + encodeQueue: summary.variant.maxEncodeQueue, + decodeQueue: summary.variant.maxDecodeQueue, + pendingFrames: summary.variant.maxPendingFrames, + observedRenderBackends: summary.observedRenderBackends, + observedEncodeBackends: summary.observedEncodeBackends, + observedEncoders: summary.observedEncoders, + })), + ); +} + +export function printBackendDetailTable(benchmarkResults) { + printTable( + "Observed backends", + [ + { header: "Pipeline", getValue: (row) => row.pipeline }, + { header: "Backend", getValue: (row) => row.backend }, + { header: "Variant", getValue: (row) => row.variant }, + { header: "Encode Q", getValue: (row) => row.encodeQueue }, + { header: "Decode Q", getValue: (row) => row.decodeQueue }, + { header: "Pending", getValue: (row) => row.pendingFrames }, + { header: "Render", getValue: (row) => row.observedRenderBackends }, + { header: "Encode", getValue: (row) => row.observedEncodeBackends }, + { header: "Encoder", getValue: (row) => row.observedEncoders }, + ], + buildBackendDetailTableRows(benchmarkResults), + ); +} + +function buildDeltaTableRows(benchmarkResults) { + return benchmarkResults + .map((result) => { + const baseline = result.summaries.find((summary) => summary.variant.name === "baseline"); + const tuned = result.summaries.find((summary) => summary.variant.name === "tuned"); + if (!baseline || !tuned) { + return null; + } + + const averageDelta = calculateDelta(baseline.averageElapsedMs, tuned.averageElapsedMs); + const medianDelta = calculateDelta(baseline.medianElapsedMs, tuned.medianElapsedMs); + const exportDelta = calculateDelta( + baseline.averageSmokeElapsedMs, + tuned.averageSmokeElapsedMs, + ); + + return { + pipeline: result.request.pipeline ?? "default", + backend: result.request.backend ?? "default", + averageDeltaMs: averageDelta.deltaMs, + averageDeltaPercent: averageDelta.deltaPercent, + medianDeltaMs: medianDelta.deltaMs, + medianDeltaPercent: medianDelta.deltaPercent, + exportDeltaMs: exportDelta.deltaMs, + exportDeltaPercent: exportDelta.deltaPercent, + }; + }) + .filter(Boolean); +} + +export function printDeltaTable(benchmarkResults) { + printTable( + "Tuned vs baseline", + [ + { header: "Pipeline", getValue: (row) => row.pipeline }, + { header: "Backend", getValue: (row) => row.backend }, + { + header: "Avg delta", + getValue: (row) => + `${formatDeltaMs(row.averageDeltaMs)} (${formatPercent(row.averageDeltaPercent)})`, + }, + { + header: "Median delta", + getValue: (row) => + `${formatDeltaMs(row.medianDeltaMs)} (${formatPercent(row.medianDeltaPercent)})`, + }, + { + header: "Export delta", + getValue: (row) => + `${formatDeltaMs(row.exportDeltaMs)} (${formatPercent(row.exportDeltaPercent)})`, + }, + ], + buildDeltaTableRows(benchmarkResults), + ); +} \ No newline at end of file diff --git a/scripts/benchmark-export-queues/runner.mjs b/scripts/benchmark-export-queues/runner.mjs new file mode 100644 index 00000000..65c6d1eb --- /dev/null +++ b/scripts/benchmark-export-queues/runner.mjs @@ -0,0 +1,241 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { inspectOutput, readSmokeExportReport } from "./fixtures.mjs"; + +function collectUniqueStrings(values) { + return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))]; +} + +function summarizeSmokeProgress(progressSamples) { + if (!Array.isArray(progressSamples) || progressSamples.length === 0) { + return null; + } + + const extractingSamples = progressSamples.filter( + (sample) => + sample?.phase === "extracting" && + typeof sample?.currentFrame === "number" && + sample.currentFrame > 1, + ); + const fpsSource = extractingSamples.length > 0 ? extractingSamples : progressSamples; + const renderFpsSamples = fpsSource + .map((sample) => sample?.renderFps) + .filter((value) => typeof value === "number" && Number.isFinite(value)); + const firstSample = progressSamples[0] ?? null; + const lastSample = progressSamples.at(-1) ?? null; + const firstExtractingSample = extractingSamples[0] ?? null; + const lastExtractingSample = extractingSamples.at(-1) ?? null; + + return { + samples: progressSamples.length, + extractingSamples: extractingSamples.length, + firstElapsedMs: typeof firstSample?.elapsedMs === "number" ? firstSample.elapsedMs : null, + lastElapsedMs: typeof lastSample?.elapsedMs === "number" ? lastSample.elapsedMs : null, + firstExtractingElapsedMs: + typeof firstExtractingSample?.elapsedMs === "number" + ? firstExtractingSample.elapsedMs + : null, + lastExtractingElapsedMs: + typeof lastExtractingSample?.elapsedMs === "number" + ? lastExtractingSample.elapsedMs + : null, + firstRenderFps: renderFpsSamples[0] ?? null, + lastRenderFps: renderFpsSamples.at(-1) ?? null, + minRenderFps: renderFpsSamples.length > 0 ? Math.min(...renderFpsSamples) : null, + maxRenderFps: renderFpsSamples.length > 0 ? Math.max(...renderFpsSamples) : null, + }; +} + +async function runVariant(electronPath, ffmpegPath, inputPath, webcamInputPath, benchmarkRequest, variant, runIndex, config) { + const outputPath = path.join( + path.dirname(inputPath), + `${benchmarkRequest.slug}-${variant.name}-${runIndex + 1}-${Date.now()}.mp4`, + ); + const startedAt = performance.now(); + const runLabel = `${benchmarkRequest.label}/${variant.name}#${runIndex + 1}`; + const child = spawn(electronPath, [config.repoRoot], { + cwd: config.repoRoot, + env: { + ...process.env, + RECORDLY_SMOKE_EXPORT: "1", + RECORDLY_SMOKE_EXPORT_INPUT: inputPath, + RECORDLY_SMOKE_EXPORT_OUTPUT: outputPath, + ...(config.useNativeExport ? { RECORDLY_SMOKE_EXPORT_USE_NATIVE: "1" } : {}), + ...(config.exportEncodingMode + ? { RECORDLY_SMOKE_EXPORT_ENCODING_MODE: config.exportEncodingMode } + : {}), + ...(config.exportShadowIntensity !== null + ? { RECORDLY_SMOKE_EXPORT_SHADOW_INTENSITY: String(config.exportShadowIntensity) } + : {}), + ...(webcamInputPath ? { RECORDLY_SMOKE_EXPORT_WEBCAM_INPUT: webcamInputPath } : {}), + ...(config.webcamShadowIntensity !== null + ? { RECORDLY_SMOKE_EXPORT_WEBCAM_SHADOW: String(config.webcamShadowIntensity) } + : {}), + ...(config.webcamSize !== null + ? { RECORDLY_SMOKE_EXPORT_WEBCAM_SIZE: String(config.webcamSize) } + : {}), + ...(benchmarkRequest.pipeline + ? { RECORDLY_SMOKE_EXPORT_PIPELINE: benchmarkRequest.pipeline } + : {}), + ...(benchmarkRequest.backend + ? { RECORDLY_SMOKE_EXPORT_BACKEND: benchmarkRequest.backend } + : {}), + ...(typeof variant.maxEncodeQueue === "number" + ? { RECORDLY_SMOKE_EXPORT_MAX_ENCODE_QUEUE: String(variant.maxEncodeQueue) } + : {}), + ...(typeof variant.maxDecodeQueue === "number" + ? { RECORDLY_SMOKE_EXPORT_MAX_DECODE_QUEUE: String(variant.maxDecodeQueue) } + : {}), + ...(typeof variant.maxPendingFrames === "number" + ? { RECORDLY_SMOKE_EXPORT_MAX_PENDING_FRAMES: String(variant.maxPendingFrames) } + : {}), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let combinedOutput = ""; + child.stdout.on("data", (chunk) => { + const text = chunk.toString(); + combinedOutput += text; + process.stdout.write(`[${runLabel}] ${text}`); + }); + child.stderr.on("data", (chunk) => { + const text = chunk.toString(); + combinedOutput += text; + process.stderr.write(`[${runLabel}] ${text}`); + }); + + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + }, config.timeoutMs); + + const [exitCode, signal] = await once(child, "close"); + clearTimeout(timeout); + + if (exitCode !== 0) { + const signalText = signal ? ` (signal ${signal})` : ""; + throw new Error( + `${variant.name} run ${runIndex + 1} failed with code ${exitCode ?? "unknown"}${signalText}\n${combinedOutput.trim()}`, + ); + } + + const smokeExportReport = await readSmokeExportReport(outputPath); + let outputStats; + try { + outputStats = await fs.stat(outputPath); + } catch (error) { + const reportSuffix = smokeExportReport ? `\n${JSON.stringify(smokeExportReport.report)}` : ""; + throw new Error( + `${variant.name} run ${runIndex + 1} did not produce an output file: ${error instanceof Error ? error.message : String(error)}${reportSuffix}`, + ); + } + if (outputStats.size <= 0) { + const reportSuffix = smokeExportReport ? `\n${JSON.stringify(smokeExportReport.report)}` : ""; + throw new Error( + `${variant.name} run ${runIndex + 1} produced an empty output file${reportSuffix}`, + ); + } + + const elapsedMs = Math.round(performance.now() - startedAt); + const outputDuration = await inspectOutput(ffmpegPath, outputPath); + + return { + elapsedMs, + outputPath, + sizeBytes: outputStats.size, + outputDuration, + webcamEnabled: !!webcamInputPath, + smokeExportReport: smokeExportReport?.report ?? null, + smokeProgressSummary: summarizeSmokeProgress(smokeExportReport?.report?.progressSamples), + }; +} + +function average(values) { + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function median(values) { + if (values.length === 0) { + return 0; + } + + const sorted = [...values].sort((left, right) => left - right); + const middleIndex = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2; + } + + return sorted[middleIndex]; +} + +function summarizeVariantRuns(runs) { + const elapsedValues = runs.map((run) => run.elapsedMs); + const sizeValues = runs.map((run) => run.sizeBytes); + const outputDurationValues = runs + .map((run) => run.outputDuration) + .filter((value) => typeof value === "number" && Number.isFinite(value)); + const smokeElapsedValues = runs + .map((run) => run.smokeExportReport?.elapsedMs) + .filter((value) => typeof value === "number" && Number.isFinite(value)); + + return { + averageElapsedMs: Math.round(average(elapsedValues)), + medianElapsedMs: Math.round(median(elapsedValues)), + minElapsedMs: Math.min(...elapsedValues), + maxElapsedMs: Math.max(...elapsedValues), + averageSizeBytes: Math.round(average(sizeValues)), + averageOutputDurationSeconds: + outputDurationValues.length > 0 ? average(outputDurationValues) : null, + averageSmokeElapsedMs: + smokeElapsedValues.length > 0 ? Math.round(average(smokeElapsedValues)) : null, + observedRenderBackends: collectUniqueStrings( + runs.map((run) => run.smokeExportReport?.metrics?.renderBackend), + ), + observedEncodeBackends: collectUniqueStrings( + runs.map((run) => run.smokeExportReport?.metrics?.encodeBackend), + ), + observedEncoders: collectUniqueStrings( + runs.map((run) => run.smokeExportReport?.metrics?.encoderName), + ), + }; +} + +export async function runBenchmarkRequest(electronPath, ffmpegPath, inputPath, webcamInputPath, benchmarkRequest, config) { + const summaries = []; + for (const variant of config.variants) { + const runs = []; + for (let index = 0; index < config.runsPerVariant; index += 1) { + console.log( + `[benchmark-export-queues] Running ${benchmarkRequest.label}/${variant.name} (${index + 1}/${config.runsPerVariant}) with encode=${variant.maxEncodeQueue ?? "auto"} decode=${variant.maxDecodeQueue ?? "auto"} pending=${variant.maxPendingFrames ?? "auto"}`, + ); + runs.push( + await runVariant( + electronPath, + ffmpegPath, + inputPath, + webcamInputPath, + benchmarkRequest, + variant, + index, + config, + ), + ); + } + + const runSummary = summarizeVariantRuns(runs); + summaries.push({ + variant, + runs, + ...runSummary, + webcamEnabled: config.useWebcamOverlay, + }); + } + + return { + request: benchmarkRequest, + summaries, + }; +} \ No newline at end of file diff --git a/scripts/build-native-helpers.mjs b/scripts/build-native-helpers.mjs index 778b19b8..130db98c 100644 --- a/scripts/build-native-helpers.mjs +++ b/scripts/build-native-helpers.mjs @@ -25,19 +25,24 @@ function getTargetConfigs() { const helpers = [ { - source: "ScreenCaptureKitRecorder.swift", + sources: [ + "ScreenCaptureKitRecorder.swift", + "ScreenCaptureKitRecorder/ScreenCaptureRecorder.swift", + "ScreenCaptureKitRecorder/ScreenCaptureRecorder+Stream.swift", + "ScreenCaptureKitRecorder/RecorderService.swift", + ], output: "recordly-screencapturekit-helper", }, { - source: "ScreenCaptureKitWindowList.swift", + sources: ["ScreenCaptureKitWindowList.swift"], output: "recordly-window-list", }, { - source: "SystemCursorAssets.swift", + sources: ["SystemCursorAssets.swift"], output: "recordly-system-cursors", }, { - source: "NativeCursorMonitor.swift", + sources: ["NativeCursorMonitor.swift"], output: "recordly-native-cursor-monitor", }, ]; @@ -53,12 +58,12 @@ for (const target of getTargetConfigs()) { await mkdir(outputDir, { recursive: true }); for (const helper of helpers) { - const sourcePath = path.join(nativeRoot, helper.source); + const sourcePaths = helper.sources.map((source) => path.join(nativeRoot, source)); const outputPath = path.join(outputDir, helper.output); const result = spawnSync( "swiftc", - ["-O", "-target", target.swiftTarget, sourcePath, "-o", outputPath], + ["-O", "-target", target.swiftTarget, ...sourcePaths, "-o", outputPath], { encoding: "utf8", timeout: 120000, @@ -67,7 +72,7 @@ for (const target of getTargetConfigs()) { if (result.status !== 0) { const details = [result.stderr, result.stdout].filter(Boolean).join("\n").trim(); - throw new Error(details || `Failed to compile ${helper.source} for ${target.archTag}`); + throw new Error(details || `Failed to compile ${helper.sources[0]} for ${target.archTag}`); } await chmod(outputPath, 0o755); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx deleted file mode 100644 index 2c4e6146..00000000 --- a/src/components/launch/LaunchWindow.tsx +++ /dev/null @@ -1,1647 +0,0 @@ -import { - AppWindow, - ArrowCircleUp as ArrowUpCircle, - ArrowClockwise as RefreshCw, - CaretUp as ChevronUp, - CheckCircle as CheckCircle2, - DotsThreeVertical as MoreVertical, - Eye, - EyeSlash as EyeOff, - FolderOpen, - Microphone as Mic, - MicrophoneSlash as MicOff, - Minus, - Monitor, - Pause, - Play, - SpeakerHigh as Volume2, - SpeakerX as VolumeX, - Stop as Square, - Timer, - Translate as Languages, - VideoCamera as Video, - VideoCamera as VideoIcon, - VideoCameraSlash as VideoOff, - X, -} from "@phosphor-icons/react"; -import { AnimatePresence, motion } from "motion/react"; -import type { ReactNode } from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { RxDragHandleDots2 } from "react-icons/rx"; -import { useI18n } from "@/contexts/I18nContext"; -import type { AppLocale } from "@/i18n/config"; -import { SUPPORTED_LOCALES } from "@/i18n/config"; -import { useScopedT } from "../../contexts/I18nContext"; -import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; -import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; -import { useScreenRecorder } from "../../hooks/useScreenRecorder"; -import { useVideoDevices } from "../../hooks/useVideoDevices"; -import { AudioLevelMeter } from "../ui/audio-level-meter"; -import { ContentClamp } from "../ui/content-clamp"; -import ProjectBrowserDialog, { - type ProjectLibraryEntry, -} from "../video-editor/ProjectBrowserDialog"; -import styles from "./LaunchWindow.module.css"; - -interface DesktopSource { - id: string; - name: string; - thumbnail: string | null; - display_id: string; - appIcon: string | null; - sourceType?: "screen" | "window"; - appName?: string; - windowTitle?: string; -} - -const LOCALE_LABELS: Record = { - en: "EN", - es: "ES", - nl: "NL", - "zh-CN": "中文", - ko: "한국어", -}; - -const COUNTDOWN_OPTIONS = [0, 3, 5, 10]; -const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6; -const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 }; -const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 }; - -function IconButton({ - onClick, - title, - className = "", - buttonRef, - children, -}: { - onClick?: () => void; - title?: string; - className?: string; - buttonRef?: React.Ref; - children: ReactNode; -}) { - return ( - - ); -} - -function DropdownItem({ - onClick, - selected, - icon, - children, - trailing, -}: { - onClick: () => void; - selected?: boolean; - icon: ReactNode; - children: ReactNode; - trailing?: ReactNode; -}) { - return ( - - ); -} - -function Separator({ dropdown = false }: { dropdown?: boolean }) { - return
; -} - -function MicDeviceRow({ - device, - selected, - onSelect, -}: { - device: { deviceId: string; label: string }; - selected: boolean; - onSelect: () => void; -}) { - const { level } = useAudioLevelMeter({ - enabled: true, - deviceId: device.deviceId, - }); - - return ( - - ); -} - -export function LaunchWindow() { - const { locale, setLocale } = useI18n(); - const t = useScopedT("launch"); - - const { - recording, - paused, - countdownActive, - toggleRecording, - pauseRecording, - resumeRecording, - cancelRecording, - microphoneEnabled, - setMicrophoneEnabled, - microphoneDeviceId, - setMicrophoneDeviceId, - systemAudioEnabled, - setSystemAudioEnabled, - webcamEnabled, - setWebcamEnabled, - webcamDeviceId, - setWebcamDeviceId, - countdownDelay, - setCountdownDelay, - preparePermissions, - } = useScreenRecorder(); - - const [recordingStart, setRecordingStart] = useState(null); - const [elapsed, setElapsed] = useState(0); - const [pausedAt, setPausedAt] = useState(null); - const [pausedTotal, setPausedTotal] = useState(0); - const [selectedSource, setSelectedSource] = useState("Screen"); - const [hasSelectedSource, setHasSelectedSource] = useState(false); - const [, setRecordingsDirectory] = useState(null); - const [activeDropdown, setActiveDropdown] = useState< - "none" | "sources" | "more" | "mic" | "countdown" | "webcam" - >("none"); - const [projectLibraryEntries, setProjectLibraryEntries] = useState([]); - const [projectBrowserOpen, setProjectBrowserOpen] = useState(false); - const [sources, setSources] = useState([]); - const [sourcesLoading, setSourcesLoading] = useState(false); - const [hideHudFromCapture, setHideHudFromCapture] = useState(true); - const [showFloatingWebcamPreview, setShowFloatingWebcamPreview] = useState(true); - const [webcamPreviewOffset, setWebcamPreviewOffset] = useState(DEFAULT_WEBCAM_PREVIEW_OFFSET); - const [recordingHudOffset, setRecordingHudOffset] = useState(DEFAULT_RECORDING_HUD_OFFSET); - const [platform, setPlatform] = useState(null); - const [appVersion, setAppVersion] = useState(null); - const [updateStatus, setUpdateStatus] = useState<{ - status: - | "idle" - | "checking" - | "up-to-date" - | "available" - | "downloading" - | "ready" - | "error"; - currentVersion: string; - availableVersion: string | null; - detail?: string; - }>({ - status: "idle", - currentVersion: "", - availableVersion: null, - }); - const [updateActionPending, setUpdateActionPending] = useState(false); - const dropdownRef = useRef(null); - const hudContentRef = useRef(null); - const hudBarRef = useRef(null); - const moreButtonRef = useRef(null); - const webcamPreviewRef = useRef(null); - const recordingWebcamPreviewRef = useRef(null); - const recordingWebcamPreviewContainerRef = useRef(null); - const previewStreamRef = useRef(null); - const webcamPreviewDragStartRef = useRef<{ - pointerId: number; - startX: number; - startY: number; - originX: number; - originY: number; - initialLeft: number; - initialTop: number; - previewWidth: number; - previewHeight: number; - dragging: boolean; - } | null>(null); - const hudDragStartRef = useRef< - | { - pointerId: number; - mode: "webcam-preview"; - startX: number; - startY: number; - originX: number; - originY: number; - initialLeft: number; - initialTop: number; - hudWidth: number; - hudHeight: number; - } - | { - pointerId: number; - mode: "overlay"; - } - | null - >(null); - const isHudDraggingRef = useRef(false); - const isWebcamPreviewDraggingRef = useRef(false); - - const micDropdownOpen = activeDropdown === "mic"; - const webcamDropdownOpen = activeDropdown === "webcam"; - const showWebcamControls = webcamEnabled && !recording; - const showRecordingWebcamPreview = webcamEnabled && showFloatingWebcamPreview; - const shouldStreamWebcamPreview = - webcamEnabled && (showFloatingWebcamPreview || (showWebcamControls && webcamDropdownOpen)); - const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices( - microphoneEnabled || micDropdownOpen, - microphoneDeviceId, - ); - const { - devices: videoDevices, - selectedDeviceId: selectedVideoDeviceId, - setSelectedDeviceId: setSelectedVideoDeviceId, - } = useVideoDevices(webcamEnabled || webcamDropdownOpen); - - const supportsHudCaptureProtection = platform !== "linux"; - - useEffect(() => { - if (!selectedDeviceId) { - return; - } - - setMicrophoneDeviceId(selectedDeviceId === "default" ? undefined : selectedDeviceId); - }, [selectedDeviceId, setMicrophoneDeviceId]); - - useEffect(() => { - if (selectedVideoDeviceId && selectedVideoDeviceId !== "default") { - setWebcamDeviceId(selectedVideoDeviceId); - } - }, [selectedVideoDeviceId, setWebcamDeviceId]); - - useEffect(() => { - if (!webcamEnabled) { - setWebcamPreviewOffset(DEFAULT_WEBCAM_PREVIEW_OFFSET); - setRecordingHudOffset(DEFAULT_RECORDING_HUD_OFFSET); - webcamPreviewDragStartRef.current = null; - isWebcamPreviewDraggingRef.current = false; - setShowFloatingWebcamPreview(true); - } - }, [webcamEnabled]); - - useEffect(() => { - if (!showRecordingWebcamPreview) { - setRecordingHudOffset(DEFAULT_RECORDING_HUD_OFFSET); - } - }, [showRecordingWebcamPreview]); - - const handleWebcamPreviewPointerDown = (event: React.PointerEvent) => { - if (event.button !== 0) { - return; - } - - const previewRect = event.currentTarget.getBoundingClientRect(); - - event.preventDefault(); - window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); - webcamPreviewDragStartRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: webcamPreviewOffset.x, - originY: webcamPreviewOffset.y, - initialLeft: previewRect.left, - initialTop: previewRect.top, - previewWidth: previewRect.width, - previewHeight: previewRect.height, - dragging: false, - }; - event.currentTarget.setPointerCapture(event.pointerId); - }; - - const handleWebcamPreviewPointerMove = (event: React.PointerEvent) => { - const dragState = webcamPreviewDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } - - const deltaX = event.clientX - dragState.startX; - const deltaY = event.clientY - dragState.startY; - - if (!dragState.dragging && Math.hypot(deltaX, deltaY) < WEBCAM_PREVIEW_DRAG_THRESHOLD) { - return; - } - - if (!dragState.dragging) { - dragState.dragging = true; - isWebcamPreviewDraggingRef.current = true; - } - - const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0); - const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); - const unclampedLeft = dragState.initialLeft + deltaX; - const unclampedTop = dragState.initialTop + deltaY; - const clampedLeft = Math.min( - Math.max(0, unclampedLeft), - Math.max(0, viewportWidth - dragState.previewWidth), - ); - const clampedTop = Math.min( - Math.max(0, unclampedTop), - Math.max(0, viewportHeight - dragState.previewHeight), - ); - - setWebcamPreviewOffset({ - x: dragState.originX + (clampedLeft - dragState.initialLeft), - y: dragState.originY + (clampedTop - dragState.initialTop), - }); - }; - - const handleWebcamPreviewPointerUp = (event: React.PointerEvent) => { - const dragState = webcamPreviewDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } - - const wasDragging = dragState.dragging; - webcamPreviewDragStartRef.current = null; - isWebcamPreviewDraggingRef.current = false; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - if (wasDragging) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }; - - const handleHudBarPointerDown = (event: React.PointerEvent) => { - if (event.button !== 0) { - return; - } - - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - isHudDraggingRef.current = true; - window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); - - if (showRecordingWebcamPreview && hudBarRef.current) { - const hudRect = hudBarRef.current.getBoundingClientRect(); - hudDragStartRef.current = { - pointerId: event.pointerId, - mode: "webcam-preview", - startX: event.clientX, - startY: event.clientY, - originX: recordingHudOffset.x, - originY: recordingHudOffset.y, - initialLeft: hudRect.left, - initialTop: hudRect.top, - hudWidth: hudRect.width, - hudHeight: hudRect.height, - }; - return; - } - - hudDragStartRef.current = { - pointerId: event.pointerId, - mode: "overlay", - }; - window.electronAPI?.hudOverlayDrag?.("start", event.screenX, event.screenY); - }; - - const handleHudBarPointerMove = (event: React.PointerEvent) => { - const dragState = hudDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } - - if (dragState.mode === "webcam-preview") { - const deltaX = event.clientX - dragState.startX; - const deltaY = event.clientY - dragState.startY; - const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0); - const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); - const unclampedLeft = dragState.initialLeft + deltaX; - const unclampedTop = dragState.initialTop + deltaY; - const clampedLeft = Math.min( - Math.max(0, unclampedLeft), - Math.max(0, viewportWidth - dragState.hudWidth), - ); - const clampedTop = Math.min( - Math.max(0, unclampedTop), - Math.max(0, viewportHeight - dragState.hudHeight), - ); - - setRecordingHudOffset({ - x: dragState.originX + (clampedLeft - dragState.initialLeft), - y: dragState.originY + (clampedTop - dragState.initialTop), - }); - return; - } - - window.electronAPI?.hudOverlayDrag?.("move", event.screenX, event.screenY); - }; - - const handleHudBarPointerUp = (event: React.PointerEvent) => { - const dragState = hudDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } - - if (dragState.mode === "overlay") { - window.electronAPI?.hudOverlayDrag?.("end", 0, 0); - } - - hudDragStartRef.current = null; - const wasDragging = isHudDraggingRef.current; - isHudDraggingRef.current = false; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - if (wasDragging) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }; - - const attachPreviewStreamToNode = useCallback((videoElement: HTMLVideoElement | null) => { - const previewStream = previewStreamRef.current; - if (!videoElement || !previewStream || videoElement.srcObject === previewStream) { - return; - } - - videoElement.srcObject = previewStream; - const playPromise = videoElement.play(); - if (playPromise) { - playPromise.catch(() => { - // Ignore autoplay interruptions while the preview element mounts. - }); - } - }, []); - - const setWebcamPreviewNode = useCallback( - (node: HTMLVideoElement | null) => { - webcamPreviewRef.current = node; - attachPreviewStreamToNode(node); - }, - [attachPreviewStreamToNode], - ); - - const setRecordingWebcamPreviewNode = useCallback( - (node: HTMLVideoElement | null) => { - recordingWebcamPreviewRef.current = node; - attachPreviewStreamToNode(node); - }, - [attachPreviewStreamToNode], - ); - - useEffect(() => { - let mounted = true; - - const startPreview = async () => { - if (!shouldStreamWebcamPreview) { - return; - } - - try { - const previewStream = await navigator.mediaDevices.getUserMedia({ - video: webcamDeviceId - ? { - deviceId: { exact: webcamDeviceId }, - width: { ideal: 320 }, - height: { ideal: 320 }, - frameRate: { ideal: 24, max: 30 }, - } - : { - width: { ideal: 320 }, - height: { ideal: 320 }, - frameRate: { ideal: 24, max: 30 }, - }, - audio: false, - }); - - if (!mounted) { - previewStream.getTracks().forEach((track) => track.stop()); - return; - } - - previewStreamRef.current = previewStream; - attachPreviewStreamToNode(webcamPreviewRef.current); - attachPreviewStreamToNode(recordingWebcamPreviewRef.current); - } catch (error) { - console.warn("Failed to start live webcam preview:", error); - } - }; - - void startPreview(); - - return () => { - mounted = false; - const previewNode = webcamPreviewRef.current; - const recordingPreviewNode = recordingWebcamPreviewRef.current; - const previewStream = previewStreamRef.current; - - [previewNode, recordingPreviewNode] - .filter((node): node is HTMLVideoElement => Boolean(node)) - .forEach((videoElement) => { - videoElement.pause(); - videoElement.srcObject = null; - }); - previewStream?.getTracks().forEach((track) => track.stop()); - if (previewStreamRef.current === previewStream) { - previewStreamRef.current = null; - } - }; - }, [attachPreviewStreamToNode, shouldStreamWebcamPreview, webcamDeviceId]); - - useEffect(() => { - let timer: NodeJS.Timeout | null = null; - if (recording) { - if (!recordingStart) { - setRecordingStart(Date.now()); - setPausedTotal(0); - } - if (paused) { - if (!pausedAt) setPausedAt(Date.now()); - if (timer) clearInterval(timer); - } else { - if (pausedAt) { - setPausedTotal((prev) => prev + (Date.now() - pausedAt)); - setPausedAt(null); - } - timer = setInterval(() => { - if (recordingStart) { - setElapsed(Math.floor((Date.now() - recordingStart - pausedTotal) / 1000)); - } - }, 1000); - } - } else { - setRecordingStart(null); - setElapsed(0); - setPausedAt(null); - setPausedTotal(0); - if (timer) clearInterval(timer); - } - return () => { - if (timer) clearInterval(timer); - }; - }, [recording, recordingStart, paused, pausedAt, pausedTotal]); - - const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60) - .toString() - .padStart(2, "0"); - const s = (seconds % 60).toString().padStart(2, "0"); - return `${m}:${s}`; - }; - - useEffect(() => { - let mounted = true; - - const applySelectedSource = (source: { name?: string } | null | undefined) => { - if (!mounted) { - return; - } - - if (source?.name) { - setSelectedSource(source.name); - setHasSelectedSource(true); - return; - } - - setSelectedSource("Screen"); - setHasSelectedSource(false); - }; - - void window.electronAPI.getSelectedSource().then((source) => { - applySelectedSource(source); - }); - - const cleanup = window.electronAPI.onSelectedSourceChanged((source) => { - applySelectedSource(source); - }); - - return () => { - mounted = false; - cleanup?.(); - }; - }, []); - - useEffect(() => { - const load = async () => { - const result = await window.electronAPI.getRecordingsDirectory(); - if (result.success) setRecordingsDirectory(result.path); - }; - void load(); - }, []); - - useEffect(() => { - let cancelled = false; - const loadPlatform = async () => { - try { - const nextPlatform = await window.electronAPI.getPlatform(); - if (!cancelled) setPlatform(nextPlatform); - } catch (error) { - console.error("Failed to load platform:", error); - } - }; - void loadPlatform(); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - void preparePermissions({ startup: true }); - }, [preparePermissions]); - - useEffect(() => { - let mounted = true; - - const refreshUpdateStatus = async () => { - try { - const summary = await window.electronAPI.getUpdateStatusSummary(); - if (mounted) { - setUpdateStatus(summary); - } - } catch (error) { - console.error("Failed to load update status summary:", error); - } - }; - - void refreshUpdateStatus(); - const pollTimer = window.setInterval(() => { - void refreshUpdateStatus(); - }, 2500); - - return () => { - mounted = false; - window.clearInterval(pollTimer); - }; - }, []); - - useEffect(() => { - let cancelled = false; - const loadVersion = async () => { - try { - const version = await window.electronAPI.getAppVersion(); - if (!cancelled) setAppVersion(version); - } catch (error) { - console.error("Failed to load app version:", error); - } - }; - void loadVersion(); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - let cancelled = false; - const loadHudCaptureProtection = async () => { - try { - const result = await window.electronAPI.getHudOverlayCaptureProtection(); - if (!cancelled && result.success) { - setHideHudFromCapture(result.enabled); - } - } catch (error) { - console.error("Failed to load HUD capture protection state:", error); - } - }; - void loadHudCaptureProtection(); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - const expanded = - activeDropdown !== "none" || projectBrowserOpen || showRecordingWebcamPreview; - window.electronAPI.setHudOverlayExpanded(expanded); - - return () => { - window.electronAPI.setHudOverlayExpanded(false); - }; - }, [activeDropdown, projectBrowserOpen, showRecordingWebcamPreview]); - - const reportHudSize = useCallback(() => { - const hudContent = hudContentRef.current; - const hudBar = hudBarRef.current; - if (!hudContent || !hudBar) { - return; - } - - if (showRecordingWebcamPreview) { - const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0); - const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); - window.electronAPI.setHudOverlayCompactWidth(Math.ceil(viewportWidth)); - window.electronAPI.setHudOverlayMeasuredHeight(Math.ceil(viewportHeight), true); - return; - } - - const hudContentRect = hudContent.getBoundingClientRect(); - const hudBarRect = hudBar.getBoundingClientRect(); - const standardWidth = Math.max( - hudBarRect.width, - hudBar.scrollWidth, - hudContentRect.width, - hudContent.scrollWidth, - ); - const standardHeight = Math.max(hudContentRect.height, hudContent.scrollHeight); - - window.electronAPI.setHudOverlayCompactWidth(Math.ceil(standardWidth + 24)); - window.electronAPI.setHudOverlayMeasuredHeight( - Math.ceil(standardHeight + 24), - activeDropdown !== "none" || projectBrowserOpen, - ); - }, [activeDropdown, projectBrowserOpen, showRecordingWebcamPreview]); - - useEffect(() => { - const hudContent = hudContentRef.current; - const hudBar = hudBarRef.current; - const previewContainer = recordingWebcamPreviewContainerRef.current; - if (!hudContent || !hudBar || typeof ResizeObserver === "undefined") { - return; - } - - let frameId = 0; - const scheduleHudSizeReport = () => { - if (frameId !== 0) { - cancelAnimationFrame(frameId); - } - frameId = requestAnimationFrame(() => { - frameId = 0; - reportHudSize(); - }); - }; - - scheduleHudSizeReport(); - - const resizeObserver = new ResizeObserver(() => { - scheduleHudSizeReport(); - }); - resizeObserver.observe(hudContent); - resizeObserver.observe(hudBar); - if (previewContainer) { - resizeObserver.observe(previewContainer); - } - - return () => { - resizeObserver.disconnect(); - if (frameId !== 0) { - cancelAnimationFrame(frameId); - } - }; - }, [reportHudSize]); - - useEffect(() => { - const handleClick = (e: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { - setActiveDropdown("none"); - setProjectBrowserOpen(false); - } - }; - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, []); - - const fetchSources = useCallback(async () => { - if (!window.electronAPI) return; - setSourcesLoading(true); - try { - const rawSources = await window.electronAPI.getSources({ - types: ["screen", "window"], - thumbnailSize: { width: 160, height: 90 }, - fetchWindowIcons: true, - }); - setSources( - rawSources.map((s) => { - const isWindow = s.id.startsWith("window:"); - const type = s.sourceType ?? (isWindow ? "window" : "screen"); - let displayName = s.name; - let appName = s.appName; - if (isWindow && !appName && s.name.includes(" — ")) { - const parts = s.name.split(" — "); - appName = parts[0]?.trim(); - displayName = parts.slice(1).join(" — ").trim() || s.name; - } else if (isWindow && s.windowTitle) { - displayName = s.windowTitle; - } - return { - id: s.id, - name: displayName, - thumbnail: s.thumbnail, - display_id: s.display_id, - appIcon: s.appIcon, - sourceType: type, - appName, - windowTitle: s.windowTitle ?? displayName, - }; - }), - ); - } catch (error) { - console.error("Failed to fetch sources:", error); - } finally { - setSourcesLoading(false); - } - }, []); - - const toggleDropdown = (which: "sources" | "more" | "mic" | "countdown" | "webcam") => { - setProjectBrowserOpen(false); - setActiveDropdown(activeDropdown === which ? "none" : which); - if (activeDropdown !== which && which === "sources") fetchSources(); - }; - - const handleSourceSelect = async (source: DesktopSource) => { - await window.electronAPI.selectSource(source); - setSelectedSource(source.name); - setHasSelectedSource(true); - setActiveDropdown("none"); - window.electronAPI.showSourceHighlight?.({ - ...source, - name: source.appName ? `${source.appName} — ${source.name}` : source.name, - appName: source.appName, - }); - }; - - const openVideoFile = async () => { - setActiveDropdown("none"); - const result = await window.electronAPI.openVideoFilePicker(); - if (result.canceled) return; - if (result.success && result.path) { - await window.electronAPI.setCurrentVideoPath(result.path); - await window.electronAPI.switchToEditor(); - } - }; - - const refreshProjectLibrary = useCallback(async () => { - try { - const result = await window.electronAPI.listProjectFiles(); - if (!result.success) return; - - setProjectLibraryEntries(result.entries); - } catch (error) { - console.error("Failed to load project library:", error); - } - }, []); - - const openProjectBrowser = useCallback(async () => { - if (projectBrowserOpen) { - setProjectBrowserOpen(false); - return; - } - - setActiveDropdown("none"); - await refreshProjectLibrary(); - setProjectBrowserOpen(true); - }, [projectBrowserOpen, refreshProjectLibrary]); - - const openProjectFromLibrary = useCallback(async (projectPath: string) => { - try { - const result = await window.electronAPI.openProjectFileAtPath(projectPath); - if (result.canceled || !result.success) { - return; - } - - setProjectBrowserOpen(false); - await window.electronAPI.switchToEditor(); - } catch (error) { - console.error("Failed to open project from library:", error); - } - }, []); - - const chooseRecordingsDirectory = async () => { - setActiveDropdown("none"); - const result = await window.electronAPI.chooseRecordingsDirectory(); - if (result.canceled) return; - if (result.success && result.path) setRecordingsDirectory(result.path); - }; - - const toggleMicrophone = () => { - if (recording) return; - toggleDropdown("mic"); - }; - - const toggleHudCaptureProtection = async () => { - const nextValue = !hideHudFromCapture; - setHideHudFromCapture(nextValue); - try { - const result = await window.electronAPI.setHudOverlayCaptureProtection(nextValue); - if (!result.success) { - setHideHudFromCapture(!nextValue); - return; - } - setHideHudFromCapture(result.enabled); - } catch (error) { - console.error("Failed to update HUD capture protection:", error); - setHideHudFromCapture(!nextValue); - } - }; - - const screenSources = sources.filter((s) => s.sourceType === "screen"); - const windowSources = sources.filter((s) => s.sourceType === "window"); - const hudStateTransition = { - duration: 0.24, - ease: [0.22, 1, 0.36, 1] as const, - }; - - const toggleWebcam = () => { - if (recording) return; - toggleDropdown("webcam"); - }; - - const updateButtonLabel = - updateStatus.status === "up-to-date" - ? t("recording.update.updated") - : t("recording.update.update"); - const updateButtonTitle = (() => { - switch (updateStatus.status) { - case "up-to-date": - return t("recording.update.upToDateTitle", "Recordly {{version}} is up to date.", { - version: updateStatus.currentVersion, - }); - case "available": - case "ready": - return updateStatus.availableVersion - ? t("recording.update.availableTitle", "Recordly {{version}} is available.", { - version: updateStatus.availableVersion, - }) - : t("recording.update.availableGenericTitle"); - case "downloading": - return updateStatus.detail ?? t("recording.update.downloadingTitle"); - case "checking": - return t("recording.update.checkingTitle"); - case "error": - return updateStatus.detail ?? t("recording.update.errorTitle"); - default: - return t("recording.update.idleTitle"); - } - })(); - const updateButtonClassName = `${styles.updateBadge} ${updateStatus.status === "up-to-date" ? styles.updateBadgeQuiet : styles.updateBadgeHot} ${styles.electronNoDrag}`; - const updateButtonIcon = (() => { - switch (updateStatus.status) { - case "up-to-date": - return ; - case "checking": - case "downloading": - return ; - default: - return ; - } - })(); - - const handleUpdateButtonClick = async () => { - if (updateActionPending || updateStatus.status === "downloading") { - return; - } - - setUpdateActionPending(true); - try { - switch (updateStatus.status) { - case "available": - await window.electronAPI.downloadAvailableUpdate(); - break; - case "ready": - await window.electronAPI.installDownloadedUpdate(); - break; - default: - await window.electronAPI.checkForAppUpdates(); - break; - } - - const summary = await window.electronAPI.getUpdateStatusSummary(); - setUpdateStatus(summary); - } catch (error) { - console.error("Failed to handle update button action:", error); - } finally { - setUpdateActionPending(false); - } - }; - - const recordingControls = ( - <> -
-
- - {paused ? t("recording.paused") : t("recording.rec")} - -
- - - {formatTime(elapsed)} - - - - - - {microphoneEnabled ? : } - - - - - - {paused ? ( - - ) : ( - - )} - - - - - - - window.electronAPI?.hudOverlayHide?.()} - title={t("recording.hideHud")} - > - - - - - - - - ); - - const idleControls = ( - <> - {platform !== "linux" && ( - <> - - - - - )} - - - {microphoneEnabled ? : } - - - - {webcamEnabled ? - - toggleDropdown("countdown")} - title={t("recording.countdownDelay")} - className={countdownDelay > 0 ? styles.ibActive : ""} - > - - - - - - - - - - toggleDropdown("more")} - title={t("recording.more")} - > - - - - window.electronAPI?.hudOverlayHide?.()} - title={t("recording.hideHud")} - > - - - - window.electronAPI?.hudOverlayClose?.()} - title={t("recording.closeApp")} - > - - - - ); - - return ( -
-
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false)} - onMouseLeave={() => { - if ( - !isHudDraggingRef.current && - !isWebcamPreviewDraggingRef.current && - !webcamPreviewDragStartRef.current - ) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }} - > - {/* Only the visible HUD content should become interactive. */} -
- {projectBrowserOpen ? ( -
- { - void openProjectFromLibrary(projectPath); - }} - /> -
- ) : null} - {activeDropdown !== "none" && ( -
- {activeDropdown === "sources" && ( - <> - {sourcesLoading ? ( -
-
-
- ) : ( - <> - {screenSources.length > 0 && ( - <> -
- {t("recording.screens")} -
- {screenSources.map((source) => ( - } - selected={ - selectedSource === source.name - } - onClick={() => - handleSourceSelect(source) - } - > - {source.name} - - ))} - - )} - {windowSources.length > 0 && ( - <> -
0 - ? { - marginTop: 4, - } - : undefined - } - > - {t("recording.windows")} -
- {windowSources.map((source) => ( - } - selected={ - selectedSource === source.name - } - onClick={() => - handleSourceSelect(source) - } - > - {source.appName && - source.appName !== source.name - ? `${source.appName} — ${source.name}` - : source.name} - - ))} - - )} - {screenSources.length === 0 && - windowSources.length === 0 && ( -
- {t("recording.noSourcesFound")} -
- )} - - )} - - )} - - {activeDropdown === "mic" && ( - <> -
- {t("recording.microphone")} -
- - ) : ( - - ) - } - selected={systemAudioEnabled} - onClick={() => { - setSystemAudioEnabled(!systemAudioEnabled); - }} - > - {systemAudioEnabled - ? t("recording.disableSystemAudio") - : t("recording.enableSystemAudio")} - - {microphoneEnabled && ( - } - onClick={() => { - setMicrophoneEnabled(false); - setActiveDropdown("none"); - }} - > - {t("recording.turnOffMicrophone")} - - )} - {!microphoneEnabled && ( -
- {t("recording.selectMicToEnable")} -
- )} - {devices.map((device) => ( - { - setMicrophoneEnabled(true); - setSelectedDeviceId(device.deviceId); - setMicrophoneDeviceId( - device.deviceId === "default" - ? undefined - : device.deviceId, - ); - }} - /> - ))} - {devices.length === 0 && ( -
- {t("recording.noMicrophonesFound")} -
- )} - - )} - - {activeDropdown === "webcam" && ( - <> -
{t("recording.webcam")}
- {webcamEnabled && ( - <> - } - onClick={() => { - setWebcamEnabled(false); - setActiveDropdown("none"); - }} - > - {t("recording.turnOffWebcam")} - - - ) : ( - - ) - } - selected={showFloatingWebcamPreview} - onClick={() => { - setShowFloatingWebcamPreview( - (current) => !current, - ); - }} - > - {showFloatingWebcamPreview - ? t("recording.hideFloatingWebcamPreview") - : t("recording.showFloatingWebcamPreview")} - - - )} - {!webcamEnabled && ( -
- {t("recording.selectWebcamToEnable")} -
- )} - {showWebcamControls && ( -
-
-
-
- )} - {videoDevices.map((device) => ( - - ) : ( - - ) - } - selected={ - webcamEnabled && - (webcamDeviceId === device.deviceId || - selectedVideoDeviceId === device.deviceId) - } - onClick={() => { - setWebcamEnabled(true); - setSelectedVideoDeviceId(device.deviceId); - setWebcamDeviceId(device.deviceId); - }} - > - {device.label} - - ))} - {videoDevices.length === 0 && ( -
- {t("recording.noWebcamsFound")} -
- )} - - )} - - {activeDropdown === "countdown" && ( - <> -
- {t("recording.countdownDelay")} -
- {COUNTDOWN_OPTIONS.map((delay) => ( - } - selected={countdownDelay === delay} - onClick={() => { - setCountdownDelay(delay); - setActiveDropdown("none"); - }} - > - {delay === 0 ? t("recording.noDelay") : `${delay}s`} - - ))} - - )} - - {activeDropdown === "more" && ( - <> - {supportsHudCaptureProtection && ( - - ) : ( - - ) - } - selected={hideHudFromCapture} - onClick={() => { - void toggleHudCaptureProtection(); - }} - > - {hideHudFromCapture - ? t("recording.hideHudFromVideo") - : t("recording.showHudInVideo")} - - )} - } - onClick={chooseRecordingsDirectory} - > - {t("recording.recordingsFolder")} - - } - onClick={openVideoFile} - > - {t("recording.openVideoFile")} - - } - onClick={() => void openProjectBrowser()} - > - {t("recording.openProject")} - -
- {t("recording.language")} -
- {SUPPORTED_LOCALES.map((code) => ( - } - selected={locale === code} - onClick={() => { - setLocale(code as AppLocale); - setActiveDropdown("none"); - }} - > - {LOCALE_LABELS[code] ?? code} - - ))} - {appVersion && ( -
- v{appVersion} -
- )} - - )} -
- )} -
- -
-
- -
- -
- - - -
- - - {recording ? recordingControls : idleControls} - - -
-
-
- {showRecordingWebcamPreview && ( -
-
- )} -
-
-
- ); -} diff --git a/src/components/launch/LaunchWindow/DropdownContent.tsx b/src/components/launch/LaunchWindow/DropdownContent.tsx new file mode 100644 index 00000000..41671281 --- /dev/null +++ b/src/components/launch/LaunchWindow/DropdownContent.tsx @@ -0,0 +1,380 @@ +import { + AppWindow, + Eye, + EyeSlash as EyeOff, + FolderOpen, + MicrophoneSlash as MicOff, + Monitor, + SpeakerHigh as Volume2, + SpeakerX as VolumeX, + Timer, + Translate as Languages, + VideoCamera as Video, + VideoCamera as VideoIcon, + VideoCameraSlash as VideoOff, +} from "@phosphor-icons/react"; +import type React from "react"; +import { useI18n } from "@/contexts/I18nContext"; +import { useScopedT } from "@/contexts/I18nContext"; +import type { AppLocale } from "@/i18n/config"; +import { SUPPORTED_LOCALES } from "@/i18n/config"; +import { DropdownItem, MicDeviceRow } from "./helperComponents"; +import { COUNTDOWN_OPTIONS, type DesktopSource, LOCALE_LABELS } from "./types"; +import styles from "./LaunchWindow.module.css"; + +interface DropdownContentProps { + activeDropdown: "sources" | "more" | "mic" | "countdown" | "webcam"; + setActiveDropdown: (v: "none" | "sources" | "more" | "mic" | "countdown" | "webcam") => void; + sourcesLoading: boolean; + screenSources: DesktopSource[]; + windowSources: DesktopSource[]; + selectedSource: string; + onSourceSelect: (source: DesktopSource) => void; + systemAudioEnabled: boolean; + setSystemAudioEnabled: (v: boolean) => void; + microphoneEnabled: boolean; + setMicrophoneEnabled: (v: boolean) => void; + microphoneDeviceId: string | undefined; + selectedDeviceId: string | null; + setSelectedDeviceId: (v: string) => void; + setMicrophoneDeviceId: (v: string | undefined) => void; + devices: { deviceId: string; label: string }[]; + webcamEnabled: boolean; + setWebcamEnabled: (v: boolean) => void; + webcamDeviceId: string | undefined; + selectedVideoDeviceId: string | null; + setSelectedVideoDeviceId: (v: string) => void; + setWebcamDeviceId: (v: string) => void; + videoDevices: { deviceId: string; label: string }[]; + showWebcamControls: boolean; + showFloatingWebcamPreview: boolean; + setShowFloatingWebcamPreview: React.Dispatch>; + setWebcamPreviewNode: (node: HTMLVideoElement | null) => void; + countdownDelay: number; + setCountdownDelay: (v: number) => void; + supportsHudCaptureProtection: boolean; + hideHudFromCapture: boolean; + onToggleHudCaptureProtection: () => void; + onChooseRecordingsDirectory: () => void; + onOpenVideoFile: () => void; + onOpenProjectBrowser: () => void; + appVersion: string | null; +} + +export function DropdownContent({ + activeDropdown, + setActiveDropdown, + sourcesLoading, + screenSources, + windowSources, + selectedSource, + onSourceSelect, + systemAudioEnabled, + setSystemAudioEnabled, + microphoneEnabled, + setMicrophoneEnabled, + microphoneDeviceId, + selectedDeviceId, + setSelectedDeviceId, + setMicrophoneDeviceId, + devices, + webcamEnabled, + setWebcamEnabled, + webcamDeviceId, + selectedVideoDeviceId, + setSelectedVideoDeviceId, + setWebcamDeviceId, + videoDevices, + showWebcamControls, + showFloatingWebcamPreview, + setShowFloatingWebcamPreview, + setWebcamPreviewNode, + countdownDelay, + setCountdownDelay, + supportsHudCaptureProtection, + hideHudFromCapture, + onToggleHudCaptureProtection, + onChooseRecordingsDirectory, + onOpenVideoFile, + onOpenProjectBrowser, + appVersion, +}: DropdownContentProps) { + const { locale, setLocale } = useI18n(); + const t = useScopedT("launch"); + + return ( +
+ {activeDropdown === "sources" && ( + <> + {sourcesLoading ? ( +
+
+
+ ) : ( + <> + {screenSources.length > 0 && ( + <> +
{t("recording.screens")}
+ {screenSources.map((source) => ( + } + selected={selectedSource === source.name} + onClick={() => onSourceSelect(source)} + > + {source.name} + + ))} + + )} + {windowSources.length > 0 && ( + <> +
0 ? { marginTop: 4 } : undefined} + > + {t("recording.windows")} +
+ {windowSources.map((source) => ( + } + selected={selectedSource === source.name} + onClick={() => onSourceSelect(source)} + > + {source.appName && source.appName !== source.name + ? `${source.appName} — ${source.name}` + : source.name} + + ))} + + )} + {screenSources.length === 0 && windowSources.length === 0 && ( +
+ {t("recording.noSourcesFound")} +
+ )} + + )} + + )} + + {activeDropdown === "mic" && ( + <> +
{t("recording.microphone")}
+ : } + selected={systemAudioEnabled} + onClick={() => setSystemAudioEnabled(!systemAudioEnabled)} + > + {systemAudioEnabled + ? t("recording.disableSystemAudio") + : t("recording.enableSystemAudio")} + + {microphoneEnabled && ( + } + onClick={() => { + setMicrophoneEnabled(false); + setActiveDropdown("none"); + }} + > + {t("recording.turnOffMicrophone")} + + )} + {!microphoneEnabled && ( +
+ {t("recording.selectMicToEnable")} +
+ )} + {devices.map((device) => ( + { + setMicrophoneEnabled(true); + setSelectedDeviceId(device.deviceId); + setMicrophoneDeviceId( + device.deviceId === "default" ? undefined : device.deviceId, + ); + }} + /> + ))} + {devices.length === 0 && ( +
+ {t("recording.noMicrophonesFound")} +
+ )} + + )} + + {activeDropdown === "webcam" && ( + <> +
{t("recording.webcam")}
+ {webcamEnabled && ( + <> + } + onClick={() => { + setWebcamEnabled(false); + setActiveDropdown("none"); + }} + > + {t("recording.turnOffWebcam")} + + + ) : ( + + ) + } + selected={showFloatingWebcamPreview} + onClick={() => setShowFloatingWebcamPreview((current) => !current)} + > + {showFloatingWebcamPreview + ? t("recording.hideFloatingWebcamPreview") + : t("recording.showFloatingWebcamPreview")} + + + )} + {!webcamEnabled && ( +
+ {t("recording.selectWebcamToEnable")} +
+ )} + {showWebcamControls && ( +
+
+
+
+ )} + {videoDevices.map((device) => ( + + ) : ( + + ) + } + selected={ + webcamEnabled && + (webcamDeviceId === device.deviceId || + selectedVideoDeviceId === device.deviceId) + } + onClick={() => { + setWebcamEnabled(true); + setSelectedVideoDeviceId(device.deviceId); + setWebcamDeviceId(device.deviceId); + }} + > + {device.label} + + ))} + {videoDevices.length === 0 && ( +
+ {t("recording.noWebcamsFound")} +
+ )} + + )} + + {activeDropdown === "countdown" && ( + <> +
{t("recording.countdownDelay")}
+ {COUNTDOWN_OPTIONS.map((delay) => ( + } + selected={countdownDelay === delay} + onClick={() => { + setCountdownDelay(delay); + setActiveDropdown("none"); + }} + > + {delay === 0 ? t("recording.noDelay") : `${delay}s`} + + ))} + + )} + + {activeDropdown === "more" && ( + <> + {supportsHudCaptureProtection && ( + : } + selected={hideHudFromCapture} + onClick={() => void onToggleHudCaptureProtection()} + > + {hideHudFromCapture + ? t("recording.hideHudFromVideo") + : t("recording.showHudInVideo")} + + )} + } + onClick={onChooseRecordingsDirectory} + > + {t("recording.recordingsFolder")} + + } onClick={onOpenVideoFile}> + {t("recording.openVideoFile")} + + } + onClick={() => void onOpenProjectBrowser()} + > + {t("recording.openProject")} + +
+ {t("recording.language")} +
+ {SUPPORTED_LOCALES.map((code) => ( + } + selected={locale === code} + onClick={() => { + setLocale(code as AppLocale); + setActiveDropdown("none"); + }} + > + {LOCALE_LABELS[code] ?? code} + + ))} + {appVersion && ( +
+ v{appVersion} +
+ )} + + )} +
+ ); +} diff --git a/src/components/launch/LaunchWindow/HudControls.tsx b/src/components/launch/LaunchWindow/HudControls.tsx new file mode 100644 index 00000000..bbee287b --- /dev/null +++ b/src/components/launch/LaunchWindow/HudControls.tsx @@ -0,0 +1,302 @@ +import { + ArrowCircleUp as ArrowUpCircle, + ArrowClockwise as RefreshCw, + CaretUp as ChevronUp, + CheckCircle as CheckCircle2, + DotsThreeVertical as MoreVertical, + Microphone as Mic, + MicrophoneSlash as MicOff, + Minus, + Monitor, + Pause, + Play, + Stop as Square, + Timer, + VideoCamera as Video, + VideoCameraSlash as VideoOff, + X, +} from "@phosphor-icons/react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { ContentClamp } from "@/components/ui/content-clamp"; +import { IconButton, Separator } from "./helperComponents"; +import styles from "./LaunchWindow.module.css"; + +interface UpdateBadgeProps { + updateStatus: { + status: "idle" | "checking" | "up-to-date" | "available" | "downloading" | "ready" | "error"; + currentVersion: string; + availableVersion: string | null; + detail?: string; + }; + updateActionPending: boolean; + onUpdateClick: () => void; +} + +export function UpdateBadge({ updateStatus, updateActionPending, onUpdateClick }: UpdateBadgeProps) { + const t = useScopedT("launch"); + + const label = + updateStatus.status === "up-to-date" + ? t("recording.update.updated") + : t("recording.update.update"); + + const title = (() => { + switch (updateStatus.status) { + case "up-to-date": + return t("recording.update.upToDateTitle", "Recordly {{version}} is up to date.", { + version: updateStatus.currentVersion, + }); + case "available": + case "ready": + return updateStatus.availableVersion + ? t("recording.update.availableTitle", "Recordly {{version}} is available.", { + version: updateStatus.availableVersion, + }) + : t("recording.update.availableGenericTitle"); + case "downloading": + return updateStatus.detail ?? t("recording.update.downloadingTitle"); + case "checking": + return t("recording.update.checkingTitle"); + case "error": + return updateStatus.detail ?? t("recording.update.errorTitle"); + default: + return t("recording.update.idleTitle"); + } + })(); + + const className = `${styles.updateBadge} ${updateStatus.status === "up-to-date" ? styles.updateBadgeQuiet : styles.updateBadgeHot} ${styles.electronNoDrag}`; + + const icon = (() => { + switch (updateStatus.status) { + case "up-to-date": + return ; + case "checking": + case "downloading": + return ; + default: + return ; + } + })(); + + return ( + + ); +} + +interface RecordingControlsProps { + paused: boolean; + elapsed: number; + formatTime: (s: number) => string; + microphoneEnabled: boolean; + resumeRecording: () => void; + pauseRecording: () => void; + toggleRecording: () => void; + cancelRecording: () => void; +} + +export function RecordingControls({ + paused, + elapsed, + formatTime, + microphoneEnabled, + resumeRecording, + pauseRecording, + toggleRecording, + cancelRecording, +}: RecordingControlsProps) { + const t = useScopedT("launch"); + + return ( + <> +
+
+ + {paused ? t("recording.paused") : t("recording.rec")} + +
+ + + {formatTime(elapsed)} + + + + + + {microphoneEnabled ? : } + + + + + + {paused ? ( + + ) : ( + + )} + + + + + + + window.electronAPI?.hudOverlayHide?.()} + title={t("recording.hideHud")} + > + + + + + + + + ); +} + +interface IdleControlsProps { + selectedSource: string; + activeDropdown: string; + toggleDropdown: (which: "sources" | "more" | "mic" | "countdown" | "webcam") => void; + hasSelectedSource: boolean; + toggleRecording: () => void; + microphoneEnabled: boolean; + toggleMicrophone: () => void; + webcamEnabled: boolean; + toggleWebcam: () => void; + countdownDelay: number; + countdownActive: boolean; + moreButtonRef: React.RefObject; +} + +export function IdleControls({ + selectedSource, + activeDropdown, + toggleDropdown, + hasSelectedSource, + toggleRecording, + microphoneEnabled, + toggleMicrophone, + webcamEnabled, + toggleWebcam, + countdownDelay, + countdownActive, + moreButtonRef, +}: IdleControlsProps) { + const t = useScopedT("launch"); + + return ( + <> + + + + + + {microphoneEnabled ? : } + + + + {webcamEnabled ? + + toggleDropdown("countdown")} + title={t("recording.countdownDelay")} + className={countdownDelay > 0 ? styles.ibActive : ""} + > + + + + + + + + + + toggleDropdown("more")} + title={t("recording.more")} + > + + + + window.electronAPI?.hudOverlayHide?.()} + title={t("recording.hideHud")} + > + + + + window.electronAPI?.hudOverlayClose?.()} + title={t("recording.closeApp")} + > + + + + ); +} diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow/LaunchWindow.module.css similarity index 100% rename from src/components/launch/LaunchWindow.module.css rename to src/components/launch/LaunchWindow/LaunchWindow.module.css diff --git a/src/components/launch/LaunchWindow/helperComponents.tsx b/src/components/launch/LaunchWindow/helperComponents.tsx new file mode 100644 index 00000000..8cb1d434 --- /dev/null +++ b/src/components/launch/LaunchWindow/helperComponents.tsx @@ -0,0 +1,91 @@ +import { + Microphone as Mic, + MicrophoneSlash as MicOff, +} from "@phosphor-icons/react"; +import type { ReactNode } from "react"; +import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter"; +import { AudioLevelMeter } from "@/components/ui/audio-level-meter"; +import styles from "./LaunchWindow.module.css"; + +export function IconButton({ + onClick, + title, + className = "", + buttonRef, + children, +}: { + onClick?: () => void; + title?: string; + className?: string; + buttonRef?: React.Ref; + children: ReactNode; +}) { + return ( + + ); +} + +export function DropdownItem({ + onClick, + selected, + icon, + children, + trailing, +}: { + onClick: () => void; + selected?: boolean; + icon: ReactNode; + children: ReactNode; + trailing?: ReactNode; +}) { + return ( + + ); +} + +export function Separator({ dropdown = false }: { dropdown?: boolean }) { + return
; +} + +export function MicDeviceRow({ + device, + selected, + onSelect, +}: { + device: { deviceId: string; label: string }; + selected: boolean; + onSelect: () => void; +}) { + const { level } = useAudioLevelMeter({ + enabled: true, + deviceId: device.deviceId, + }); + + return ( + + ); +} diff --git a/src/components/launch/LaunchWindow/hooks.ts b/src/components/launch/LaunchWindow/hooks.ts new file mode 100644 index 00000000..fefc8d09 --- /dev/null +++ b/src/components/launch/LaunchWindow/hooks.ts @@ -0,0 +1,372 @@ +import type React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + DEFAULT_RECORDING_HUD_OFFSET, + DEFAULT_WEBCAM_PREVIEW_OFFSET, + WEBCAM_PREVIEW_DRAG_THRESHOLD, +} from "./types"; + +export function useDragHandlers({ + webcamEnabled, + showRecordingWebcamPreview, + hudBarRef, +}: { + webcamEnabled: boolean; + showRecordingWebcamPreview: boolean; + hudBarRef: React.RefObject; +}) { + const [webcamPreviewOffset, setWebcamPreviewOffset] = useState(DEFAULT_WEBCAM_PREVIEW_OFFSET); + const [recordingHudOffset, setRecordingHudOffset] = useState(DEFAULT_RECORDING_HUD_OFFSET); + + const webcamPreviewDragStartRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + originX: number; + originY: number; + initialLeft: number; + initialTop: number; + previewWidth: number; + previewHeight: number; + dragging: boolean; + } | null>(null); + + const hudDragStartRef = useRef< + | { + pointerId: number; + mode: "webcam-preview"; + startX: number; + startY: number; + originX: number; + originY: number; + initialLeft: number; + initialTop: number; + hudWidth: number; + hudHeight: number; + } + | { + pointerId: number; + mode: "overlay"; + } + | null + >(null); + + const isHudDraggingRef = useRef(false); + const isWebcamPreviewDraggingRef = useRef(false); + + useEffect(() => { + if (!webcamEnabled) { + setWebcamPreviewOffset(DEFAULT_WEBCAM_PREVIEW_OFFSET); + setRecordingHudOffset(DEFAULT_RECORDING_HUD_OFFSET); + webcamPreviewDragStartRef.current = null; + isWebcamPreviewDraggingRef.current = false; + } + }, [webcamEnabled]); + + useEffect(() => { + if (!showRecordingWebcamPreview) { + setRecordingHudOffset(DEFAULT_RECORDING_HUD_OFFSET); + } + }, [showRecordingWebcamPreview]); + + const handleWebcamPreviewPointerDown = (event: React.PointerEvent) => { + if (event.button !== 0) return; + const previewRect = event.currentTarget.getBoundingClientRect(); + event.preventDefault(); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + webcamPreviewDragStartRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: webcamPreviewOffset.x, + originY: webcamPreviewOffset.y, + initialLeft: previewRect.left, + initialTop: previewRect.top, + previewWidth: previewRect.width, + previewHeight: previewRect.height, + dragging: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handleWebcamPreviewPointerMove = (event: React.PointerEvent) => { + const dragState = webcamPreviewDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + const deltaX = event.clientX - dragState.startX; + const deltaY = event.clientY - dragState.startY; + if (!dragState.dragging && Math.hypot(deltaX, deltaY) < WEBCAM_PREVIEW_DRAG_THRESHOLD) return; + if (!dragState.dragging) { + dragState.dragging = true; + isWebcamPreviewDraggingRef.current = true; + } + const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0); + const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); + const unclampedLeft = dragState.initialLeft + deltaX; + const unclampedTop = dragState.initialTop + deltaY; + const clampedLeft = Math.min( + Math.max(0, unclampedLeft), + Math.max(0, viewportWidth - dragState.previewWidth), + ); + const clampedTop = Math.min( + Math.max(0, unclampedTop), + Math.max(0, viewportHeight - dragState.previewHeight), + ); + setWebcamPreviewOffset({ + x: dragState.originX + (clampedLeft - dragState.initialLeft), + y: dragState.originY + (clampedTop - dragState.initialTop), + }); + }; + + const handleWebcamPreviewPointerUp = (event: React.PointerEvent) => { + const dragState = webcamPreviewDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + const wasDragging = dragState.dragging; + webcamPreviewDragStartRef.current = null; + isWebcamPreviewDraggingRef.current = false; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (wasDragging) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }; + + const handleHudBarPointerDown = (event: React.PointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + isHudDraggingRef.current = true; + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + + if (showRecordingWebcamPreview && hudBarRef.current) { + const hudRect = hudBarRef.current.getBoundingClientRect(); + hudDragStartRef.current = { + pointerId: event.pointerId, + mode: "webcam-preview", + startX: event.clientX, + startY: event.clientY, + originX: recordingHudOffset.x, + originY: recordingHudOffset.y, + initialLeft: hudRect.left, + initialTop: hudRect.top, + hudWidth: hudRect.width, + hudHeight: hudRect.height, + }; + return; + } + + hudDragStartRef.current = { pointerId: event.pointerId, mode: "overlay" }; + window.electronAPI?.hudOverlayDrag?.("start", event.screenX, event.screenY); + }; + + const handleHudBarPointerMove = (event: React.PointerEvent) => { + const dragState = hudDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + + if (dragState.mode === "webcam-preview") { + const deltaX = event.clientX - dragState.startX; + const deltaY = event.clientY - dragState.startY; + const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0); + const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); + const unclampedLeft = dragState.initialLeft + deltaX; + const unclampedTop = dragState.initialTop + deltaY; + const clampedLeft = Math.min( + Math.max(0, unclampedLeft), + Math.max(0, viewportWidth - dragState.hudWidth), + ); + const clampedTop = Math.min( + Math.max(0, unclampedTop), + Math.max(0, viewportHeight - dragState.hudHeight), + ); + setRecordingHudOffset({ + x: dragState.originX + (clampedLeft - dragState.initialLeft), + y: dragState.originY + (clampedTop - dragState.initialTop), + }); + return; + } + + window.electronAPI?.hudOverlayDrag?.("move", event.screenX, event.screenY); + }; + + const handleHudBarPointerUp = (event: React.PointerEvent) => { + const dragState = hudDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + + if (dragState.mode === "overlay") { + window.electronAPI?.hudOverlayDrag?.("end", 0, 0); + } + + hudDragStartRef.current = null; + const wasDragging = isHudDraggingRef.current; + isHudDraggingRef.current = false; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (wasDragging) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }; + + return { + webcamPreviewOffset, + recordingHudOffset, + isHudDraggingRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + handleWebcamPreviewPointerDown, + handleWebcamPreviewPointerMove, + handleWebcamPreviewPointerUp, + handleHudBarPointerDown, + handleHudBarPointerMove, + handleHudBarPointerUp, + }; +} + +export function useWebcamPreview({ + shouldStreamWebcamPreview, + webcamDeviceId, +}: { + shouldStreamWebcamPreview: boolean; + webcamDeviceId: string | undefined; +}) { + const webcamPreviewRef = useRef(null); + const recordingWebcamPreviewRef = useRef(null); + const previewStreamRef = useRef(null); + + const attachPreviewStreamToNode = useCallback((videoElement: HTMLVideoElement | null) => { + const previewStream = previewStreamRef.current; + if (!videoElement || !previewStream || videoElement.srcObject === previewStream) return; + videoElement.srcObject = previewStream; + const playPromise = videoElement.play(); + if (playPromise) { + playPromise.catch(() => { + // Ignore autoplay interruptions while the preview element mounts. + }); + } + }, []); + + const setWebcamPreviewNode = useCallback( + (node: HTMLVideoElement | null) => { + webcamPreviewRef.current = node; + attachPreviewStreamToNode(node); + }, + [attachPreviewStreamToNode], + ); + + const setRecordingWebcamPreviewNode = useCallback( + (node: HTMLVideoElement | null) => { + recordingWebcamPreviewRef.current = node; + attachPreviewStreamToNode(node); + }, + [attachPreviewStreamToNode], + ); + + useEffect(() => { + let mounted = true; + + const startPreview = async () => { + if (!shouldStreamWebcamPreview) return; + + try { + const previewStream = await navigator.mediaDevices.getUserMedia({ + video: webcamDeviceId + ? { + deviceId: { exact: webcamDeviceId }, + width: { ideal: 320 }, + height: { ideal: 320 }, + frameRate: { ideal: 24, max: 30 }, + } + : { + width: { ideal: 320 }, + height: { ideal: 320 }, + frameRate: { ideal: 24, max: 30 }, + }, + audio: false, + }); + + if (!mounted) { + previewStream.getTracks().forEach((track) => track.stop()); + return; + } + + previewStreamRef.current = previewStream; + attachPreviewStreamToNode(webcamPreviewRef.current); + attachPreviewStreamToNode(recordingWebcamPreviewRef.current); + } catch (error) { + console.warn("Failed to start live webcam preview:", error); + } + }; + + void startPreview(); + + return () => { + mounted = false; + const previewNode = webcamPreviewRef.current; + const recordingPreviewNode = recordingWebcamPreviewRef.current; + const previewStream = previewStreamRef.current; + + [previewNode, recordingPreviewNode] + .filter((node): node is HTMLVideoElement => Boolean(node)) + .forEach((videoElement) => { + videoElement.pause(); + videoElement.srcObject = null; + }); + previewStream?.getTracks().forEach((track) => track.stop()); + if (previewStreamRef.current === previewStream) { + previewStreamRef.current = null; + } + }; + }, [attachPreviewStreamToNode, shouldStreamWebcamPreview, webcamDeviceId]); + + return { setWebcamPreviewNode, setRecordingWebcamPreviewNode }; +} + +export function useRecordingTimer({ recording, paused }: { recording: boolean; paused: boolean }) { + const [recordingStart, setRecordingStart] = useState(null); + const [elapsed, setElapsed] = useState(0); + const [pausedAt, setPausedAt] = useState(null); + const [pausedTotal, setPausedTotal] = useState(0); + + useEffect(() => { + let timer: NodeJS.Timeout | null = null; + if (recording) { + if (!recordingStart) { + setRecordingStart(Date.now()); + setPausedTotal(0); + } + if (paused) { + if (!pausedAt) setPausedAt(Date.now()); + if (timer) clearInterval(timer); + } else { + if (pausedAt) { + setPausedTotal((prev) => prev + (Date.now() - pausedAt)); + setPausedAt(null); + } + timer = setInterval(() => { + if (recordingStart) { + setElapsed(Math.floor((Date.now() - recordingStart - pausedTotal) / 1000)); + } + }, 1000); + } + } else { + setRecordingStart(null); + setElapsed(0); + setPausedAt(null); + setPausedTotal(0); + if (timer) clearInterval(timer); + } + return () => { + if (timer) clearInterval(timer); + }; + }, [recording, recordingStart, paused, pausedAt, pausedTotal]); + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60) + .toString() + .padStart(2, "0"); + const s = (seconds % 60).toString().padStart(2, "0"); + return `${m}:${s}`; + }; + + return { elapsed, formatTime }; +} diff --git a/src/components/launch/LaunchWindow/index.tsx b/src/components/launch/LaunchWindow/index.tsx new file mode 100644 index 00000000..036a0252 --- /dev/null +++ b/src/components/launch/LaunchWindow/index.tsx @@ -0,0 +1,385 @@ +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; +import { RxDragHandleDots2 } from "react-icons/rx"; +import { useScopedT } from "@/contexts/I18nContext"; +import { useMicrophoneDevices } from "@/hooks/useMicrophoneDevices"; +import { useScreenRecorder } from "@/hooks/useScreenRecorder"; +import { useVideoDevices } from "@/hooks/useVideoDevices"; +import ProjectBrowserDialog, { + type ProjectLibraryEntry, +} from "@/components/video-editor/ProjectBrowserDialog"; +import { DropdownContent } from "./DropdownContent"; +import { useDragHandlers, useRecordingTimer, useWebcamPreview } from "./hooks"; +import { IdleControls, RecordingControls, UpdateBadge } from "./HudControls"; +import type { DesktopSource } from "./types"; +import { useLaunchWindowActions } from "./useLaunchWindowActions"; +import { useLaunchWindowSetup } from "./useLaunchWindowSetup"; +import styles from "./LaunchWindow.module.css"; + +export function LaunchWindow() { + const t = useScopedT("launch"); + + const { + recording, + paused, + countdownActive, + toggleRecording, + pauseRecording, + resumeRecording, + cancelRecording, + microphoneEnabled, + setMicrophoneEnabled, + microphoneDeviceId, + setMicrophoneDeviceId, + systemAudioEnabled, + setSystemAudioEnabled, + webcamEnabled, + setWebcamEnabled, + webcamDeviceId, + setWebcamDeviceId, + countdownDelay, + setCountdownDelay, + preparePermissions, + } = useScreenRecorder(); + + const [activeDropdown, setActiveDropdown] = useState< + "none" | "sources" | "more" | "mic" | "countdown" | "webcam" + >("none"); + const [projectBrowserOpen, setProjectBrowserOpen] = useState(false); + const [projectLibraryEntries, setProjectLibraryEntries] = useState([]); + const [sources, setSources] = useState([]); + const [sourcesLoading, setSourcesLoading] = useState(false); + const [showFloatingWebcamPreview, setShowFloatingWebcamPreview] = useState(true); + const [, setRecordingsDirectory] = useState(null); + + const dropdownRef = useRef(null); + const hudContentRef = useRef(null); + const hudBarRef = useRef(null); + const moreButtonRef = useRef(null); + const recordingWebcamPreviewContainerRef = useRef(null); + + const micDropdownOpen = activeDropdown === "mic"; + const webcamDropdownOpen = activeDropdown === "webcam"; + const showWebcamControls = webcamEnabled && !recording; + const showRecordingWebcamPreview = webcamEnabled && showFloatingWebcamPreview; + const shouldStreamWebcamPreview = + webcamEnabled && (showFloatingWebcamPreview || (showWebcamControls && webcamDropdownOpen)); + + const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices( + microphoneEnabled || micDropdownOpen, + microphoneDeviceId, + ); + const { + devices: videoDevices, + selectedDeviceId: selectedVideoDeviceId, + setSelectedDeviceId: setSelectedVideoDeviceId, + } = useVideoDevices(webcamEnabled || webcamDropdownOpen); + + const { + webcamPreviewOffset, + recordingHudOffset, + isHudDraggingRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + handleWebcamPreviewPointerDown, + handleWebcamPreviewPointerMove, + handleWebcamPreviewPointerUp, + handleHudBarPointerDown, + handleHudBarPointerMove, + handleHudBarPointerUp, + } = useDragHandlers({ webcamEnabled, showRecordingWebcamPreview, hudBarRef }); + + const { setWebcamPreviewNode, setRecordingWebcamPreviewNode } = useWebcamPreview({ + shouldStreamWebcamPreview, + webcamDeviceId, + }); + + const { elapsed, formatTime } = useRecordingTimer({ recording, paused }); + + const { + selectedSource, + setSelectedSource, + hasSelectedSource, + setHasSelectedSource, + platform, + appVersion, + updateStatus, + updateActionPending, + hideHudFromCapture, + setHideHudFromCapture, + handleUpdateButtonClick, + } = useLaunchWindowSetup({ + preparePermissions, + activeDropdown, + projectBrowserOpen, + showRecordingWebcamPreview, + hudContentRef, + hudBarRef, + recordingWebcamPreviewContainerRef, + }); + + const supportsHudCaptureProtection = platform !== "linux"; + + useEffect(() => { + if (!selectedDeviceId) return; + setMicrophoneDeviceId(selectedDeviceId === "default" ? undefined : selectedDeviceId); + }, [selectedDeviceId, setMicrophoneDeviceId]); + + useEffect(() => { + if (selectedVideoDeviceId && selectedVideoDeviceId !== "default") { + setWebcamDeviceId(selectedVideoDeviceId); + } + }, [selectedVideoDeviceId, setWebcamDeviceId]); + + useEffect(() => { + if (!webcamEnabled) setShowFloatingWebcamPreview(true); + }, [webcamEnabled]); + + // Click outside dropdown + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setActiveDropdown("none"); + setProjectBrowserOpen(false); + } + }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + // Recordings directory + useEffect(() => { + const load = async () => { + const result = await window.electronAPI.getRecordingsDirectory(); + if (result.success) setRecordingsDirectory(result.path); + }; + void load(); + }, []); + + const { + toggleDropdown, + handleSourceSelect, + openVideoFile, + openProjectBrowser, + openProjectFromLibrary, + chooseRecordingsDirectory, + toggleHudCaptureProtection, + toggleMicrophone, + toggleWebcam, + } = useLaunchWindowActions({ + activeDropdown, + projectBrowserOpen, + recording, + hideHudFromCapture, + setActiveDropdown, + setSelectedSource, + setHasSelectedSource, + setSources, + setSourcesLoading, + setProjectLibraryEntries, + setProjectBrowserOpen, + setRecordingsDirectory, + setHideHudFromCapture, + fetchSourcesOnOpen: true, + }); + + const screenSources = sources.filter((s) => s.sourceType === "screen"); + const windowSources = sources.filter((s) => s.sourceType === "window"); + const hudStateTransition = { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const }; + + return ( +
+
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false)} + onMouseLeave={() => { + if ( + !isHudDraggingRef.current && + !isWebcamPreviewDraggingRef.current && + !webcamPreviewDragStartRef.current + ) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }} + > +
+ {projectBrowserOpen ? ( +
+ { + void openProjectFromLibrary(projectPath); + }} + /> +
+ ) : null} + {activeDropdown !== "none" && ( + + )} +
+ +
+
+ +
+ +
+ + { + void handleUpdateButtonClick(); + }} + /> + +
+ + + {recording ? ( + + ) : ( + + )} + + +
+
+
+ {showRecordingWebcamPreview && ( +
+
+ )} +
+
+
+ ); +} diff --git a/src/components/launch/LaunchWindow/types.ts b/src/components/launch/LaunchWindow/types.ts new file mode 100644 index 00000000..e5f24228 --- /dev/null +++ b/src/components/launch/LaunchWindow/types.ts @@ -0,0 +1,24 @@ +export interface DesktopSource { + id: string; + name: string; + thumbnail: string | null; + display_id: string; + appIcon: string | null; + originalName: string; + sourceType: "screen" | "window"; + appName?: string; + windowTitle?: string; +} + +export const LOCALE_LABELS: Record = { + en: "EN", + es: "ES", + nl: "NL", + "zh-CN": "中文", + ko: "한국어", +}; + +export const COUNTDOWN_OPTIONS = [0, 3, 5, 10]; +export const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6; +export const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 }; +export const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 }; diff --git a/src/components/launch/LaunchWindow/useLaunchWindowActions.ts b/src/components/launch/LaunchWindow/useLaunchWindowActions.ts new file mode 100644 index 00000000..b4ae6e01 --- /dev/null +++ b/src/components/launch/LaunchWindow/useLaunchWindowActions.ts @@ -0,0 +1,208 @@ +import { useCallback } from "react"; +import type ProjectBrowserDialog from "@/components/video-editor/ProjectBrowserDialog"; +import type { DesktopSource } from "./types"; + +type ProjectLibraryEntry = React.ComponentProps["entries"][number]; + +function toProcessedDesktopSource(source: DesktopSource): ProcessedDesktopSource { + return { + id: source.id, + name: source.originalName, + thumbnail: source.thumbnail, + display_id: source.display_id, + appIcon: source.appIcon, + originalName: source.originalName, + sourceType: source.sourceType, + appName: source.appName, + windowTitle: source.windowTitle, + }; +} + +interface UseLaunchWindowActionsParams { + activeDropdown: "none" | "sources" | "more" | "mic" | "countdown" | "webcam"; + projectBrowserOpen: boolean; + recording: boolean; + hideHudFromCapture: boolean; + setActiveDropdown: (value: "none" | "sources" | "more" | "mic" | "countdown" | "webcam") => void; + setSelectedSource: (value: string) => void; + setHasSelectedSource: (value: boolean) => void; + setSources: (value: DesktopSource[]) => void; + setSourcesLoading: (value: boolean) => void; + setProjectLibraryEntries: (value: ProjectLibraryEntry[]) => void; + setProjectBrowserOpen: (value: boolean) => void; + setRecordingsDirectory: (value: string | null) => void; + setHideHudFromCapture: (value: boolean) => void; + fetchSourcesOnOpen: boolean; +} + +export function useLaunchWindowActions({ + activeDropdown, + projectBrowserOpen, + recording, + hideHudFromCapture, + setActiveDropdown, + setSelectedSource, + setHasSelectedSource, + setSources, + setSourcesLoading, + setProjectLibraryEntries, + setProjectBrowserOpen, + setRecordingsDirectory, + setHideHudFromCapture, + fetchSourcesOnOpen, +}: UseLaunchWindowActionsParams) { + const fetchSources = useCallback(async () => { + if (!window.electronAPI) return; + setSourcesLoading(true); + try { + const rawSources = await window.electronAPI.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 160, height: 90 }, + fetchWindowIcons: true, + }); + setSources( + rawSources.map((source) => { + const isWindow = source.id.startsWith("window:"); + const type = source.sourceType ?? (isWindow ? "window" : "screen"); + let displayName = source.name; + let appName = source.appName; + if (isWindow && !appName && source.name.includes(" — ")) { + const parts = source.name.split(" — "); + appName = parts[0]?.trim(); + displayName = parts.slice(1).join(" — ").trim() || source.name; + } else if (isWindow && source.windowTitle) { + displayName = source.windowTitle; + } + return { + id: source.id, + name: displayName, + thumbnail: source.thumbnail ?? null, + display_id: source.display_id ?? "", + appIcon: source.appIcon ?? null, + originalName: source.name, + sourceType: type, + appName, + windowTitle: source.windowTitle ?? displayName, + }; + }), + ); + } catch (error) { + console.error("Failed to fetch sources:", error); + } finally { + setSourcesLoading(false); + } + }, [setSources, setSourcesLoading]); + + const toggleDropdown = useCallback( + (which: "sources" | "more" | "mic" | "countdown" | "webcam") => { + setProjectBrowserOpen(false); + setActiveDropdown(activeDropdown === which ? "none" : which); + if (fetchSourcesOnOpen && activeDropdown !== which && which === "sources") { + void fetchSources(); + } + }, + [activeDropdown, fetchSources, fetchSourcesOnOpen, setActiveDropdown, setProjectBrowserOpen], + ); + + const handleSourceSelect = useCallback( + async (source: DesktopSource) => { + const processedSource = toProcessedDesktopSource(source); + await window.electronAPI.selectSource(processedSource); + setSelectedSource(source.name); + setHasSelectedSource(true); + setActiveDropdown("none"); + window.electronAPI.showSourceHighlight?.(processedSource); + }, + [setActiveDropdown, setHasSelectedSource, setSelectedSource], + ); + + const openVideoFile = useCallback(async () => { + setActiveDropdown("none"); + const result = await window.electronAPI.openVideoFilePicker(); + if (result.canceled) return; + if (result.success && result.path) { + await window.electronAPI.setCurrentVideoPath(result.path); + await window.electronAPI.switchToEditor(); + } + }, [setActiveDropdown]); + + const refreshProjectLibrary = useCallback(async () => { + try { + const result = await window.electronAPI.listProjectFiles(); + if (!result.success) return; + setProjectLibraryEntries(result.entries); + } catch (error) { + console.error("Failed to load project library:", error); + } + }, [setProjectLibraryEntries]); + + const openProjectBrowser = useCallback(async () => { + if (projectBrowserOpen) { + setProjectBrowserOpen(false); + return; + } + setActiveDropdown("none"); + await refreshProjectLibrary(); + setProjectBrowserOpen(true); + }, [projectBrowserOpen, refreshProjectLibrary, setActiveDropdown, setProjectBrowserOpen]); + + const openProjectFromLibrary = useCallback( + async (projectPath: string) => { + try { + const result = await window.electronAPI.openProjectFileAtPath(projectPath); + if (result.canceled || !result.success) return; + setProjectBrowserOpen(false); + await window.electronAPI.switchToEditor(); + } catch (error) { + console.error("Failed to open project from library:", error); + } + }, + [setProjectBrowserOpen], + ); + + const chooseRecordingsDirectory = useCallback(async () => { + setActiveDropdown("none"); + const result = await window.electronAPI.chooseRecordingsDirectory(); + if (result.canceled) return; + if (result.success && result.path) setRecordingsDirectory(result.path); + }, [setActiveDropdown, setRecordingsDirectory]); + + const toggleHudCaptureProtection = useCallback(async () => { + const nextValue = !hideHudFromCapture; + setHideHudFromCapture(nextValue); + try { + const result = await window.electronAPI.setHudOverlayCaptureProtection(nextValue); + if (!result.success) { + setHideHudFromCapture(!nextValue); + return; + } + setHideHudFromCapture(result.enabled); + } catch (error) { + console.error("Failed to update HUD capture protection:", error); + setHideHudFromCapture(!nextValue); + } + }, [hideHudFromCapture, setHideHudFromCapture]); + + const toggleMicrophone = useCallback(() => { + if (recording) return; + toggleDropdown("mic"); + }, [recording, toggleDropdown]); + + const toggleWebcam = useCallback(() => { + if (recording) return; + toggleDropdown("webcam"); + }, [recording, toggleDropdown]); + + return { + fetchSources, + toggleDropdown, + handleSourceSelect, + openVideoFile, + openProjectBrowser, + openProjectFromLibrary, + chooseRecordingsDirectory, + toggleHudCaptureProtection, + toggleMicrophone, + toggleWebcam, + }; +} \ No newline at end of file diff --git a/src/components/launch/LaunchWindow/useLaunchWindowSetup.ts b/src/components/launch/LaunchWindow/useLaunchWindowSetup.ts new file mode 100644 index 00000000..461f52cd --- /dev/null +++ b/src/components/launch/LaunchWindow/useLaunchWindowSetup.ts @@ -0,0 +1,270 @@ +import type React from "react"; +import { useCallback, useEffect, useState } from "react"; + +interface SetupParams { + preparePermissions: (opts?: { startup?: boolean }) => Promise; + activeDropdown: string; + projectBrowserOpen: boolean; + showRecordingWebcamPreview: boolean; + hudContentRef: React.RefObject; + hudBarRef: React.RefObject; + recordingWebcamPreviewContainerRef: React.RefObject; +} + +export function useLaunchWindowSetup({ + preparePermissions, + activeDropdown, + projectBrowserOpen, + showRecordingWebcamPreview, + hudContentRef, + hudBarRef, + recordingWebcamPreviewContainerRef, +}: SetupParams) { + const [selectedSource, setSelectedSource] = useState("Screen"); + const [hasSelectedSource, setHasSelectedSource] = useState(false); + const [platform, setPlatform] = useState(null); + const [appVersion, setAppVersion] = useState(null); + const [updateStatus, setUpdateStatus] = useState<{ + status: + | "idle" + | "checking" + | "up-to-date" + | "available" + | "downloading" + | "ready" + | "error"; + currentVersion: string; + availableVersion: string | null; + detail?: string; + }>({ + status: "idle", + currentVersion: "", + availableVersion: null, + }); + const [updateActionPending, setUpdateActionPending] = useState(false); + const [hideHudFromCapture, setHideHudFromCapture] = useState(true); + + // Selected source listener + useEffect(() => { + let mounted = true; + + const applySelectedSource = (source: { name?: string } | null | undefined) => { + if (!mounted) return; + if (source?.name) { + setSelectedSource(source.name); + setHasSelectedSource(true); + return; + } + setSelectedSource("Screen"); + setHasSelectedSource(false); + }; + + void window.electronAPI.getSelectedSource().then((source) => { + applySelectedSource(source); + }); + + const cleanup = window.electronAPI.onSelectedSourceChanged((source) => { + applySelectedSource(source); + }); + + return () => { + mounted = false; + cleanup?.(); + }; + }, []); + + // Platform loading + useEffect(() => { + let cancelled = false; + const loadPlatform = async () => { + try { + const nextPlatform = await window.electronAPI.getPlatform(); + if (!cancelled) setPlatform(nextPlatform); + } catch (error) { + console.error("Failed to load platform:", error); + } + }; + void loadPlatform(); + return () => { + cancelled = true; + }; + }, []); + + // Prepare permissions + useEffect(() => { + void preparePermissions({ startup: true }); + }, [preparePermissions]); + + // Update status polling + useEffect(() => { + let mounted = true; + + const refreshUpdateStatus = async () => { + try { + const summary = await window.electronAPI.getUpdateStatusSummary(); + if (mounted) setUpdateStatus(summary); + } catch (error) { + console.error("Failed to load update status summary:", error); + } + }; + + void refreshUpdateStatus(); + const pollTimer = window.setInterval(() => { + void refreshUpdateStatus(); + }, 2500); + + return () => { + mounted = false; + window.clearInterval(pollTimer); + }; + }, []); + + // App version loading + useEffect(() => { + let cancelled = false; + const loadVersion = async () => { + try { + const version = await window.electronAPI.getAppVersion(); + if (!cancelled) setAppVersion(version); + } catch (error) { + console.error("Failed to load app version:", error); + } + }; + void loadVersion(); + return () => { + cancelled = true; + }; + }, []); + + // HUD capture protection loading + useEffect(() => { + let cancelled = false; + const loadHudCaptureProtection = async () => { + try { + const result = await window.electronAPI.getHudOverlayCaptureProtection(); + if (!cancelled && result.success) { + setHideHudFromCapture(result.enabled); + } + } catch (error) { + console.error("Failed to load HUD capture protection state:", error); + } + }; + void loadHudCaptureProtection(); + return () => { + cancelled = true; + }; + }, []); + + // HUD overlay expanded state + useEffect(() => { + const expanded = + activeDropdown !== "none" || projectBrowserOpen || showRecordingWebcamPreview; + window.electronAPI.setHudOverlayExpanded(expanded); + + return () => { + window.electronAPI.setHudOverlayExpanded(false); + }; + }, [activeDropdown, projectBrowserOpen, showRecordingWebcamPreview]); + + // HUD size reporting + const reportHudSize = useCallback(() => { + const hudContent = hudContentRef.current; + const hudBar = hudBarRef.current; + if (!hudContent || !hudBar) return; + + if (showRecordingWebcamPreview) { + const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0); + const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); + window.electronAPI.setHudOverlayCompactWidth(Math.ceil(viewportWidth)); + window.electronAPI.setHudOverlayMeasuredHeight(Math.ceil(viewportHeight), true); + return; + } + + const hudContentRect = hudContent.getBoundingClientRect(); + const hudBarRect = hudBar.getBoundingClientRect(); + const standardWidth = Math.max( + hudBarRect.width, + hudBar.scrollWidth, + hudContentRect.width, + hudContent.scrollWidth, + ); + const standardHeight = Math.max(hudContentRect.height, hudContent.scrollHeight); + + window.electronAPI.setHudOverlayCompactWidth(Math.ceil(standardWidth + 24)); + window.electronAPI.setHudOverlayMeasuredHeight( + Math.ceil(standardHeight + 24), + activeDropdown !== "none" || projectBrowserOpen, + ); + }, [activeDropdown, projectBrowserOpen, showRecordingWebcamPreview, hudContentRef, hudBarRef]); + + // HUD resize observer + useEffect(() => { + const hudContent = hudContentRef.current; + const hudBar = hudBarRef.current; + const previewContainer = recordingWebcamPreviewContainerRef.current; + if (!hudContent || !hudBar || typeof ResizeObserver === "undefined") return; + + let frameId = 0; + const scheduleHudSizeReport = () => { + if (frameId !== 0) cancelAnimationFrame(frameId); + frameId = requestAnimationFrame(() => { + frameId = 0; + reportHudSize(); + }); + }; + + scheduleHudSizeReport(); + + const resizeObserver = new ResizeObserver(() => { + scheduleHudSizeReport(); + }); + resizeObserver.observe(hudContent); + resizeObserver.observe(hudBar); + if (previewContainer) resizeObserver.observe(previewContainer); + + return () => { + resizeObserver.disconnect(); + if (frameId !== 0) cancelAnimationFrame(frameId); + }; + }, [reportHudSize, hudContentRef, hudBarRef, recordingWebcamPreviewContainerRef]); + + // Update button handler + const handleUpdateButtonClick = async () => { + if (updateActionPending || updateStatus.status === "downloading") return; + + setUpdateActionPending(true); + try { + switch (updateStatus.status) { + case "available": + await window.electronAPI.downloadAvailableUpdate(); + break; + case "ready": + await window.electronAPI.installDownloadedUpdate(); + break; + default: + await window.electronAPI.checkForAppUpdates(); + break; + } + const summary = await window.electronAPI.getUpdateStatusSummary(); + setUpdateStatus(summary); + } catch (error) { + console.error("Failed to handle update button action:", error); + } finally { + setUpdateActionPending(false); + } + }; + + return { + selectedSource, + setSelectedSource, + hasSelectedSource, + setHasSelectedSource, + platform, + appVersion, + updateStatus, + updateActionPending, + hideHudFromCapture, + setHideHudFromCapture, + handleUpdateButtonClick, + }; +} diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index fec7af9b..895cca68 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -18,6 +18,20 @@ interface DesktopSource { windowTitle?: string; } +function toProcessedDesktopSource(source: DesktopSource): ProcessedDesktopSource { + return { + id: source.id, + name: source.originalName, + thumbnail: source.thumbnail, + display_id: source.display_id, + appIcon: source.appIcon, + originalName: source.originalName, + sourceType: source.sourceType, + appName: source.appName, + windowTitle: source.windowTitle, + }; +} + function parseSourceMetadata(source: ProcessedDesktopSource) { if (source.sourceType === "window" && (source.appName || source.windowTitle)) { return { @@ -73,13 +87,13 @@ export function SourceSelector() { return { id: source.id, name: metadata.displayName, - thumbnail: source.thumbnail, - display_id: source.display_id, - appIcon: source.appIcon, + thumbnail: source.thumbnail ?? null, + display_id: source.display_id ?? "", + appIcon: source.appIcon ?? null, originalName: source.name, sourceType: metadata.sourceType, appName: metadata.appName, - windowTitle: metadata.windowTitle, + windowTitle: metadata.windowTitle ?? source.name, }; }), ); @@ -124,7 +138,9 @@ export function SourceSelector() { }; const handleShare = async () => { - if (selectedSource) await window.electronAPI.selectSource(selectedSource); + if (selectedSource) { + await window.electronAPI.selectSource(toProcessedDesktopSource(selectedSource)); + } }; if (loading) { diff --git a/src/components/video-editor/AnnotationBlurTab.tsx b/src/components/video-editor/AnnotationBlurTab.tsx new file mode 100644 index 00000000..95622d6a --- /dev/null +++ b/src/components/video-editor/AnnotationBlurTab.tsx @@ -0,0 +1,122 @@ +import Block from "@uiw/react-color-block"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Slider } from "@/components/ui/slider"; +import { TabsContent } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; +import { useScopedT } from "../../contexts/I18nContext"; +import { ANNOTATION_COLOR_PALETTE, type AnnotationSettingsPanelProps } from "./annotationSettingsShared"; + +interface AnnotationBlurTabProps extends Pick< + AnnotationSettingsPanelProps, + "annotation" | "onBlurIntensityChange" | "onBlurColorChange" +> {} + +export function AnnotationBlurTab({ + annotation, + onBlurIntensityChange, + onBlurColorChange, +}: AnnotationBlurTabProps) { + const t = useScopedT("editor"); + + return ( + +
+
+
+ + {t("annotations.blurStrength", undefined, { + strength: annotation.blurIntensity ?? 20, + })} + +
+ onBlurIntensityChange?.(value)} + min={1} + max={100} + step={1} + className="w-full" + /> +
+ +
+
+ + {t("annotations.solidColor", "Solid Color (Censorship)")} + +
+
+ + + + + onBlurColorChange?.(color.hex)} + style={{ borderRadius: "8px" }} + /> + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/video-editor/AnnotationFigureTab.tsx b/src/components/video-editor/AnnotationFigureTab.tsx new file mode 100644 index 00000000..47ba2e9a --- /dev/null +++ b/src/components/video-editor/AnnotationFigureTab.tsx @@ -0,0 +1,122 @@ +import { CaretDown as ChevronDown } from "@phosphor-icons/react"; +import Block from "@uiw/react-color-block"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Slider } from "@/components/ui/slider"; +import { TabsContent } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; +import { useScopedT } from "../../contexts/I18nContext"; +import { getArrowComponent } from "./ArrowSvgs"; +import { ANNOTATION_COLOR_PALETTE, type AnnotationSettingsPanelProps } from "./annotationSettingsShared"; +import type { ArrowDirection, FigureData } from "./types"; + +interface AnnotationFigureTabProps extends Pick {} + +export function AnnotationFigureTab({ annotation, onFigureDataChange }: AnnotationFigureTabProps) { + const t = useScopedT("editor"); + + return ( + +
+ +
+ {([ + "up", + "down", + "left", + "right", + "up-right", + "up-left", + "down-right", + "down-left", + ] as ArrowDirection[]).map((direction) => { + const ArrowComponent = getArrowComponent(direction); + return ( + + ); + })} +
+
+ +
+ + + onFigureDataChange?.({ ...annotation.figureData!, strokeWidth: value }) + } + min={1} + max={6} + step={1} + className="w-full" + /> +
+ +
+ + + + + + + + onFigureDataChange?.({ + ...annotation.figureData!, + color: color.hex, + } as FigureData) + } + style={{ borderRadius: "8px" }} + /> + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/video-editor/AnnotationImageTab.tsx b/src/components/video-editor/AnnotationImageTab.tsx new file mode 100644 index 00000000..043ef43c --- /dev/null +++ b/src/components/video-editor/AnnotationImageTab.tsx @@ -0,0 +1,78 @@ +import { UploadSimple as Upload } from "@phosphor-icons/react"; +import { useRef } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { TabsContent } from "@/components/ui/tabs"; +import { useScopedT } from "../../contexts/I18nContext"; +import type { AnnotationSettingsPanelProps } from "./annotationSettingsShared"; + +interface AnnotationImageTabProps extends Pick {} + +export function AnnotationImageTab({ annotation, onContentChange }: AnnotationImageTabProps) { + const t = useScopedT("editor"); + const fileInputRef = useRef(null); + + const handleImageUpload = (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + const file = files[0]; + const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"]; + if (!validTypes.includes(file.type)) { + toast.error(t("annotations.imageUploadError"), { + description: t("annotations.imageUploadErrorDescription"), + }); + event.target.value = ""; + return; + } + + const reader = new FileReader(); + reader.onload = (loadEvent) => { + const dataUrl = loadEvent.target?.result as string; + if (dataUrl) { + onContentChange(dataUrl); + toast.success(t("annotations.imageUploadSuccess")); + } + }; + reader.onerror = () => { + toast.error(t("annotations.imageUploadFailed"), { + description: t("annotations.imageUploadFailedDescription"), + }); + }; + + reader.readAsDataURL(file); + if (event.target) { + event.target.value = ""; + } + }; + + return ( + + + + + {annotation.content && annotation.content.startsWith("data:image") && ( +
+ Uploaded annotation +
+ )} + +

+ {t("annotations.supportedFormats")} +

+
+ ); +} \ No newline at end of file diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index a0370a5d..f443cb97 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -1,63 +1,19 @@ import { - AlignCenterHorizontal as AlignCenter, - AlignLeft, - AlignRight, - TextB as Bold, - CaretDown as ChevronDown, ImageSquare as ImageIcon, Info, - TextItalic as Italic, BoundingBox as SquareDashed, Trash as Trash2, TextT as Type, - TextUnderline as Underline, - UploadSimple as Upload, } from "@phosphor-icons/react"; -import Block from "@uiw/react-color-block"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Slider } from "@/components/ui/slider"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { type CustomFont, getCustomFonts } from "@/lib/customFonts"; -import { cn } from "@/lib/utils"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useScopedT } from "../../contexts/I18nContext"; -import { AddCustomFontDialog } from "./AddCustomFontDialog"; -import { getArrowComponent } from "./ArrowSvgs"; -import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types"; - -interface AnnotationSettingsPanelProps { - annotation: AnnotationRegion; - onContentChange: (content: string) => void; - onTypeChange: (type: AnnotationType) => void; - onStyleChange: (style: Partial) => void; - onFigureDataChange?: (figureData: FigureData) => void; - onBlurIntensityChange?: (intensity: number) => void; - onBlurColorChange?: (color: string) => void; - onDelete: () => void; -} - -export const FONT_FAMILY_VALUES = [ - { value: "system-ui, -apple-system, sans-serif", labelKey: "fontStyles.classic" }, - { value: "Georgia, serif", labelKey: "fontStyles.editor" }, - { value: "Impact, Arial Black, sans-serif", labelKey: "fontStyles.strong" }, - { value: "Courier New, monospace", labelKey: "fontStyles.typewriter" }, - { value: "Brush Script MT, cursive", labelKey: "fontStyles.deco" }, - { value: "Arial, sans-serif", labelKey: "fontStyles.simple" }, - { value: "Verdana, sans-serif", labelKey: "fontStyles.modern" }, - { value: "Trebuchet MS, sans-serif", labelKey: "fontStyles.clean" }, -]; - -export const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128]; +import { AnnotationBlurTab } from "./AnnotationBlurTab"; +import { AnnotationFigureTab } from "./AnnotationFigureTab"; +import { AnnotationImageTab } from "./AnnotationImageTab"; +import { AnnotationTextTab } from "./AnnotationTextTab"; +import type { AnnotationSettingsPanelProps } from "./annotationSettingsShared"; +import type { AnnotationType } from "./types"; export function AnnotationSettingsPanel({ annotation, @@ -70,73 +26,6 @@ export function AnnotationSettingsPanel({ onDelete, }: AnnotationSettingsPanelProps) { const t = useScopedT("editor"); - const fileInputRef = useRef(null); - const [customFonts, setCustomFonts] = useState([]); - - const fontFamilies = useMemo( - () => FONT_FAMILY_VALUES.map((f) => ({ value: f.value, label: t(f.labelKey) })), - [t], - ); - - // Load custom fonts on mount - useEffect(() => { - setCustomFonts(getCustomFonts()); - }, []); - - const colorPalette = [ - "#FF0000", // Red - "#FFD700", // Yellow/Gold - "#00FF00", // Green - "#FFFFFF", // White - "#0000FF", // Blue - "#FF6B00", // Orange - "#9B59B6", // Purple - "#E91E63", // Pink - "#00BCD4", // Cyan - "#FF5722", // Deep Orange - "#8BC34A", // Light Green - "#FFC107", // Amber - "#2563EB", // Brand Blue - "#000000", // Black - "#607D8B", // Blue Grey - "#795548", // Brown - ]; - - const handleImageUpload = (event: React.ChangeEvent) => { - const files = event.target.files; - if (!files || files.length === 0) return; - - const file = files[0]; - - // Validate file type - const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"]; - if (!validTypes.includes(file.type)) { - toast.error(t("annotations.imageUploadError"), { - description: t("annotations.imageUploadErrorDescription"), - }); - event.target.value = ""; - return; - } - - const reader = new FileReader(); - - reader.onload = (e) => { - const dataUrl = e.target?.result as string; - if (dataUrl) { - onContentChange(dataUrl); - toast.success(t("annotations.imageUploadSuccess")); - } - }; - - reader.onerror = () => { - toast.error(t("annotations.imageUploadFailed"), { - description: t("annotations.imageUploadFailedDescription"), - }); - }; - - reader.readAsDataURL(file); - event.target.value = ""; - }; return (
@@ -150,7 +39,6 @@ export function AnnotationSettingsPanel({
- {/* Type Selector */} onTypeChange(value as AnnotationType)} @@ -199,577 +87,21 @@ export function AnnotationSettingsPanel({ - {/* Text Content */} - -
- -