From 5d040fbf2c02b50e4c628d439787509656f065b4 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:41:42 +1000 Subject: [PATCH 1/3] refactor: split large files into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split all source files exceeding 500 lines into smaller, focused modules: - electron/main.ts → mainBootstrapHelpers, mainRuntimeState, mainUpdateIntegration, mainWindowControls - electron/windows.ts → editorWindows, hudWindows, windowShared - electron/updater.ts → updaterDialogs, updaterEventHandlers, updaterShared - electron/preload.ts → preloadExtensionsBridge, preloadUpdateBridge - electron/ipc/register/recording.ts → recording/ directory with focused handlers - src/components/video-editor/VideoEditor.tsx → EditorContent, EditorHeader, EditorSidebar, EditorToolbar, hooks/ - src/components/video-editor/SettingsPanel.tsx → settings/ directory with per-section components - src/components/video-editor/AnnotationSettingsPanel.tsx → tab components - src/components/video-editor/ExtensionManager.tsx → extension-manager/ directory - src/components/video-editor/VideoPlayback.tsx → videoPlaybackComponent/ directory - src/components/video-editor/projectPersistence.ts → projectPersistence{Normalization,Paths,Regions,Shared}.ts - src/components/video-editor/timeline/TimelineEditor.tsx → TimelineEditor/ directory - src/components/video-editor/types.ts → focused type modules - src/components/launch/LaunchWindow.tsx → LaunchWindow/ directory - src/hooks/useScreenRecorder.ts → useScreenRecorder/ directory - src/lib/exporter/audioEncoder.ts → audioEncoder/ directory - src/lib/exporter/frameRenderer.ts → frameRenderer modules - src/lib/exporter/modernFrameRenderer.ts → filters and lifecycle modules - src/lib/exporter/modernVideoExporter.ts → modernVideoExporter/ directory - src/lib/exporter/videoExporter.ts → video-exporter/ directory - src/lib/exporter/streamingDecoder.ts → streamingDecoderHelpers.ts - src/lib/extensions/extensionHost.ts → extensionHost{ApiFactory,QueryApi,RegistrationApi,Shared,State}.ts - src/lib/extensions/types.ts → focused type modules - electron/native/ScreenCaptureKitRecorder.swift → ScreenCaptureKitRecorder/ directory - scripts/benchmark-export-queues.mjs → benchmark-export-queues/ directory - .github/workflows/release.yml → extracted merge-macos-metadata composite action - scripts/build-native-helpers.mjs → updated for multi-file Swift compilation Also incorporates upstream default export quality change (good → source). --- .../actions/merge-macos-metadata/action.yml | 66 + .github/workflows/release.yml | 48 +- electron/editorWindows.ts | 293 + electron/electron-api-capture.d.ts | 125 + electron/electron-api-export.d.ts | 132 + electron/electron-api-projects.d.ts | 94 + electron/electron-api-settings.d.ts | 81 + electron/electron-env.d.ts | 459 +- electron/hudWindows.ts | 498 ++ electron/ipc/register/recording.ts | 1172 ---- .../ipc/register/recording/ffmpegHandlers.ts | 104 + electron/ipc/register/recording/index.ts | 17 + .../recording/nativeControlHandlers.ts | 262 + .../register/recording/nativeStartHandlers.ts | 490 ++ .../register/recording/nativeStopHandlers.ts | 294 + .../ipc/register/recording/storageHandlers.ts | 71 + .../register/recording/telemetryHandlers.ts | 143 + electron/main.ts | 816 +-- electron/mainBootstrapHelpers.ts | 42 + electron/mainRuntimeState.ts | 27 + electron/mainUpdateIntegration.ts | 230 + electron/mainWindowControls.ts | 453 ++ .../native/ScreenCaptureKitRecorder.swift | 740 +-- .../RecorderService.swift | 55 + .../ScreenCaptureRecorder+Stream.swift | 207 + .../ScreenCaptureRecorder.swift | 373 ++ .../recordly-screencapturekit-helper | Bin 189936 -> 216416 bytes .../recordly-screencapturekit-helper | Bin 180456 -> 206704 bytes electron/preload.ts | 120 +- electron/preloadExtensionsBridge.ts | 31 + electron/preloadUpdateBridge.ts | 54 + electron/updater.ts | 757 +-- electron/updaterDialogs.ts | 116 + electron/updaterEventHandlers.ts | 188 + electron/updaterShared.ts | 269 + electron/windowShared.ts | 46 + electron/windows.ts | 953 +-- scripts/benchmark-export-queues.mjs | 1013 +-- scripts/benchmark-export-queues/config.mjs | 253 + scripts/benchmark-export-queues/fixtures.mjs | 99 + scripts/benchmark-export-queues/reporting.mjs | 259 + scripts/benchmark-export-queues/runner.mjs | 241 + scripts/build-native-helpers.mjs | 19 +- src/components/launch/LaunchWindow.tsx | 1647 ----- .../launch/LaunchWindow/DropdownContent.tsx | 380 ++ .../launch/LaunchWindow/HudControls.tsx | 302 + .../LaunchWindow.module.css | 0 .../launch/LaunchWindow/helperComponents.tsx | 91 + src/components/launch/LaunchWindow/hooks.ts | 372 ++ src/components/launch/LaunchWindow/index.tsx | 385 ++ src/components/launch/LaunchWindow/types.ts | 24 + .../LaunchWindow/useLaunchWindowActions.ts | 208 + .../LaunchWindow/useLaunchWindowSetup.ts | 270 + src/components/launch/SourceSelector.tsx | 26 +- .../video-editor/AnnotationBlurTab.tsx | 122 + .../video-editor/AnnotationFigureTab.tsx | 122 + .../video-editor/AnnotationImageTab.tsx | 78 + .../video-editor/AnnotationSettingsPanel.tsx | 712 +-- .../video-editor/AnnotationTextTab.tsx | 309 + .../video-editor/CursorStylePreview.tsx | 52 + src/components/video-editor/EditorContent.tsx | 346 ++ src/components/video-editor/EditorHeader.tsx | 400 ++ src/components/video-editor/EditorSidebar.tsx | 283 + src/components/video-editor/EditorToolbar.tsx | 260 + .../video-editor/ExtensionManager.tsx | 922 +-- .../video-editor/ExtensionSettingsSection.tsx | 188 + src/components/video-editor/SettingsPanel.tsx | 2731 +------- src/components/video-editor/VideoEditor.tsx | 5493 +---------------- src/components/video-editor/VideoPlayback.tsx | 2474 +------- .../video-editor/annotationSettingsShared.ts | 44 + .../video-editor/captureProjectThumbnail.ts | 141 + .../ExtensionDetailModal.tsx | 203 + .../ExtensionManagerCards.tsx | 285 + .../ExtensionManagerShared.tsx | 28 + .../ExtensionManagerTabs.tsx | 250 + .../video-editor/hooks/editorExportShared.ts | 141 + .../hooks/editorExportWorkflow.ts | 477 ++ .../hooks/useEditorAnnotationAudioRegions.ts | 259 + .../video-editor/hooks/useEditorAudioSync.ts | 274 + .../video-editor/hooks/useEditorCaptions.ts | 245 + .../hooks/useEditorClipRegions.ts | 255 + .../hooks/useEditorCursorTelemetry.ts | 83 + .../video-editor/hooks/useEditorExport.ts | 282 + .../video-editor/hooks/useEditorHistory.ts | 110 + .../video-editor/hooks/useEditorInit.ts | 264 + .../hooks/useEditorPreferences.ts | 381 ++ .../video-editor/hooks/useEditorProject.ts | 228 + .../video-editor/hooks/useEditorRegions.ts | 400 ++ .../hooks/useEditorSideEffects.ts | 291 + .../video-editor/hooks/useEditorWiring.ts | 381 ++ .../video-editor/projectPersistence.ts | 889 +-- .../projectPersistenceNormalization.ts | 265 + .../video-editor/projectPersistencePaths.ts | 110 + .../video-editor/projectPersistenceRegions.ts | 310 + .../video-editor/projectPersistenceShared.ts | 120 + .../settings/BackgroundSection.tsx | 427 ++ .../video-editor/settings/CaptionsSection.tsx | 218 + .../video-editor/settings/ClipSection.tsx | 94 + .../video-editor/settings/CursorSection.tsx | 237 + .../video-editor/settings/FrameSection.tsx | 217 + .../settings/GeneralSettingsSection.tsx | 113 + .../video-editor/settings/WebcamSection.tsx | 199 + .../video-editor/settings/ZoomSection.tsx | 154 + .../video-editor/settingsPanelConstants.tsx | 153 + .../video-editor/settingsPanelUtils.ts | 136 + .../video-editor/timeline/TimelineEditor.tsx | 2145 +------ .../TimelineEditor/TimelineDecorations.tsx | 280 + .../TimelineEditor/TimelineSurface.tsx | 244 + .../TimelineEditor/TimelineToolbar.tsx | 209 + .../timeline/TimelineEditor/index.tsx | 279 + .../timeline/TimelineEditor/shared.ts | 246 + .../TimelineEditor/timelineActionUtils.ts | 82 + .../useTimelineEditorActions.ts | 408 ++ .../TimelineEditor/useTimelineEditorState.ts | 110 + .../useTimelineEditorTimeline.ts | 227 + .../useTimelineKeyboardShortcuts.ts | 183 + .../useTimelineRegionOperations.ts | 273 + src/components/video-editor/types.ts | 7 +- .../video-editor/videoEditorUtils.ts | 398 ++ .../videoPlayback/cursorRenderer.ts | 1318 ---- .../videoPlayback/cursorRenderer/assets.ts | 485 ++ .../cursorRenderer/canvasRenderer.ts | 72 + .../videoPlayback/cursorRenderer/index.ts | 6 + .../cursorRenderer/pixiOverlay.ts | 450 ++ .../videoPlayback/cursorRenderer/shared.ts | 107 + .../cursorRenderer/smoothedState.ts | 74 + .../videoPlayback/cursorRenderer/telemetry.ts | 149 + .../videoPlayback/zoomAnimation.test.ts | 293 - .../VideoPlaybackOverlay.tsx | 282 + .../videoPlaybackComponent/index.tsx | 368 ++ .../videoPlaybackComponent/shared.ts | 270 + .../useCaptionLayout.ts | 102 + .../useCursorOverlayRefresh.ts | 89 + .../videoPlaybackComponent/usePixiApp.ts | 108 + .../usePixiVideoScene.ts | 124 + .../usePlaybackTicker.ts | 365 ++ .../useResolvedWallpaper.ts | 99 + .../useVideoElementLifecycle.ts | 94 + .../useVideoPlaybackLayout.ts | 335 + .../useVideoPlaybackRefs.ts | 171 + .../useVideoPlaybackSync.ts | 401 ++ src/hooks/useScreenRecorder.ts | 1469 ----- src/hooks/useScreenRecorder/index.ts | 467 ++ src/hooks/useScreenRecorder/lifecycle.ts | 125 + .../useScreenRecorder/nativeRecording.ts | 135 + .../useScreenRecorder/recordingControls.ts | 438 ++ src/hooks/useScreenRecorder/recordingCore.ts | 215 + .../useScreenRecorder/recordingStopHelpers.ts | 96 + src/hooks/useScreenRecorder/shared.ts | 112 + .../useScreenRecorder/webcamRecording.ts | 257 + src/lib/exporter/audioEncoder.ts | 1501 ----- src/lib/exporter/audioEncoder/decoding.ts | 294 + src/lib/exporter/audioEncoder/index.ts | 239 + .../exporter/audioEncoder/offlineRender.ts | 287 + .../exporter/audioEncoder/renderHelpers.ts | 248 + src/lib/exporter/audioEncoder/shared.ts | 156 + .../exporter/audioEncoder/trimTranscode.ts | 306 + src/lib/exporter/frameRenderer.ts | 1766 +----- src/lib/exporter/frameRenderer/types.ts | 116 + src/lib/exporter/frameRendererBackground.ts | 366 ++ src/lib/exporter/frameRendererCaptions.ts | 216 + src/lib/exporter/frameRendererCompositing.ts | 387 ++ src/lib/exporter/frameRendererHelpers.ts | 294 + src/lib/exporter/frameRendererTypes.ts | 144 + src/lib/exporter/frameRendererWebcam.ts | 391 ++ src/lib/exporter/frameRendererWebcamSync.ts | 268 + src/lib/exporter/frameRendererZoom.ts | 272 + src/lib/exporter/modernFrameRenderer.ts | 2834 +-------- .../exporter/modernFrameRendererFilters.ts | 12 + .../exporter/modernFrameRendererLifecycle.ts | 138 + src/lib/exporter/modernVideoExporter.ts | 1594 ----- .../exporter/modernVideoExporter/audioPlan.ts | 117 + .../exporter/modernVideoExporter/encoding.ts | 235 + .../modernVideoExporter/errorFormatting.ts | 119 + .../modernVideoExporter/exporterTypes.ts | 141 + src/lib/exporter/modernVideoExporter/index.ts | 397 ++ .../modernVideoExporter/nativeExport.ts | 351 ++ .../exporter/modernVideoExporter/progress.ts | 182 + src/lib/exporter/streamingDecoder.ts | 278 +- src/lib/exporter/streamingDecoderHelpers.ts | 265 + src/lib/exporter/video-exporter/audioBase.ts | 255 + .../exporter/video-exporter/encoderBase.ts | 388 ++ .../exporter/video-exporter/progressBase.ts | 92 + src/lib/exporter/video-exporter/shared.ts | 79 + src/lib/exporter/videoExporter.ts | 818 +-- src/lib/extensions/extensionApiTypes.ts | 83 + src/lib/extensions/extensionHookTypes.ts | 116 + src/lib/extensions/extensionHost.ts | 741 +-- src/lib/extensions/extensionHostApiFactory.ts | 61 + src/lib/extensions/extensionHostQueryApi.ts | 152 + .../extensionHostRegistrationApi.ts | 261 + src/lib/extensions/extensionHostShared.ts | 145 + src/lib/extensions/extensionHostState.ts | 235 + src/lib/extensions/extensionManifestTypes.ts | 74 + .../extensions/extensionMarketplaceTypes.ts | 54 + src/lib/extensions/types.ts | 605 +- 196 files changed, 35786 insertions(+), 35025 deletions(-) create mode 100644 .github/actions/merge-macos-metadata/action.yml create mode 100644 electron/editorWindows.ts create mode 100644 electron/electron-api-capture.d.ts create mode 100644 electron/electron-api-export.d.ts create mode 100644 electron/electron-api-projects.d.ts create mode 100644 electron/electron-api-settings.d.ts create mode 100644 electron/hudWindows.ts delete mode 100644 electron/ipc/register/recording.ts create mode 100644 electron/ipc/register/recording/ffmpegHandlers.ts create mode 100644 electron/ipc/register/recording/index.ts create mode 100644 electron/ipc/register/recording/nativeControlHandlers.ts create mode 100644 electron/ipc/register/recording/nativeStartHandlers.ts create mode 100644 electron/ipc/register/recording/nativeStopHandlers.ts create mode 100644 electron/ipc/register/recording/storageHandlers.ts create mode 100644 electron/ipc/register/recording/telemetryHandlers.ts create mode 100644 electron/mainBootstrapHelpers.ts create mode 100644 electron/mainRuntimeState.ts create mode 100644 electron/mainUpdateIntegration.ts create mode 100644 electron/mainWindowControls.ts create mode 100644 electron/native/ScreenCaptureKitRecorder/RecorderService.swift create mode 100644 electron/native/ScreenCaptureKitRecorder/ScreenCaptureRecorder+Stream.swift create mode 100644 electron/native/ScreenCaptureKitRecorder/ScreenCaptureRecorder.swift create mode 100644 electron/preloadExtensionsBridge.ts create mode 100644 electron/preloadUpdateBridge.ts create mode 100644 electron/updaterDialogs.ts create mode 100644 electron/updaterEventHandlers.ts create mode 100644 electron/updaterShared.ts create mode 100644 electron/windowShared.ts create mode 100644 scripts/benchmark-export-queues/config.mjs create mode 100644 scripts/benchmark-export-queues/fixtures.mjs create mode 100644 scripts/benchmark-export-queues/reporting.mjs create mode 100644 scripts/benchmark-export-queues/runner.mjs delete mode 100644 src/components/launch/LaunchWindow.tsx create mode 100644 src/components/launch/LaunchWindow/DropdownContent.tsx create mode 100644 src/components/launch/LaunchWindow/HudControls.tsx rename src/components/launch/{ => LaunchWindow}/LaunchWindow.module.css (100%) create mode 100644 src/components/launch/LaunchWindow/helperComponents.tsx create mode 100644 src/components/launch/LaunchWindow/hooks.ts create mode 100644 src/components/launch/LaunchWindow/index.tsx create mode 100644 src/components/launch/LaunchWindow/types.ts create mode 100644 src/components/launch/LaunchWindow/useLaunchWindowActions.ts create mode 100644 src/components/launch/LaunchWindow/useLaunchWindowSetup.ts create mode 100644 src/components/video-editor/AnnotationBlurTab.tsx create mode 100644 src/components/video-editor/AnnotationFigureTab.tsx create mode 100644 src/components/video-editor/AnnotationImageTab.tsx create mode 100644 src/components/video-editor/AnnotationTextTab.tsx create mode 100644 src/components/video-editor/CursorStylePreview.tsx create mode 100644 src/components/video-editor/EditorContent.tsx create mode 100644 src/components/video-editor/EditorHeader.tsx create mode 100644 src/components/video-editor/EditorSidebar.tsx create mode 100644 src/components/video-editor/EditorToolbar.tsx create mode 100644 src/components/video-editor/ExtensionSettingsSection.tsx create mode 100644 src/components/video-editor/annotationSettingsShared.ts create mode 100644 src/components/video-editor/captureProjectThumbnail.ts create mode 100644 src/components/video-editor/extension-manager/ExtensionDetailModal.tsx create mode 100644 src/components/video-editor/extension-manager/ExtensionManagerCards.tsx create mode 100644 src/components/video-editor/extension-manager/ExtensionManagerShared.tsx create mode 100644 src/components/video-editor/extension-manager/ExtensionManagerTabs.tsx create mode 100644 src/components/video-editor/hooks/editorExportShared.ts create mode 100644 src/components/video-editor/hooks/editorExportWorkflow.ts create mode 100644 src/components/video-editor/hooks/useEditorAnnotationAudioRegions.ts create mode 100644 src/components/video-editor/hooks/useEditorAudioSync.ts create mode 100644 src/components/video-editor/hooks/useEditorCaptions.ts create mode 100644 src/components/video-editor/hooks/useEditorClipRegions.ts create mode 100644 src/components/video-editor/hooks/useEditorCursorTelemetry.ts create mode 100644 src/components/video-editor/hooks/useEditorExport.ts create mode 100644 src/components/video-editor/hooks/useEditorHistory.ts create mode 100644 src/components/video-editor/hooks/useEditorInit.ts create mode 100644 src/components/video-editor/hooks/useEditorPreferences.ts create mode 100644 src/components/video-editor/hooks/useEditorProject.ts create mode 100644 src/components/video-editor/hooks/useEditorRegions.ts create mode 100644 src/components/video-editor/hooks/useEditorSideEffects.ts create mode 100644 src/components/video-editor/hooks/useEditorWiring.ts create mode 100644 src/components/video-editor/projectPersistenceNormalization.ts create mode 100644 src/components/video-editor/projectPersistencePaths.ts create mode 100644 src/components/video-editor/projectPersistenceRegions.ts create mode 100644 src/components/video-editor/projectPersistenceShared.ts create mode 100644 src/components/video-editor/settings/BackgroundSection.tsx create mode 100644 src/components/video-editor/settings/CaptionsSection.tsx create mode 100644 src/components/video-editor/settings/ClipSection.tsx create mode 100644 src/components/video-editor/settings/CursorSection.tsx create mode 100644 src/components/video-editor/settings/FrameSection.tsx create mode 100644 src/components/video-editor/settings/GeneralSettingsSection.tsx create mode 100644 src/components/video-editor/settings/WebcamSection.tsx create mode 100644 src/components/video-editor/settings/ZoomSection.tsx create mode 100644 src/components/video-editor/settingsPanelConstants.tsx create mode 100644 src/components/video-editor/settingsPanelUtils.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/TimelineDecorations.tsx create mode 100644 src/components/video-editor/timeline/TimelineEditor/TimelineSurface.tsx create mode 100644 src/components/video-editor/timeline/TimelineEditor/TimelineToolbar.tsx create mode 100644 src/components/video-editor/timeline/TimelineEditor/index.tsx create mode 100644 src/components/video-editor/timeline/TimelineEditor/shared.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/timelineActionUtils.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/useTimelineEditorActions.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/useTimelineEditorState.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/useTimelineEditorTimeline.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/useTimelineKeyboardShortcuts.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/useTimelineRegionOperations.ts create mode 100644 src/components/video-editor/videoEditorUtils.ts delete mode 100644 src/components/video-editor/videoPlayback/cursorRenderer.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/assets.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/canvasRenderer.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/index.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/pixiOverlay.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/shared.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/smoothedState.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/telemetry.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/VideoPlaybackOverlay.tsx create mode 100644 src/components/video-editor/videoPlaybackComponent/index.tsx create mode 100644 src/components/video-editor/videoPlaybackComponent/shared.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useCaptionLayout.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useCursorOverlayRefresh.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/usePixiApp.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/usePixiVideoScene.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/usePlaybackTicker.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useResolvedWallpaper.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useVideoElementLifecycle.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useVideoPlaybackLayout.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useVideoPlaybackRefs.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useVideoPlaybackSync.ts delete mode 100644 src/hooks/useScreenRecorder.ts create mode 100644 src/hooks/useScreenRecorder/index.ts create mode 100644 src/hooks/useScreenRecorder/lifecycle.ts create mode 100644 src/hooks/useScreenRecorder/nativeRecording.ts create mode 100644 src/hooks/useScreenRecorder/recordingControls.ts create mode 100644 src/hooks/useScreenRecorder/recordingCore.ts create mode 100644 src/hooks/useScreenRecorder/recordingStopHelpers.ts create mode 100644 src/hooks/useScreenRecorder/shared.ts create mode 100644 src/hooks/useScreenRecorder/webcamRecording.ts delete mode 100644 src/lib/exporter/audioEncoder.ts create mode 100644 src/lib/exporter/audioEncoder/decoding.ts create mode 100644 src/lib/exporter/audioEncoder/index.ts create mode 100644 src/lib/exporter/audioEncoder/offlineRender.ts create mode 100644 src/lib/exporter/audioEncoder/renderHelpers.ts create mode 100644 src/lib/exporter/audioEncoder/shared.ts create mode 100644 src/lib/exporter/audioEncoder/trimTranscode.ts create mode 100644 src/lib/exporter/frameRenderer/types.ts create mode 100644 src/lib/exporter/frameRendererBackground.ts create mode 100644 src/lib/exporter/frameRendererCaptions.ts create mode 100644 src/lib/exporter/frameRendererCompositing.ts create mode 100644 src/lib/exporter/frameRendererHelpers.ts create mode 100644 src/lib/exporter/frameRendererTypes.ts create mode 100644 src/lib/exporter/frameRendererWebcam.ts create mode 100644 src/lib/exporter/frameRendererWebcamSync.ts create mode 100644 src/lib/exporter/frameRendererZoom.ts create mode 100644 src/lib/exporter/modernFrameRendererFilters.ts create mode 100644 src/lib/exporter/modernFrameRendererLifecycle.ts delete mode 100644 src/lib/exporter/modernVideoExporter.ts create mode 100644 src/lib/exporter/modernVideoExporter/audioPlan.ts create mode 100644 src/lib/exporter/modernVideoExporter/encoding.ts create mode 100644 src/lib/exporter/modernVideoExporter/errorFormatting.ts create mode 100644 src/lib/exporter/modernVideoExporter/exporterTypes.ts create mode 100644 src/lib/exporter/modernVideoExporter/index.ts create mode 100644 src/lib/exporter/modernVideoExporter/nativeExport.ts create mode 100644 src/lib/exporter/modernVideoExporter/progress.ts create mode 100644 src/lib/exporter/streamingDecoderHelpers.ts create mode 100644 src/lib/exporter/video-exporter/audioBase.ts create mode 100644 src/lib/exporter/video-exporter/encoderBase.ts create mode 100644 src/lib/exporter/video-exporter/progressBase.ts create mode 100644 src/lib/exporter/video-exporter/shared.ts create mode 100644 src/lib/extensions/extensionApiTypes.ts create mode 100644 src/lib/extensions/extensionHookTypes.ts create mode 100644 src/lib/extensions/extensionHostApiFactory.ts create mode 100644 src/lib/extensions/extensionHostQueryApi.ts create mode 100644 src/lib/extensions/extensionHostRegistrationApi.ts create mode 100644 src/lib/extensions/extensionHostShared.ts create mode 100644 src/lib/extensions/extensionHostState.ts create mode 100644 src/lib/extensions/extensionManifestTypes.ts create mode 100644 src/lib/extensions/extensionMarketplaceTypes.ts 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; -}); - -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 }; -}); - -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) }; +initializeMainWindowControls({ + rendererDist: RENDERER_DIST, + createHudOverlayWindow, + createEditorWindow, + createSourceSelectorWindow, + getHudOverlayWindow, + isHudOverlayMousePassthroughSupported, + onCheckForUpdates: runManualUpdateCheck, }); -ipcMain.handle("check-for-app-updates", async () => { - await checkForAppUpdates(getUpdateDialogWindow, { manual: true }); - return { success: true, logPath: getUpdaterLogPath() }; +initializeMainUpdateIntegration({ + rendererDist: RENDERER_DIST, + focusOrCreateMainWindow, + reassertHudOverlayMouseState, }); -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? +private func exitWithError(_ message: String) -> Never { + fputs("\(message)\n", stderr) + fflush(stderr) + exit(1) } -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 - } - +private func requestScreenRecordingPermissionIfNeeded() { + if CGPreflightScreenCaptureAccess() { 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 + let granted = CGRequestScreenCaptureAccess() + if !granted { + exitWithError("SCREEN_RECORDING_PERMISSION_DENIED") } +} - 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 requestMicrophonePermissionIfNeeded(configJSON: String) { + guard let configData = configJSON.data(using: .utf8), + let config = try? JSONDecoder().decode(CaptureConfig.self, from: configData), + config.capturesMicrophone == true else { + return } - 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 - } + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + break + case .notDetermined: + let semaphore = DispatchSemaphore(value: 0) + AVCaptureDevice.requestAccess(for: .audio) { _ in semaphore.signal() } + semaphore.wait() + if AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { + exitWithError("MICROPHONE_PERMISSION_DENIED") } + default: + exitWithError("MICROPHONE_PERMISSION_DENIED") } +} - 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 - } +@main +struct ScreenCaptureKitRecorderMain { + static func main() { + guard CommandLine.arguments.count >= 2 else { + exitWithError("Missing config JSON") } - return nil - } + let configJSON = CommandLine.arguments[1] - 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 - } + // Force CoreGraphics Services initialization on the main thread. + _ = CGMainDisplayID() - private func startWindowValidationIfNeeded() { - guard let trackedWindowId else { - windowValidationTask?.cancel() - windowValidationTask = nil - return - } + requestScreenRecordingPermissionIfNeeded() + requestMicrophonePermissionIfNeeded(configJSON: configJSON) - 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 } + let service = RecorderService() + service.start(configJSON: configJSON) - 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 { + DispatchQueue.global(qos: .utility).async { + while let input = readLine(strippingNewline: true)?.lowercased() { + if input == "pause" { + service.pause() 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() + if input == "resume" { + service.resume() + continue } - } - } - } - 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() + if input == "stop" { + service.stop() + break } } } - } - - 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) - 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() - -// 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) - } -} - -// 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 { - switch AVCaptureDevice.authorizationStatus(for: .audio) { - case .authorized: - break - case .notDetermined: - let sem = DispatchSemaphore(value: 0) - AVCaptureDevice.requestAccess(for: .audio) { _ in sem.signal() } - sem.wait() - if AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { - fputs("MICROPHONE_PERMISSION_DENIED\n", stderr) - fflush(stderr) - exit(1) - } - default: - fputs("MICROPHONE_PERMISSION_DENIED\n", stderr) - fflush(stderr) - exit(1) - } -} - -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 - } - if input == "resume" { - service.resume() - continue - } - - 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 91b54570d68b316b5fd176d8496a340e8c8dfe79..d1eb40d8deb2fe2776d3ee1a24f56dbab3013df9 100755 GIT binary patch literal 216416 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 Mhr8?F=tcJb08Ujl$p8QV literal 189936 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

6R^oS=V1AF_eiLUU{#ym!CGb}QoBB)fQ`{dX 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 Mf5MX)6Ie&_e-OXSCjbBd diff --git a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper index 3696e45dfb7f9cccc0cb63bf9d5b5a4c1c01cb1c..86893573427556bf87a6ac6b1767f92c23f07877 100755 GIT binary patch literal 206704 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%0DVZQ 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ⅈDEW|Ij`hLdlw;&s_|Mvgt% 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 Ai~s-t 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"; - -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), - }); -} +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"; + +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"); - -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)]; +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"; + +function logBenchmarkConfig(config, benchmarkRequests) { + console.log("[benchmark-export-queues] Config"); + console.log( + 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, + }), + ); + printRequestedConfigTable(config, benchmarkRequests); } -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", +function printJsonSummary(benchmarkResults, 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: 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, + })), + }), ); } } - - 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]; +function printVariantComparisons(benchmarkResults) { + for (const result of benchmarkResults) { + if (result.summaries.length < 2) { + continue; + } - if (includeAudio) { - args.push( - "-f", - "lavfi", - "-i", - "sine=frequency=880:sample_rate=48000", - "-c:a", - "aac", - "-b:a", - "128k", + const baseline = result.summaries[0]; + const tuned = result.summaries[1]; + const { deltaMs, deltaPercent } = calculateDelta( + baseline.averageElapsedMs, + tuned.averageElapsedMs, ); - } 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, - }, + const { deltaMs: medianDeltaMs, deltaPercent: medianPercent } = calculateDelta( + baseline.medianElapsedMs, + tuned.medianElapsedMs, ); - 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}`); - console.log( - `| ${columns - .map((column, columnIndex) => column.header.padEnd(widths[columnIndex])) - .join(" | ")} |`, - ); - console.log(divider); - for (const row of formattedRows) { + const backendLabel = result.request.backend ?? "default"; 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"; -} - -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()}`, + `[benchmark-export-queues] ${backendLabel} tuned vs baseline: ${deltaMs}ms (${typeof deltaPercent === "number" ? deltaPercent.toFixed(1) : "-"}%)`, ); - } - - 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}`, + console.log( + `[benchmark-export-queues] ${backendLabel} tuned vs baseline (median): ${medianDeltaMs}ms (${typeof medianPercent === "number" ? medianPercent.toFixed(1) : "-"}%)`, ); } - - 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) { - 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, - ), - ); - } - - 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 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), - ), - }; } 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 */} - -
- -