From 69312518c29d03c2688b693f4b7952b788209683 Mon Sep 17 00:00:00 2001 From: young Date: Fri, 28 Aug 2026 09:54:01 +1000 Subject: [PATCH 1/7] Disable the extension system --- electron/electron-env.d.ts | 47 - electron/main.ts | 10 +- electron/preload.ts | 31 - .../video-editor/ExtensionManager.tsx | 1112 +---------------- src/components/video-editor/SettingsPanel.tsx | 2 +- src/components/video-editor/VideoEditor.tsx | 5 - src/hooks/useExtensions.ts | 270 +--- src/lib/extensions/extensionHost.ts | 657 +--------- 8 files changed, 57 insertions(+), 2077 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8a4eedd63..7dce4fa05 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -64,13 +64,6 @@ interface UpdateStatusSummary { detail?: string; } -type RendererExtensionInfo = import("./extensions/extensionTypes").ExtensionInfo; -type RendererExtensionReview = import("./extensions/extensionTypes").ExtensionReview; -type RendererMarketplaceExtension = import("./extensions/extensionTypes").MarketplaceExtension; -type RendererMarketplaceReviewStatus = - import("./extensions/extensionTypes").MarketplaceReviewStatus; -type RendererMarketplaceSearchResult = - import("./extensions/extensionTypes").MarketplaceSearchResult; type RendererRecordingSessionData = import("./ipc/types").RecordingSessionData; interface RendererFfmpegAudioMuxMetrics { @@ -898,46 +891,6 @@ interface Window { 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 }>; }; } diff --git a/electron/main.ts b/electron/main.ts index 470fc8243..60eb16bb9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -6,6 +6,7 @@ import { BrowserWindow, desktopCapturer, dialog, + webContents as electronWebContents, ipcMain, Menu, Notification, @@ -14,11 +15,9 @@ import { shell, systemPreferences, Tray, - webContents as electronWebContents, } from "electron"; import { RECORDINGS_DIR } from "./appPaths"; import { showCursor } from "./cursorHider"; -import { registerExtensionIpcHandlers } from "./extensions/extensionIpc"; import { getGpuSwitches } from "./gpuSwitches"; import { cleanupAllExportStreams, @@ -28,12 +27,9 @@ import { registerIpcHandlers, } from "./ipc/handlers"; import { ensureMediaServer } from "./mediaServer"; +import { hardenWebContentsNavigation, shouldHardenWebContentsType } from "./navigationPolicy"; import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy"; import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer"; -import { - hardenWebContentsNavigation, - shouldHardenWebContentsType, -} from "./navigationPolicy"; import type { UpdateToastPayload } from "./updater"; import { checkForAppUpdates, @@ -1055,8 +1051,6 @@ app.whenReady().then(async () => { }, ); - registerExtensionIpcHandlers(); - if (IS_SMOKE_EXPORT || process.env.RECORDLY_DEV_OPEN_RECORDING_INPUT) { await logSmokeExportGpuDiagnostics(); if (IS_SMOKE_EXPORT) { diff --git a/electron/preload.ts b/electron/preload.ts index e55d42cbd..d53edb543 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -983,35 +983,4 @@ 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), }); diff --git a/src/components/video-editor/ExtensionManager.tsx b/src/components/video-editor/ExtensionManager.tsx index ef06fb8e5..b8fc0f589 100644 --- a/src/components/video-editor/ExtensionManager.tsx +++ b/src/components/video-editor/ExtensionManager.tsx @@ -1,1113 +1,29 @@ -/** - * ExtensionManager — Sidebar panel for browsing, installing, and managing extensions. - * - * Matches the SettingsPanel sidebar styling with tabs: - * - Browse: Marketplace search and download - * - Installed: Local extensions with toggle switches - */ - -import { - BookOpen, - Check, - CaretLeft as ChevronLeft, - CaretRight as ChevronRight, - DownloadSimple as Download, - ArrowSquareOut as ExternalLink, - FolderOpen, - SpinnerGap as Loader2, - Plus, - PuzzlePiece as Puzzle, - ArrowsClockwise as RefreshCw, - MagnifyingGlass as Search, - ShieldWarning as ShieldAlert, - Tag, - Trash as Trash2, -} from "@phosphor-icons/react"; -import { AnimatePresence, LayoutGroup, motion } from "motion/react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent } from "@/components/ui/dialog"; -import { Switch } from "@/components/ui/switch"; +import { PuzzlePiece } from "@phosphor-icons/react"; import { useScopedT } from "@/contexts/I18nContext"; -import { useExtensions } from "@/hooks/useExtensions"; -import type { ExtensionInfo, MarketplaceExtension } from "@/lib/extensions"; -import { cn } from "@/lib/utils"; -import { ExtensionIcon } from "./ExtensionIcon"; - -type ExtensionTab = "installed" | "browse"; - -const TAB_OPTIONS: { value: ExtensionTab; labelKey: string }[] = [ - { value: "browse", labelKey: "tabs.browse" }, - { value: "installed", labelKey: "tabs.installed" }, -]; - -const EXTENSIONS_DOCS_URL = "https://marketplace.recordly.dev/extensions"; -const EXTENSIONS_SUBMIT_URL = "https://marketplace.recordly.dev/extensions/submit"; - -function toSafeHttpUrl(value?: string): string | null { - if (!value) return null; - - try { - const parsed = new URL(value); - return parsed.protocol === "http:" || parsed.protocol === "https:" - ? parsed.toString() - : null; - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Installed Extension Card -// --------------------------------------------------------------------------- - -function InstalledExtensionCard({ - extension, - isActive, - onToggle, - onUninstall, - onClick, -}: { - extension: ExtensionInfo; - isActive: boolean; - onToggle: () => void; - onUninstall?: () => void; - onClick?: () => void; -}) { - const t = useScopedT("extensions"); - const isError = extension.status === "error"; - const isBuiltin = extension.builtin; - const homepageUrl = toSafeHttpUrl(extension.manifest.homepage); - - return ( -
-
- -
- -
-
- - {extension.manifest.name} - -
- - {extension.manifest.author && ( -

- {homepageUrl ? ( - e.stopPropagation()} - > - {t("detail.by", undefined, { author: extension.manifest.author })} - - ) : ( - <>{t("detail.by", undefined, { author: extension.manifest.author })} - )} -

- )} - -

- {extension.manifest.description || t("detail.noDescription")} -

- - {isError && extension.error && ( -

- {t("detail.error", undefined, { message: extension.error })} -

- )} - - {extension.manifest.permissions.length > 0 && ( -
- {extension.manifest.permissions.map((perm) => ( - - {perm} - - ))} -
- )} -
- -
- {!isBuiltin && onUninstall && ( - - )} -
e.stopPropagation()}> - -
-
-
- ); -} - -// --------------------------------------------------------------------------- -// Marketplace Extension Card -// --------------------------------------------------------------------------- - -function MarketplaceCard({ - extension, - isInstalling, - onInstall, - onClick, -}: { - extension: MarketplaceExtension; - isInstalling: boolean; - onInstall: () => void; - onClick?: () => void; -}) { - const t = useScopedT("extensions"); - const homepageUrl = toSafeHttpUrl(extension.homepage); - return ( -
-
- {extension.iconUrl ? ( - - ) : ( - - )} -
- -
-
- - {extension.name} - -
- -

- {homepageUrl ? ( - e.stopPropagation()} - > - {t("detail.by", undefined, { author: extension.author })} - - ) : ( - <>{t("detail.by", undefined, { author: extension.author })} - )} -

- -

- {extension.description} -

- -
- - - {extension.downloads.toLocaleString()} - -
- - {extension.tags.length > 0 && ( -
- {extension.tags.slice(0, 3).map((tag) => ( - - {tag} - - ))} -
- )} -
- -
- {extension.installed ? ( - - - {t("status.installed")} - - ) : ( - - )} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Screenshot Gallery Carousel -// --------------------------------------------------------------------------- - -function ScreenshotGallery({ screenshots }: { screenshots: string[] }) { - const t = useScopedT("extensions"); - const [index, setIndex] = useState(0); - const count = screenshots.length; - if (count === 0) return null; - - return ( -
-

- {t("detail.preview")} -

-
- {t("detail.screenshotAlt", - {count > 1 && ( - <> - - -
- {screenshots.map((_, i) => ( -
- - )} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Extension Detail (unified type for installed + marketplace) -// --------------------------------------------------------------------------- - -type ExtensionDetailData = - | { source: "installed"; ext: ExtensionInfo; isActive: boolean } - | { source: "marketplace"; ext: MarketplaceExtension }; - -function ExtensionDetailModal({ - detail, - onClose, - onToggle, - onInstall, - isInstalling, -}: { - detail: ExtensionDetailData; - onClose: () => void; - onToggle?: () => void; - onInstall?: () => void; - isInstalling?: boolean; -}) { - const t = useScopedT("extensions"); - const isInstalled = detail.source === "installed"; - const name = isInstalled ? detail.ext.manifest.name : detail.ext.name; - const description = isInstalled - ? detail.ext.manifest.description || t("detail.noDescription") - : detail.ext.description || t("detail.noDescription"); - const author = isInstalled ? detail.ext.manifest.author : detail.ext.author; - const permissions = isInstalled ? detail.ext.manifest.permissions : detail.ext.permissions; - const homepage = isInstalled ? detail.ext.manifest.homepage : detail.ext.homepage; - const homepageUrl = toSafeHttpUrl(homepage); - const screenshots = detail.source === "marketplace" ? (detail.ext.screenshots ?? []) : []; - const isError = isInstalled ? detail.ext.status === "error" : false; - - return ( - { - if (!open) onClose(); - }} - > - - {/* Header */} -
-
-
- {detail.source === "marketplace" && detail.ext.iconUrl ? ( - - ) : ( - - )} -
-
-
-

- {name} -

-
-

- {author ? ( - homepageUrl ? ( - - {t("detail.by", undefined, { author })} - - - ) : ( - <>{t("detail.by", undefined, { author })} - ) - ) : ( - t("detail.unknownAuthor") - )} -

-
-
- - {/* Stats for marketplace extensions */} - {detail.source === "marketplace" && ( -
- - - {t("detail.downloads", undefined, { - count: detail.ext.downloads.toLocaleString(), - })} - -
- )} -
- - {/* Body */} -
- {/* Screenshot gallery */} - {screenshots.length > 0 && } - - {/* Description */} -
-

- {t("detail.description")} -

-

- {description} -

-
- - {/* Tags */} - {detail.source === "marketplace" && detail.ext.tags.length > 0 && ( -
-

- {t("detail.tags")} -

-
- {detail.ext.tags.map((tag) => ( - - - {tag} - - ))} -
-
- )} - - {/* Permissions */} - {permissions.length > 0 && ( -
-

- {t("detail.permissions")} -

-
- {permissions.map((perm) => ( - - {perm} - - ))} -
-
- )} - - {/* Path (installed extensions) */} - {isInstalled && ( -
-

- {t("detail.location")} -

-

- {detail.ext.path} -

-
- )} - - {/* Error */} - {isError && isInstalled && detail.ext.error && ( -
-

{detail.ext.error}

-
- )} -
- - {/* Footer actions */} -
- {isInstalled && onToggle && ( -
- - - {detail.isActive ? t("status.enabled") : t("status.disabled")} - -
- )} - {detail.source === "marketplace" && !detail.ext.installed && onInstall && ( - - )} - {detail.source === "marketplace" && detail.ext.installed && ( - - - {t("status.installed")} - - )} -
- -
- -
- ); -} - -// --------------------------------------------------------------------------- -// Tab Switcher (LayoutGroup pill animation — matches SettingsPanel pattern) -// --------------------------------------------------------------------------- - -function TabSwitcher({ - activeTab, - onTabChange, - extensionCount, -}: { - activeTab: ExtensionTab; - onTabChange: (tab: ExtensionTab) => void; - extensionCount: number; -}) { - const t = useScopedT("extensions"); - return ( - -
- {TAB_OPTIONS.map((option) => { - const isActive = activeTab === option.value; - const count = option.value === "installed" ? extensionCount : undefined; - return ( - - ); - })} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Main Component -// --------------------------------------------------------------------------- export default function ExtensionManager() { const t = useScopedT("extensions"); - const { - extensions, - activeIds, - ready, - refresh, - toggleExtension, - installFromFolder, - uninstall, - openDirectory, - marketplaceSearch, - marketplaceInstall, - } = useExtensions(); - - const [activeTab, setActiveTab] = useState("browse"); - const [isRefreshing, setIsRefreshing] = useState(false); - - // Marketplace state - const [searchQuery, setSearchQuery] = useState(""); - const [marketplaceResults, setMarketplaceResults] = useState([]); - const [marketplaceLoading, setMarketplaceLoading] = useState(false); - const [marketplaceError, setMarketplaceError] = useState(null); - const [installingIds, setInstallingIds] = useState>(new Set()); - - // Extension detail modal state - const [detailData, setDetailData] = useState(null); - const hasAutoSearchedBrowseRef = useRef(false); - - const handleInstallFromFolder = useCallback(async () => { - const success = await installFromFolder(); - if (success) { - toast.success(t("toast.installedAndEnabled")); - } - }, [installFromFolder, t]); - - const handleUninstall = useCallback( - async (id: string, name: string) => { - const success = await uninstall(id); - if (success) { - toast.success(t("toast.uninstalled", undefined, { name })); - // Clear installed flag in cached marketplace results - setMarketplaceResults((prev) => - prev.map((e) => (e.id === id ? { ...e, installed: false } : e)), - ); - } else { - toast.error(t("toast.uninstallFailed", undefined, { name })); - } - }, - [uninstall, t], - ); - - // Marketplace search - const handleSearch = useCallback(async () => { - hasAutoSearchedBrowseRef.current = true; - setMarketplaceLoading(true); - setMarketplaceError(null); - try { - const result = await marketplaceSearch({ - query: searchQuery || undefined, - sort: "popular", - pageSize: 50, - }); - setMarketplaceResults(result.extensions); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : t("toast.searchFailed"); - setMarketplaceError(message); - setMarketplaceResults([]); - } finally { - setMarketplaceLoading(false); - } - }, [searchQuery, marketplaceSearch, t]); - - const handleRefresh = useCallback(async () => { - setIsRefreshing(true); - try { - await refresh(); - - if (activeTab === "browse") { - await handleSearch(); - } - - toast.success(t("toast.refreshed")); - } catch { - toast.error(t("toast.refreshFailed")); - } finally { - setIsRefreshing(false); - } - }, [activeTab, handleSearch, refresh, t]); - - // Auto-search when switching to browse tab - useEffect(() => { - if (activeTab !== "browse") { - hasAutoSearchedBrowseRef.current = false; - return; - } - - if ( - !hasAutoSearchedBrowseRef.current && - marketplaceResults.length === 0 && - !marketplaceLoading - ) { - void handleSearch(); - } - }, [activeTab, handleSearch, marketplaceLoading, marketplaceResults.length]); - - // Marketplace install - const handleMarketplaceInstall = useCallback( - async (ext: MarketplaceExtension) => { - setInstallingIds((prev) => new Set(prev).add(ext.id)); - try { - const result = await marketplaceInstall(ext.id, ext.downloadUrl); - if (result.success) { - toast.success(t("toast.marketplaceInstalled", undefined, { name: ext.name })); - // Update the marketplace results to show installed state - setMarketplaceResults((prev) => - prev.map((e) => (e.id === ext.id ? { ...e, installed: true } : e)), - ); - setDetailData((prev) => - prev?.source === "marketplace" && prev.ext.id === ext.id - ? { ...prev, ext: { ...prev.ext, installed: true } } - : prev, - ); - } else { - toast.error( - t("toast.marketplaceInstallFailed", undefined, { name: ext.name }), - { - description: result.error, - }, - ); - } - } finally { - setInstallingIds((prev) => { - const next = new Set(prev); - next.delete(ext.id); - return next; - }); - } - }, - [marketplaceInstall, t], - ); return ( -
- {/* Header */} -
-
-
- -

{t("title")}

-
-
- - - - -
-
- - +
+
+

{t("title")}

- {/* Content */} -
- {!ready ? ( -
- +
+
+
+
- ) : ( - - - {activeTab === "installed" && ( - - setDetailData({ - source: "installed", - ext, - isActive: activeIds.has(ext.manifest.id), - }) - } - /> - )} - - {activeTab === "browse" && ( - - setDetailData({ source: "marketplace", ext }) - } - /> - )} - - - )} -
- - {/* Extension Detail Modal */} - {detailData && ( - setDetailData(null)} - onToggle={ - detailData.source === "installed" - ? () => { - toggleExtension(detailData.ext.manifest.id); - setDetailData((prev) => - prev?.source === "installed" - ? { ...prev, isActive: !prev.isActive } - : prev, - ); - } - : undefined - } - onInstall={ - detailData.source === "marketplace" && !detailData.ext.installed - ? () => handleMarketplaceInstall(detailData.ext as MarketplaceExtension) - : undefined - } - isInstalling={ - detailData.source === "marketplace" - ? installingIds.has(detailData.ext.id) - : false - } - /> - )} -
- ); -} - -// --------------------------------------------------------------------------- -// Installed Tab -// --------------------------------------------------------------------------- - -function InstalledTab({ - extensions, - activeIds, - onToggle, - onUninstall, - onInstallFromFolder, - onOpenDirectory, - onViewDetail, -}: { - extensions: ExtensionInfo[]; - activeIds: Set; - onToggle: (id: string) => Promise; - onUninstall: (id: string, name: string) => void; - onInstallFromFolder: () => void; - onOpenDirectory: () => void; - onViewDetail: (ext: ExtensionInfo) => void; -}) { - const t = useScopedT("extensions"); - if (extensions.length === 0) { - return ( -
-
- -
-
-

{t("empty.title")}

-

- {t("empty.description")} +

+ Extensions are no longer available +

+

+ Extension installation and marketplace access have been disabled. This area + is kept as a placeholder for existing projects and navigation.

-
- - -
-
- ); - } - - return ( -
-
-

- {t("tabs.installed")} -

- -
- {extensions.map((ext) => ( - onToggle(ext.manifest.id)} - onUninstall={ - ext.builtin - ? undefined - : () => onUninstall(ext.manifest.id, ext.manifest.name) - } - onClick={() => onViewDetail(ext)} - /> - ))} -
- ); -} - -// --------------------------------------------------------------------------- -// Browse Tab -// --------------------------------------------------------------------------- - -function BrowseTab({ - searchQuery, - onSearchQueryChange, - onSearch, - results, - loading, - error, - installingIds, - onInstall, - onViewDetail, -}: { - searchQuery: string; - onSearchQueryChange: (q: string) => void; - onSearch: () => void; - results: MarketplaceExtension[]; - loading: boolean; - error: string | null; - installingIds: Set; - onInstall: (ext: MarketplaceExtension) => void; - onViewDetail: (ext: MarketplaceExtension) => void; -}) { - const t = useScopedT("extensions"); - return ( -
- {/* Search */} -
- - onSearchQueryChange(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") onSearch(); - }} - className="w-full h-8 pl-8 pr-3 rounded-lg bg-foreground/[0.04] border border-foreground/[0.08] text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-[#2563EB]/50 focus:border-[#2563EB]/30 transition-colors" - />
- - {/* Results */} - {loading && ( -
- -
- )} - - {error && ( -
- -

{error}

- -
- )} - - {!loading && !error && results.length === 0 && ( -
- -

- {searchQuery ? t("search.noResults") : t("search.noMarketplace")} -

-
- )} - - {!loading && !error && results.length > 0 && ( -
-

- {results.length !== 1 - ? t("search.countPlural", undefined, { count: results.length }) - : t("search.count", undefined, { count: results.length })} -

- {results.map((ext) => ( - onInstall(ext)} - onClick={() => onViewDetail(ext)} - /> - ))} -
- )}
); } diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 36e9c701e..5a6fa44a8 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1406,7 +1406,7 @@ export function SettingsPanel({ setExtensionCursorPreviewUrls(Object.fromEntries(cursorPreviewEntries)); }; - void extensionHost.autoActivateBuiltins().then(updateExtensionAssets); + void updateExtensionAssets(); const unsubscribe = extensionHost.onChange(() => { void updateExtensionAssets(); }); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b19280bc4..4a5685a10 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -739,11 +739,6 @@ export default function VideoEditor() { } }, []); - // Auto-activate builtin extensions at editor startup (idempotent) - useEffect(() => { - extensionHost.autoActivateBuiltins(); - }, []); - const [supportedMp4SourceDimensions, setSupportedMp4SourceDimensions] = useState({ width: 1920, diff --git a/src/hooks/useExtensions.ts b/src/hooks/useExtensions.ts index 0f2c4754c..4b198cd63 100644 --- a/src/hooks/useExtensions.ts +++ b/src/hooks/useExtensions.ts @@ -1,41 +1,21 @@ -/** - * useExtensions — React hook for managing extensions in the editor. - * - * Handles discovery, activation/deactivation, marketplace browsing, - * downloading, and provides the extension host instance to components - * that need render hooks. - */ - -import { useCallback, useEffect, useRef, useState } from "react"; import type { ExtensionInfo, ExtensionReview, MarketplaceReviewStatus, MarketplaceSearchResult, } from "@/lib/extensions"; -import { extensionHost } from "@/lib/extensions"; -import { createExtensionModuleUrl } from "@/lib/extensions/fileUrls"; -const electronAPI = typeof window === "undefined" ? undefined : window.electronAPI; +const UNAVAILABLE_ERROR = "Extensions are no longer available in Recordly."; export interface UseExtensionsResult { - /** All discovered extensions */ extensions: ExtensionInfo[]; - /** Currently active extension IDs */ activeIds: Set; - /** Whether initial discovery is complete */ ready: boolean; - /** Discover/refresh extensions from disk */ refresh: () => Promise; - /** Toggle an extension on/off */ toggleExtension: (id: string) => Promise; - /** Install an extension from a folder */ installFromFolder: () => Promise; - /** Uninstall an extension */ uninstall: (id: string) => Promise; - /** Open the extensions directory in Finder/Explorer */ openDirectory: () => Promise; - /** Search the marketplace */ marketplaceSearch: (params: { query?: string; tags?: string[]; @@ -43,20 +23,16 @@ export interface UseExtensionsResult { page?: number; pageSize?: number; }) => Promise; - /** Download and install from marketplace */ marketplaceInstall: ( extensionId: string, downloadUrl: string, ) => Promise<{ success: boolean; error?: string }>; - /** Submit extension for review */ marketplaceSubmit: (extensionId: string) => Promise<{ success: boolean; error?: string }>; - /** Fetch pending reviews (admin) */ fetchReviews: (params: { status?: MarketplaceReviewStatus; page?: number; pageSize?: number; }) => Promise<{ reviews: ExtensionReview[]; total: number }>; - /** Update review status (admin) */ updateReview: ( reviewId: string, status: MarketplaceReviewStatus, @@ -64,231 +40,27 @@ export interface UseExtensionsResult { ) => Promise<{ success: boolean }>; } -export function useExtensions(): UseExtensionsResult { - const [extensions, setExtensions] = useState([]); - const [activeIds, setActiveIds] = useState>(new Set()); - const [ready, setReady] = useState(false); - const activatingRef = useRef(new Set()); - - const discoverAndSync = useCallback(async (): Promise => { - let discovered: ExtensionInfo[] = []; - - try { - if (!electronAPI?.extensionsDiscover) { - setExtensions([]); - return []; - } - - discovered = await electronAPI.extensionsDiscover(); - setExtensions(discovered); - await extensionHost.syncConfiguredExtensions(discovered); - - return discovered; - } catch (error) { - console.error("[extensions] Failed to discover extensions:", error); - return discovered; - } finally { - setReady(true); - } - }, []); - - const refresh = useCallback(async () => { - await discoverAndSync(); - }, [discoverAndSync]); - - // Auto-discover on mount and restore extensions marked active. - useEffect(() => { - void discoverAndSync(); - }, [discoverAndSync]); - - // Sync activeIds with extension host (immediate + future changes) - useEffect(() => { - const sync = () => { - const active = extensionHost.getActiveExtensions(); - setActiveIds(new Set(active.map((e) => e.manifest.id))); - }; - // Immediately sync with any already-active extensions - sync(); - return extensionHost.onChange(sync); - }, []); - - const toggleExtension = useCallback( - async (id: string) => { - if (activatingRef.current.has(id)) return; - activatingRef.current.add(id); - - try { - if (activeIds.has(id)) { - await extensionHost.deactivateExtension(id); - await electronAPI?.extensionsDisable(id); - setExtensions((prev) => - prev.map((ext) => - ext.manifest.id === id ? { ...ext, status: "disabled" } : ext, - ), - ); - } else { - const ext = extensions.find((e) => e.manifest.id === id); - if (!ext) return; - - try { - await electronAPI?.extensionsEnable(id); - - const moduleUrl = createExtensionModuleUrl(ext.path, ext.manifest.main); - await extensionHost.activateExtension(ext, moduleUrl); - setExtensions((prev) => - prev.map((candidate) => - candidate.manifest.id === id - ? { ...candidate, status: "active" } - : candidate, - ), - ); - } catch (err) { - await electronAPI?.extensionsDisable(id); - setExtensions((prev) => - prev.map((candidate) => - candidate.manifest.id === id - ? { ...candidate, status: "disabled" } - : candidate, - ), - ); - throw err; - } - } - } catch (err) { - console.error(`[extensions] Failed to toggle ${id}:`, err); - } finally { - activatingRef.current.delete(id); - } - }, - [activeIds, extensions], - ); - - const installFromFolder = useCallback(async (): Promise => { - if (!electronAPI?.extensionsInstallFromFolder) return false; - const result = await electronAPI.extensionsInstallFromFolder(); - if (result?.success) { - const extensionId = result.extension?.manifest?.id; - if (typeof extensionId === "string") { - await electronAPI?.extensionsEnable(extensionId); - } - await discoverAndSync(); - return true; - } - return false; - }, [discoverAndSync]); - - const uninstall = useCallback( - async (id: string): Promise => { - // Always deactivate — avoids stale closure over activeIds - await extensionHost.deactivateExtension(id); - if (!electronAPI?.extensionsUninstall) return false; - const result = await electronAPI.extensionsUninstall(id); - if (result?.success) { - await discoverAndSync(); - return true; - } - return false; - }, - [discoverAndSync], - ); - - const openDirectory = useCallback(async () => { - await electronAPI?.extensionsOpenDirectory(); - }, []); - - const marketplaceSearch = useCallback( - async (params: { - query?: string; - tags?: string[]; - sort?: "popular" | "recent" | "rating"; - page?: number; - pageSize?: number; - }): Promise => { - if (!electronAPI?.extensionsMarketplaceSearch) { - return { extensions: [], total: 0, page: 1, pageSize: 20 }; - } - - const result = (await electronAPI.extensionsMarketplaceSearch( - params, - )) as MarketplaceSearchResult & { - error?: string; - }; - - if (result?.error) { - throw new Error(result.error); - } - - return result; - }, - [], - ); - - const marketplaceInstall = useCallback( - async (extensionId: string, downloadUrl: string) => { - if (!electronAPI?.extensionsMarketplaceInstall) { - return { success: false, error: "Not available" }; - } - const result = await electronAPI.extensionsMarketplaceInstall(extensionId, downloadUrl); - if (result.success) { - await electronAPI?.extensionsEnable(extensionId); - await discoverAndSync(); - } - return result; - }, - [discoverAndSync], - ); - - const marketplaceSubmit = useCallback(async (extensionId: string) => { - if (!electronAPI?.extensionsMarketplaceSubmit) { - return { success: false, error: "Not available" }; - } - return electronAPI.extensionsMarketplaceSubmit(extensionId); - }, []); - - const fetchReviews = useCallback( - async (params: { status?: MarketplaceReviewStatus; page?: number; pageSize?: number }) => { - if (!electronAPI?.extensionsReviewsList) { - return { reviews: [] as ExtensionReview[], total: 0 }; - } - - const result = (await electronAPI.extensionsReviewsList(params)) as { - reviews: ExtensionReview[]; - total: number; - error?: string; - }; - - if (result?.error) { - throw new Error(result.error); - } - - return result; - }, - [], - ); - - const updateReview = useCallback( - async (reviewId: string, status: MarketplaceReviewStatus, notes?: string) => { - if (!electronAPI?.extensionsReviewUpdate) { - return { success: false }; - } - return electronAPI.extensionsReviewUpdate(reviewId, status, notes); - }, - [], - ); +const unavailableResult = { success: false, error: UNAVAILABLE_ERROR } as const; +export function useExtensions(): UseExtensionsResult { return { - extensions, - activeIds, - ready, - refresh, - toggleExtension, - installFromFolder, - uninstall, - openDirectory, - marketplaceSearch, - marketplaceInstall, - marketplaceSubmit, - fetchReviews, - updateReview, + extensions: [], + activeIds: new Set(), + ready: true, + refresh: async () => undefined, + toggleExtension: async () => undefined, + installFromFolder: async () => false, + uninstall: async () => false, + openDirectory: async () => undefined, + marketplaceSearch: async (params) => ({ + extensions: [], + total: 0, + page: params.page ?? 1, + pageSize: params.pageSize ?? 20, + }), + marketplaceInstall: async () => unavailableResult, + marketplaceSubmit: async () => unavailableResult, + fetchReviews: async () => ({ reviews: [], total: 0 }), + updateReview: async () => ({ success: false }), }; } diff --git a/src/lib/extensions/extensionHost.ts b/src/lib/extensions/extensionHost.ts index d0683e7e3..f0a69163e 100644 --- a/src/lib/extensions/extensionHost.ts +++ b/src/lib/extensions/extensionHost.ts @@ -1,15 +1,13 @@ /** - * Extension Host — Renderer Process + * Disabled extension-host compatibility surface. * - * Manages the lifecycle of extensions in the renderer. Loads extension - * modules, provides the permission-gated host API, and coordinates render hooks. + * The editor still calls several rendering and state methods while legacy + * extension contributions are phased out. Activation is deliberately blocked, + * so these methods remain empty unless old in-memory contributions are cleaned up. */ -import { createExtensionModuleUrl, resolveExtensionRelativeFileUrl } from "./fileUrls"; -import { resolveIconPath } from "./iconDraw"; import type { ContributedCursorStyle, - ContributedFrame, ContributedWallpaper, CursorEffectContext, CursorEffectFn, @@ -19,7 +17,6 @@ import type { ExtensionInfo, ExtensionSettingsPanel, FrameInstance, - RecordlyExtensionAPI, RecordlyExtensionModule, RenderHookContext, RenderHookFn, @@ -28,58 +25,6 @@ import type { const EXTENSION_SETTINGS_STORAGE_KEY = "recordly.extension-settings.v1"; -// --------------------------------------------------------------------------- -// Security: Hide electronAPI from extension code -// --------------------------------------------------------------------------- -// Extensions run via dynamic import() in the renderer's main world. Since -// contextBridge.exposeInMainWorld puts electronAPI on window in the same -// world, extensions could abuse it to read arbitrary files, open URLs, etc. -// We replace the global with a Proxy that blocks access while extension code -// is executing (import + activate). The reference is stashed so that only -// app code (which runs outside of extension activation) can reach it. -// --------------------------------------------------------------------------- - -let _extensionActivationDepth = 0; -let _realElectronAPI: typeof window.electronAPI | undefined; - -function installElectronAPIGuard(): void { - if (typeof window === "undefined" || _realElectronAPI !== undefined) return; - - const real = window.electronAPI; - if (!real) return; - - _realElectronAPI = real; - - const proxy = new Proxy(real, { - get(target, prop, receiver) { - if (_extensionActivationDepth > 0) { - console.warn( - `[extensions] Blocked extension access to electronAPI.${String(prop)}`, - ); - return undefined; - } - return Reflect.get(target, prop, receiver); - }, - }); - - // contextBridge.exposeInMainWorld creates a non-configurable property on - // window. Attempting Object.defineProperty on it throws "Cannot redefine - // property: electronAPI" which crashes the renderer (and makes the - // transparent HUD window invisible). Only redefine when the descriptor - // allows it; otherwise the proxy is still used internally via - // _realElectronAPI so app code keeps working. - const desc = Object.getOwnPropertyDescriptor(window, "electronAPI"); - if (!desc || desc.configurable) { - Object.defineProperty(window, "electronAPI", { - value: proxy, - writable: false, - configurable: false, - }); - } -} - -installElectronAPIGuard(); - interface RegisteredRenderHook { extensionId: string; phase: RenderHookPhase; @@ -146,44 +91,10 @@ export class ExtensionHost { private listeners = new Set<() => void>(); private fullSettingsStore: Record> | null = null; private persistTimeout: ReturnType | null = null; - private iconPathCache = new Map(); - // Shared playback/project state — set by the app, queried by extensions + // Retained for the editor's existing video-info query path. private _videoInfo: { width: number; height: number; durationMs: number; fps: number } | null = null; - private _videoLayout: { - maskRect: { x: number; y: number; width: number; height: number }; - canvasWidth: number; - canvasHeight: number; - borderRadius: number; - padding: number | { top: number; right: number; bottom: number; left: number }; - } | null = null; - private _zoomState: { scale: number; focusX: number; focusY: number; progress: number } | null = - null; - private _shadowConfig: { enabled: boolean; intensity: number } = { - enabled: false, - intensity: 0, - }; - private _cursorTelemetry: Array<{ - timeMs: number; - cx: number; - cy: number; - interactionType?: string; - pressure?: number; - }> = []; - private _smoothedCursor: { - timeMs: number; - cx: number; - cy: number; - trail: Array<{ cx: number; cy: number }>; - } | null = null; - private _keystrokeEvents: Array<{ timeMs: number; key: string; modifiers: string[] }> = []; - private _activeFrame: string | null = null; - private _playbackState: { - currentTimeMs: number; - durationMs: number; - isPlaying: boolean; - } | null = null; constructor() { if (typeof window !== "undefined") { @@ -197,66 +108,8 @@ export class ExtensionHost { * Activate an extension given its info and resolved module URL. */ async activateExtension(info: ExtensionInfo, moduleUrl: string): Promise { - if (this.activeExtensions.has(info.manifest.id)) { - // Deactivate stale instance first so reinstall/reload works - await this.deactivateExtension(info.manifest.id); - } - - const disposables: (() => void)[] = []; - let mod: RecordlyExtensionModule | null = null; - try { - this.ensureExtensionSettingsLoaded(info.manifest.id); - - // Block electronAPI access while extension code executes - _extensionActivationDepth++; - try { - const loaded: RecordlyExtensionModule = await import(/* @vite-ignore */ moduleUrl); - mod = loaded; - const api = this.createAPI( - info.manifest.id, - info.path, - info.manifest.permissions ?? [], - disposables, - ); - - await loaded.activate(api); - } finally { - _extensionActivationDepth--; - } - - if (!mod) { - throw new Error("Extension module failed to load"); - } - - this.activeExtensions.set(info.manifest.id, { - info, - module: mod, - disposables, - }); - - this.notifyListeners(); - console.log(`[extensions] Activated: ${info.manifest.name} v${info.manifest.version}`); - } catch (err) { - for (const dispose of disposables.reverse()) { - try { - dispose(); - } catch { - /* ignore */ - } - } - - if (mod) { - try { - await mod.deactivate?.(); - } catch { - /* ignore */ - } - } - - this.notifyListeners(); - console.error(`[extensions] Failed to activate ${info.manifest.id}:`, err); - throw err; - } + void moduleUrl; + throw new Error(`Extensions are no longer available in Recordly (${info.manifest.id}).`); } /** @@ -440,7 +293,7 @@ export class ExtensionHost { } // --------------------------------------------------------------------------- - // Shared State — set by the app, read by extensions via API + // Legacy shared-state setters retained until their editor call sites are removed. // --------------------------------------------------------------------------- setVideoInfo( @@ -458,40 +311,17 @@ export class ExtensionHost { padding: number | { top: number; right: number; bottom: number; left: number }; } | null, ): void { - if (!layout) { - this._videoLayout = null; - return; - } - - // Normalize and deep clone padding to exclude UI-only fields like 'linked' - const p = layout.padding; - const normalizedPadding = - typeof p === "number" - ? p - : { - top: Number(p.top) || 0, - right: Number(p.right) || 0, - bottom: Number(p.bottom) || 0, - left: Number(p.left) || 0, - }; - - this._videoLayout = { - maskRect: { ...layout.maskRect }, - canvasWidth: layout.canvasWidth, - canvasHeight: layout.canvasHeight, - borderRadius: layout.borderRadius, - padding: normalizedPadding, - }; + void layout; } setZoomState( state: { scale: number; focusX: number; focusY: number; progress: number } | null, ): void { - this._zoomState = state; + void state; } setShadowConfig(config: { enabled: boolean; intensity: number }): void { - this._shadowConfig = config; + void config; } setCursorTelemetry( @@ -503,7 +333,7 @@ export class ExtensionHost { pressure?: number; }>, ): void { - this._cursorTelemetry = telemetry; + void telemetry; } setSmoothedCursor( @@ -514,28 +344,21 @@ export class ExtensionHost { trail: Array<{ cx: number; cy: number }>; } | null, ): void { - this._smoothedCursor = cursor - ? { - timeMs: cursor.timeMs, - cx: cursor.cx, - cy: cursor.cy, - trail: cursor.trail.map((point) => ({ ...point })), - } - : null; + void cursor; } setKeystrokeEvents(events: Array<{ timeMs: number; key: string; modifiers: string[] }>): void { - this._keystrokeEvents = events; + void events; } setActiveFrame(frameId: string | null): void { - this._activeFrame = frameId; + void frameId; } setPlaybackState( state: { currentTimeMs: number; durationMs: number; isPlaying: boolean } | null, ): void { - this._playbackState = state; + void state; } // --------------------------------------------------------------------------- @@ -637,438 +460,9 @@ export class ExtensionHost { this.writePersistedSettingsStore(this.getFullSettingsStore()); } - /** - * Create the permission-gated API object for an extension. - */ - private createAPI( - extensionId: string, - extensionPath: string, - permissions: string[], - disposables: (() => void)[], - ): RecordlyExtensionAPI { - const host = this; - const perms = new Set(permissions); - - function requirePermission(perm: string, method: string): void { - if (!perms.has(perm)) { - throw new Error( - `Extension '${extensionId}' lacks '${perm}' permission required for ${method}()`, - ); - } - } - - function getEventPermission( - event: ExtensionEventType, - ): "cursor" | "timeline" | "export" | null { - if (event.startsWith("cursor:")) { - return "cursor"; - } - - if (event.startsWith("playback:") || event.startsWith("timeline:")) { - return "timeline"; - } - - if (event.startsWith("export:")) { - return "export"; - } - - return null; - } - - return { - registerRenderHook(phase: RenderHookPhase, hook: RenderHookFn): () => void { - requirePermission("render", "registerRenderHook"); - const entry: RegisteredRenderHook = { extensionId, phase, hook }; - host.renderHooks.push(entry); - - const dispose = () => { - const index = host.renderHooks.indexOf(entry); - if (index >= 0) host.renderHooks.splice(index, 1); - }; - disposables.push(dispose); - return dispose; - }, - - registerCursorEffect(effect: CursorEffectFn): () => void { - requirePermission("cursor", "registerCursorEffect"); - const entry: RegisteredCursorEffect = { extensionId, effect }; - host.cursorEffects.push(entry); - - const dispose = () => { - const index = host.cursorEffects.indexOf(entry); - if (index >= 0) host.cursorEffects.splice(index, 1); - }; - disposables.push(dispose); - return dispose; - }, - - registerFrame(frame: ContributedFrame): () => void { - requirePermission("ui", "registerFrame"); - const resolveFramePath = (relativePath: string): string => - resolveExtensionRelativeFileUrl(extensionPath, relativePath); - - let filePath: string; - if (frame.draw) { - // Generate a small thumbnail for the picker UI - const thumbW = 192; - const thumbH = 108; - const c = document.createElement("canvas"); - c.width = thumbW; - c.height = thumbH; - const ctx = c.getContext("2d"); - if (ctx) frame.draw(ctx, thumbW, thumbH); - filePath = c.toDataURL("image/png"); - } else if (frame.dataUrl) { - filePath = frame.dataUrl; - } else if (frame.file) { - filePath = resolveFramePath(frame.file); - } else { - throw new Error("Device frame must provide either draw, file, or dataUrl"); - } - - let thumbnailPath = filePath; - if (frame.thumbnail) { - thumbnailPath = resolveFramePath(frame.thumbnail); - } - - const instance: FrameInstance = { - id: `${extensionId}/${frame.id}`, - extensionId, - label: frame.label, - category: frame.category, - filePath, - thumbnailPath, - screenInsets: frame.screenInsets, - appearance: frame.appearance, - draw: frame.draw, - }; - host.frames.push(instance); - host.notifyListeners(); - - const dispose = () => { - const index = host.frames.indexOf(instance); - if (index >= 0) host.frames.splice(index, 1); - host.notifyListeners(); - }; - disposables.push(dispose); - return dispose; - }, - - registerWallpaper(wallpaper: ContributedWallpaper): () => void { - requirePermission("assets", "registerWallpaper"); - const resolvedUrl = resolveExtensionRelativeFileUrl(extensionPath, wallpaper.file); - const resolvedThumbnailUrl = wallpaper.thumbnail - ? resolveExtensionRelativeFileUrl(extensionPath, wallpaper.thumbnail) - : resolvedUrl; - const entry: RegisteredWallpaper = { - id: `${extensionId}/${wallpaper.id}`, - extensionId, - wallpaper, - resolvedUrl, - resolvedThumbnailUrl, - }; - host.wallpapers.push(entry); - host.notifyListeners(); - - const dispose = () => { - const index = host.wallpapers.indexOf(entry); - if (index >= 0) host.wallpapers.splice(index, 1); - host.notifyListeners(); - }; - disposables.push(dispose); - return dispose; - }, - - registerCursorStyle(cursorStyle: ContributedCursorStyle): () => void { - requirePermission("assets", "registerCursorStyle"); - const resolvedDefaultUrl = resolveExtensionRelativeFileUrl( - extensionPath, - cursorStyle.defaultImage, - ); - const resolvedClickUrl = cursorStyle.clickImage - ? resolveExtensionRelativeFileUrl(extensionPath, cursorStyle.clickImage) - : undefined; - const entry: RegisteredCursorStyle = { - id: `${extensionId}/${cursorStyle.id}`, - extensionId, - cursorStyle, - resolvedDefaultUrl, - resolvedClickUrl, - }; - host.cursorStyles.push(entry); - host.notifyListeners(); - - const dispose = () => { - const index = host.cursorStyles.indexOf(entry); - if (index >= 0) host.cursorStyles.splice(index, 1); - host.notifyListeners(); - }; - disposables.push(dispose); - return dispose; - }, - - on(event: ExtensionEventType, handler: ExtensionEventHandler): () => void { - const requiredPermission = getEventPermission(event); - if (requiredPermission) { - requirePermission(requiredPermission, `on(${event})`); - } - - if (!host.eventHandlers.has(event)) { - host.eventHandlers.set(event, []); - } - const entry = { extensionId, handler }; - host.eventHandlers.get(event)!.push(entry); - - const dispose = () => { - const list = host.eventHandlers.get(event); - if (!list) return; - const index = list.indexOf(entry); - if (index >= 0) list.splice(index, 1); - }; - disposables.push(dispose); - return dispose; - }, - - registerSettingsPanel(panel: ExtensionSettingsPanel): () => void { - requirePermission("ui", "registerSettingsPanel"); - const entry: RegisteredSettingsPanel = { extensionId, panel }; - host.settingsPanels.push(entry); - host.notifyListeners(); - - const dispose = () => { - const index = host.settingsPanels.indexOf(entry); - if (index >= 0) host.settingsPanels.splice(index, 1); - host.notifyListeners(); - }; - disposables.push(dispose); - return dispose; - }, - - getSetting(settingId: string): unknown { - host.ensureExtensionSettingsLoaded(extensionId); - return host.extensionSettings.get(extensionId)?.[settingId]; - }, - - setSetting(settingId: string, value: unknown): void { - host.ensureExtensionSettingsLoaded(extensionId); - host.extensionSettings.get(extensionId)![settingId] = value; - host.persistExtensionSettings(extensionId); - // Notify per-extension setting change listeners - const cbs = host.settingChangeCallbacks.get(extensionId); - if (cbs) { - for (const cb of cbs) { - try { - cb(settingId, value); - } catch { - /* ignore */ - } - } - } - host.notifyListeners(); - }, - - resolveAsset(relativePath: string): string { - requirePermission("assets", "resolveAsset"); - return resolveExtensionRelativeFileUrl(extensionPath, relativePath); - }, - - playSound(relativePath: string, options?: { volume?: number }): () => void { - requirePermission("audio", "playSound"); - const audio = new Audio( - resolveExtensionRelativeFileUrl(extensionPath, relativePath), - ); - audio.volume = Math.max(0, Math.min(1, options?.volume ?? 1)); - audio.play().catch((err) => { - console.warn(`[ext:${extensionId}] Failed to play sound:`, err); - }); - return () => { - audio.pause(); - audio.src = ""; - }; - }, - - log(message: string, ...args: unknown[]): void { - console.log(`[ext:${extensionId}]`, message, ...args); - }, - - // ---------------------------------------------------------------- - // Query APIs - // ---------------------------------------------------------------- - - getVideoInfo() { - return host._videoInfo ? { ...host._videoInfo } : null; - }, - - getVideoLayout() { - if (!host._videoLayout) return null; - const p = host._videoLayout.padding; - return { - maskRect: { ...host._videoLayout.maskRect }, - canvasWidth: host._videoLayout.canvasWidth, - canvasHeight: host._videoLayout.canvasHeight, - borderRadius: host._videoLayout.borderRadius, - padding: typeof p === "number" ? p : { ...p }, - }; - }, - - getCursorAt(timeMs: number) { - const t = host._cursorTelemetry; - if (!t || t.length === 0) return null; - - if (timeMs <= t[0].timeMs) return { ...t[0], timeMs }; - if (timeMs >= t[t.length - 1].timeMs) return { ...t[t.length - 1], timeMs }; - - let lo = 0; - let hi = t.length - 1; - while (lo < hi - 1) { - const mid = (lo + hi) >> 1; - if (t[mid].timeMs <= timeMs) { - lo = mid; - } else { - hi = mid; - } - } - - const a = t[lo]; - const b = t[hi]; - const span = b.timeMs - a.timeMs; - const frac = span > 0 ? (timeMs - a.timeMs) / span : 0; - - return { - ...a, - cx: a.cx + (b.cx - a.cx) * frac, - cy: a.cy + (b.cy - a.cy) * frac, - timeMs, - }; - }, - - getSmoothedCursor() { - if (!host._smoothedCursor) { - return null; - } - - return { - timeMs: host._smoothedCursor.timeMs, - cx: host._smoothedCursor.cx, - cy: host._smoothedCursor.cy, - trail: host._smoothedCursor.trail.map((point) => ({ ...point })), - }; - }, - - getZoomState() { - return host._zoomState ? { ...host._zoomState } : null; - }, - - getShadowConfig() { - return { ...host._shadowConfig }; - }, - - getKeystrokesInRange(startMs: number, endMs: number) { - return host._keystrokeEvents - .filter((e) => e.timeMs >= startMs && e.timeMs <= endMs) - .map((e) => ({ ...e })); - }, - - getAspectRatio() { - if (!host._videoLayout) return null; - return host._videoLayout.canvasWidth / host._videoLayout.canvasHeight; - }, - - getActiveFrame() { - return host._activeFrame; - }, - - isExtensionActive(extId: string) { - return host.activeExtensions.has(extId); - }, - - getPlaybackState() { - return host._playbackState ? { ...host._playbackState } : null; - }, - - getCanvasDimensions() { - if (!host._videoLayout) return null; - return { - width: host._videoLayout.canvasWidth, - height: host._videoLayout.canvasHeight, - }; - }, - - drawIcon( - ctx: CanvasRenderingContext2D, - name: string, - x: number, - y: number, - size: number, - color: string, - weight: "thin" | "light" | "regular" | "bold" | "fill" = "regular", - ): void { - const path = resolveIconPath(name, weight, host.iconPathCache); - - if (path) { - ctx.save(); - ctx.translate(x, y); - const scale = size / 256; // Phosphor icons use a 256x256 grid - ctx.scale(scale, scale); - ctx.translate(-128, -128); // Center the icon - ctx.fillStyle = color; - ctx.fill(path); - ctx.restore(); - } - }, - - onSettingChange(callback: (settingId: string, value: unknown) => void): () => void { - if (!host.settingChangeCallbacks.has(extensionId)) { - host.settingChangeCallbacks.set(extensionId, new Set()); - } - host.settingChangeCallbacks.get(extensionId)!.add(callback); - - const dispose = () => { - const cbs = host.settingChangeCallbacks.get(extensionId); - if (cbs) { - cbs.delete(callback); - if (cbs.size === 0) host.settingChangeCallbacks.delete(extensionId); - } - }; - disposables.push(dispose); - return dispose; - }, - - getAllSettings(): Record { - host.ensureExtensionSettingsLoaded(extensionId); - return { ...(host.extensionSettings.get(extensionId) ?? {}) }; - }, - }; - } - async syncConfiguredExtensions(discovered: ExtensionInfo[]): Promise { - const desired = new Map( - discovered - .filter((ext) => ext.status === "active") - .map((ext) => [ext.manifest.id, ext]), - ); - - for (const activeId of Array.from(this.activeExtensions.keys())) { - if (!desired.has(activeId)) { - await this.deactivateExtension(activeId); - } - } - - for (const ext of discovered) { - if (ext.status !== "active" || this.activeExtensions.has(ext.manifest.id)) { - continue; - } - - try { - const moduleUrl = createExtensionModuleUrl(ext.path, ext.manifest.main); - await this.activateExtension(ext, moduleUrl); - } catch (err) { - console.error( - `[extensions] Failed to activate configured extension ${ext.manifest.id}:`, - err, - ); - } - } + void discovered; + await this.deactivateAll(); } // --------------------------------------------------------------------------- @@ -1082,20 +476,7 @@ export class ExtensionHost { * the discovery/activation sequence once no matter how many callers invoke it. */ autoActivateBuiltins(): Promise { - if (this._autoActivatePromise) return this._autoActivatePromise; - - this._autoActivatePromise = (async () => { - // Use the real (unproxied) reference — this is app code, not extension code - const api = _realElectronAPI ?? window.electronAPI; - if (!api?.extensionsDiscover) return; - try { - const discovered: ExtensionInfo[] = await api.extensionsDiscover(); - await this.syncConfiguredExtensions(discovered); - } catch (err) { - console.error("[extensions] Failed to discover extensions:", err); - } - })(); - + this._autoActivatePromise ??= Promise.resolve(); return this._autoActivatePromise; } } From c13703b9df9cb08d3aacf596778727b1822d61a9 Mon Sep 17 00:00:00 2001 From: young Date: Fri, 28 Aug 2026 09:54:11 +1000 Subject: [PATCH 2/7] Tighten React hook dependencies --- src/App.tsx | 2 +- src/components/launch/SourceSelector.tsx | 21 +- src/components/video-editor/VideoPlayback.tsx | 22 +- .../audio/useSourceAudioTrackSettings.ts | 207 +++++++++--------- .../components/waveform/AudioWaveform.tsx | 13 +- 5 files changed, 142 insertions(+), 123 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 9e1f4e4c5..513320f4f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,7 +47,7 @@ export default function App() { loadAllCustomFonts().catch((error) => { console.error("Failed to load custom fonts:", error); }); - }, []); + }, [isMacOS]); useEffect(() => { document.title = diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index baa4da59d..dcbde5ed2 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -42,7 +42,7 @@ export function MarqueeText({ text }: { text: string }) { useLayoutEffect(() => { const node = staticRef.current; - if (!node) return; + if (!node || node.textContent !== text) return; const checkOverflow = () => { setOverflowing(node.scrollWidth > node.clientWidth + 1); }; @@ -81,7 +81,10 @@ export const SourceSelectorContent = ({ selectedSource = "Screen", loading = false, onSourceSelect = () => undefined, -}: Pick) => { +}: Pick< + SourceSelectorProps, + "screenSources" | "windowSources" | "selectedSource" | "loading" | "onSourceSelect" +>) => { const t = useScopedT("launch"); const renderSourceItem = (source: DesktopSource, index: number) => { const isSelected = selectedSource === source.name; @@ -116,12 +119,14 @@ export const SourceSelectorContent = ({ )}
-
+
- {source.sourceType === "screen" ? t("recording.screen") : t("recording.window")} + {source.sourceType === "screen" + ? t("recording.screen") + : t("recording.window")}
@@ -156,7 +161,9 @@ export const SourceSelectorContent = ({
- {screenSources.map((source, index) => renderSourceItem(source, index))} + {screenSources.map((source, index) => + renderSourceItem(source, index), + )}
) : null} @@ -166,7 +173,9 @@ export const SourceSelectorContent = ({ {t("recording.windows")}
- {windowSources.map((source, index) => renderSourceItem(source, index))} + {windowSources.map((source, index) => + renderSourceItem(source, index), + )}
) : null} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 52e9fa43c..82b3c5c76 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -1260,7 +1260,7 @@ const VideoPlayback = forwardRef( const nextVolume = Math.max(0, Math.min(1, volume)); video.volume = nextVolume; video.muted = nextVolume <= 0.001; - }, [volume, videoPath]); + }, [volume]); useEffect(() => { layoutVideoContentRef.current = layoutVideoContent; @@ -1362,12 +1362,12 @@ const VideoPlayback = forwardRef( return () => { cancelled = true; }; - }, [aspectRatio, borderRadius, cropRegion, frame, frameUpdateCounter, padding]); + }, [frame, frameUpdateCounter]); // Always re-run geometric layout when layout props change, even if frame sprite isn't reloaded. useEffect(() => { - layoutVideoContentRef.current?.(); - }, [aspectRatio, borderRadius, cropRegion, padding]); + layoutVideoContent(); + }, [layoutVideoContent]); const selectedZoom = useMemo(() => { if (!selectedZoomId) return null; @@ -1545,6 +1545,7 @@ const VideoPlayback = forwardRef( useEffect(() => { suspendRenderingRef.current = suspendRendering; + if (!pixiReady) return; const app = appRef.current; if (!app?.ticker) { return; @@ -1656,6 +1657,7 @@ const VideoPlayback = forwardRef( }, [speedRegions]); useEffect(() => { + if (!pixiReady) return; const videoEffectsContainer = videoEffectsContainerRef.current; const zoomBlurFilter = zoomBlurFilterRef.current; const motionBlurFilter = motionBlurFilterRef.current; @@ -1783,7 +1785,7 @@ const VideoPlayback = forwardRef( motionBlurStateRef.current = createMotionBlurState(); videoEffectsContainer.filters = zoomMotionBlur > 0 ? [motionBlurFilter, zoomBlurFilter] : null; - }, [videoPath, zoomMotionBlur]); + }, [zoomMotionBlur]); useEffect(() => { zoomMotionBlurTuningRef.current = zoomMotionBlurTuning; @@ -1905,7 +1907,7 @@ const VideoPlayback = forwardRef( } }); }); - }, [pixiReady, videoReady, layoutVideoContent, cropRegion]); + }, [pixiReady, videoReady, layoutVideoContent]); useEffect(() => { if (!pixiReady || !videoReady) return; @@ -2013,11 +2015,13 @@ const VideoPlayback = forwardRef( syncWebcamMedia(); }, [syncWebcamMedia]); + // biome-ignore lint/correctness/useExhaustiveDependencies: The media path intentionally triggers source-specific state reset. useEffect(() => { setWebcamVideoDimensions(null); lastWebcamSyncTimeRef.current = null; }, [webcamVideoPath]); + // biome-ignore lint/correctness/useExhaustiveDependencies: The wallpaper identity intentionally resets media synchronization. useEffect(() => { lastBackgroundSyncTimeRef.current = null; }, [wallpaper]); @@ -2171,8 +2175,9 @@ const VideoPlayback = forwardRef( cursorContainerRef.current = null; videoSpriteRef.current = null; }; - }, [initializePixiRenderer, onError]); + }, [initializePixiRenderer, onError, syncPreviewMotionBlurQuality]); + // biome-ignore lint/correctness/useExhaustiveDependencies: A new media path must reset the persistent video element. useEffect(() => { const video = videoRef.current; if (!video) return; @@ -2273,7 +2278,7 @@ const VideoPlayback = forwardRef( videoSpriteRef.current = null; }; - }, [pixiReady, videoReady, onTimeUpdate, updateOverlayForRegion]); + }, [layoutVideoContent, onPlayStateChange, onTimeUpdate, pixiReady, videoReady]); useEffect(() => { if (!pixiReady || !videoReady) return; @@ -2650,7 +2655,6 @@ const VideoPlayback = forwardRef( }, [ pixiReady, videoReady, - clampFocusToStage, applyWebcamBubbleLayout, borderRadius, padding, diff --git a/src/components/video-editor/audio/useSourceAudioTrackSettings.ts b/src/components/video-editor/audio/useSourceAudioTrackSettings.ts index 9f126853d..c1a96d4cc 100644 --- a/src/components/video-editor/audio/useSourceAudioTrackSettings.ts +++ b/src/components/video-editor/audio/useSourceAudioTrackSettings.ts @@ -5,110 +5,115 @@ import type { } from "@/components/video-editor/audio/audioTypes"; interface UseSourceAudioTrackSettingsParams { - selectedClipId: string | null; - activeClipId: string | null; - sourceAudioTrackSettingsByClip: Record; - setSourceAudioTrackSettingsByClip: React.Dispatch< - React.SetStateAction> - >; - defaultSourceAudioTrackSettings: SourceAudioTrackSettings; - setDefaultSourceAudioTrackSettings: React.Dispatch>; + selectedClipId: string | null; + activeClipId: string | null; + sourceAudioTrackSettingsByClip: Record; + setSourceAudioTrackSettingsByClip: React.Dispatch< + React.SetStateAction> + >; + defaultSourceAudioTrackSettings: SourceAudioTrackSettings; + setDefaultSourceAudioTrackSettings: React.Dispatch< + React.SetStateAction + >; } export interface UseSourceAudioTrackSettingsResult { - sourceAudioTrackMeta: SourceAudioTrackMeta; - activeSourceAudioTrackSettings: SourceAudioTrackSettings; - selectedClipSourceAudioTrackSettings: SourceAudioTrackSettings; - getSourceAudioTrackSettingsForClip: (clipId: string | null) => SourceAudioTrackSettings; - onSourceAudioTracksMetaChange: (tracks: SourceAudioTrackMeta) => void; - onSelectedClipSourceAudioTrackVolumeChange: (id: string, volume: number) => void; - onSelectedClipSourceAudioTrackNormalizeChange: (id: string, normalize: boolean) => void; + sourceAudioTrackMeta: SourceAudioTrackMeta; + activeSourceAudioTrackSettings: SourceAudioTrackSettings; + selectedClipSourceAudioTrackSettings: SourceAudioTrackSettings; + getSourceAudioTrackSettingsForClip: (clipId: string | null) => SourceAudioTrackSettings; + onSourceAudioTracksMetaChange: (tracks: SourceAudioTrackMeta) => void; + onSelectedClipSourceAudioTrackVolumeChange: (id: string, volume: number) => void; + onSelectedClipSourceAudioTrackNormalizeChange: (id: string, normalize: boolean) => void; } function isSameTrackMeta(left: SourceAudioTrackMeta, right: SourceAudioTrackMeta): boolean { - if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { - const leftTrack = left[index]; - const rightTrack = right[index]; - if (!leftTrack || !rightTrack) return false; - if (leftTrack.id !== rightTrack.id || leftTrack.label !== rightTrack.label) { - return false; - } - } - return true; + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftTrack = left[index]; + const rightTrack = right[index]; + if (!leftTrack || !rightTrack) return false; + if (leftTrack.id !== rightTrack.id || leftTrack.label !== rightTrack.label) { + return false; + } + } + return true; } export function useSourceAudioTrackSettings({ - selectedClipId, - activeClipId, - sourceAudioTrackSettingsByClip, - setSourceAudioTrackSettingsByClip, - defaultSourceAudioTrackSettings, - setDefaultSourceAudioTrackSettings, + selectedClipId, + activeClipId, + sourceAudioTrackSettingsByClip, + setSourceAudioTrackSettingsByClip, + defaultSourceAudioTrackSettings, + setDefaultSourceAudioTrackSettings, }: UseSourceAudioTrackSettingsParams): UseSourceAudioTrackSettingsResult { - const [sourceAudioTrackMeta, setSourceAudioTrackMeta] = useState([]); + const [sourceAudioTrackMeta, setSourceAudioTrackMeta] = useState([]); - const activeSourceAudioTrackSettings = useMemo(() => { - if (!activeClipId) { - return defaultSourceAudioTrackSettings; - } - return { - ...defaultSourceAudioTrackSettings, - ...(sourceAudioTrackSettingsByClip[activeClipId] ?? {}), - }; - }, [activeClipId, defaultSourceAudioTrackSettings, sourceAudioTrackSettingsByClip]); + const activeSourceAudioTrackSettings = useMemo(() => { + if (!activeClipId) { + return defaultSourceAudioTrackSettings; + } + return { + ...defaultSourceAudioTrackSettings, + ...(sourceAudioTrackSettingsByClip[activeClipId] ?? {}), + }; + }, [activeClipId, defaultSourceAudioTrackSettings, sourceAudioTrackSettingsByClip]); - const selectedClipSourceAudioTrackSettings = useMemo(() => { - if (!selectedClipId) { - return defaultSourceAudioTrackSettings; - } - return { - ...defaultSourceAudioTrackSettings, - ...(sourceAudioTrackSettingsByClip[selectedClipId] ?? {}), - }; - }, [defaultSourceAudioTrackSettings, selectedClipId, sourceAudioTrackSettingsByClip]); + const selectedClipSourceAudioTrackSettings = useMemo(() => { + if (!selectedClipId) { + return defaultSourceAudioTrackSettings; + } + return { + ...defaultSourceAudioTrackSettings, + ...(sourceAudioTrackSettingsByClip[selectedClipId] ?? {}), + }; + }, [defaultSourceAudioTrackSettings, selectedClipId, sourceAudioTrackSettingsByClip]); - const onSourceAudioTracksMetaChange = useCallback((tracks: SourceAudioTrackMeta) => { - setSourceAudioTrackMeta((prev) => (isSameTrackMeta(prev, tracks) ? prev : tracks)); - setDefaultSourceAudioTrackSettings((prev) => { - const next: SourceAudioTrackSettings = {}; - for (const track of tracks) { - next[track.id] = prev[track.id] ?? { volume: 1, normalize: false }; - } - const prevKeys = Object.keys(prev); - const nextKeys = Object.keys(next); - if (prevKeys.length !== nextKeys.length) { - return next; - } - for (const key of nextKeys) { - const prevSetting = prev[key]; - const nextSetting = next[key]; - if (!prevSetting || !nextSetting) { - return next; - } - if ( - prevSetting.volume !== nextSetting.volume || - prevSetting.normalize !== nextSetting.normalize - ) { - return next; - } - } - return prev; - }); - }, []); + const onSourceAudioTracksMetaChange = useCallback( + (tracks: SourceAudioTrackMeta) => { + setSourceAudioTrackMeta((prev) => (isSameTrackMeta(prev, tracks) ? prev : tracks)); + setDefaultSourceAudioTrackSettings((prev) => { + const next: SourceAudioTrackSettings = {}; + for (const track of tracks) { + next[track.id] = prev[track.id] ?? { volume: 1, normalize: false }; + } + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(next); + if (prevKeys.length !== nextKeys.length) { + return next; + } + for (const key of nextKeys) { + const prevSetting = prev[key]; + const nextSetting = next[key]; + if (!prevSetting || !nextSetting) { + return next; + } + if ( + prevSetting.volume !== nextSetting.volume || + prevSetting.normalize !== nextSetting.normalize + ) { + return next; + } + } + return prev; + }); + }, + [setDefaultSourceAudioTrackSettings], + ); - const getSourceAudioTrackSettingsForClip = useCallback( - (clipId: string | null): SourceAudioTrackSettings => { - if (!clipId) { - return defaultSourceAudioTrackSettings; - } - return { - ...defaultSourceAudioTrackSettings, - ...(sourceAudioTrackSettingsByClip[clipId] ?? {}), - }; - }, - [defaultSourceAudioTrackSettings, sourceAudioTrackSettingsByClip], - ); + const getSourceAudioTrackSettingsForClip = useCallback( + (clipId: string | null): SourceAudioTrackSettings => { + if (!clipId) { + return defaultSourceAudioTrackSettings; + } + return { + ...defaultSourceAudioTrackSettings, + ...(sourceAudioTrackSettingsByClip[clipId] ?? {}), + }; + }, + [defaultSourceAudioTrackSettings, sourceAudioTrackSettingsByClip], + ); const onSelectedClipSourceAudioTrackVolumeChange = useCallback( (id: string, volume: number) => { @@ -137,7 +142,7 @@ export function useSourceAudioTrackSettings({ }; }); }, - [defaultSourceAudioTrackSettings, selectedClipId], + [defaultSourceAudioTrackSettings, selectedClipId, setSourceAudioTrackSettingsByClip], ); const onSelectedClipSourceAudioTrackNormalizeChange = useCallback( @@ -161,16 +166,16 @@ export function useSourceAudioTrackSettings({ }; }); }, - [defaultSourceAudioTrackSettings, selectedClipId], + [defaultSourceAudioTrackSettings, selectedClipId, setSourceAudioTrackSettingsByClip], ); - return { - sourceAudioTrackMeta, - activeSourceAudioTrackSettings, - selectedClipSourceAudioTrackSettings, - getSourceAudioTrackSettingsForClip, - onSourceAudioTracksMetaChange, - onSelectedClipSourceAudioTrackVolumeChange, - onSelectedClipSourceAudioTrackNormalizeChange, - }; + return { + sourceAudioTrackMeta, + activeSourceAudioTrackSettings, + selectedClipSourceAudioTrackSettings, + getSourceAudioTrackSettingsForClip, + onSourceAudioTracksMetaChange, + onSelectedClipSourceAudioTrackVolumeChange, + onSelectedClipSourceAudioTrackNormalizeChange, + }; } diff --git a/src/components/video-editor/timeline/components/waveform/AudioWaveform.tsx b/src/components/video-editor/timeline/components/waveform/AudioWaveform.tsx index 1b1926518..2a5300f1a 100644 --- a/src/components/video-editor/timeline/components/waveform/AudioWaveform.tsx +++ b/src/components/video-editor/timeline/components/waveform/AudioWaveform.tsx @@ -44,6 +44,7 @@ function AudioWaveformComponent({ } }, []); + // biome-ignore lint/correctness/useExhaustiveDependencies: resizeKey intentionally redraws the canvas after ResizeObserver notifications. useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -79,15 +80,15 @@ function AudioWaveformComponent({ const visibleStartMs = segmentStartMs ?? range.start; const visibleEndMs = segmentEndMs ?? range.end; const visibleDurationMs = visibleEndMs - visibleStartMs; - + if (visibleDurationMs <= 0) return; const midY = height / 2; ctx.beginPath(); - + for (let px = 0; px < width; px++) { const t = visibleStartMs + (px / width) * visibleDurationMs; - + // If the timeline time is beyond the actual audio duration, we draw nothing (flat line) if (t < 0 || t > durationMs) continue; @@ -95,12 +96,12 @@ function AudioWaveformComponent({ const leftIndex = Math.floor(exactIndex); const rightIndex = Math.min(peakData.length - 1, leftIndex + 1); const mix = exactIndex - leftIndex; - + let amplitude = peakData[leftIndex] * (1 - mix) + peakData[rightIndex] * mix; - + if (normalize) amplitude = Math.sqrt(Math.max(0, amplitude)); amplitude = Math.max(0, Math.min(1, amplitude * gain)); - + const barHeight = amplitude * midY * 0.85; ctx.moveTo(px, midY - barHeight); From fb447035142d9c625b1aa6145c8e63ef6bb2f12a Mon Sep 17 00:00:00 2001 From: young Date: Fri, 28 Aug 2026 09:54:28 +1000 Subject: [PATCH 3/7] Apply consistent project formatting --- electron/ipc/captions/whisper.ts | 10 +- electron/ipc/cursor/bounds.ts | 16 +- electron/ipc/cursor/interaction.test.ts | 15 +- electron/ipc/cursor/telemetry.ts | 21 +- electron/ipc/ffmpeg/filters.ts | 9 +- electron/ipc/monitorResolver.ts | 14 +- electron/ipc/paths/binaries.ts | 11 +- electron/ipc/project/session.ts | 9 +- electron/ipc/recording/diagnostics.ts | 8 +- electron/ipc/recording/ffmpeg.ts | 9 +- electron/ipc/recording/prune.ts | 11 +- electron/ipc/recording/windows.ts | 12 +- electron/ipc/register/assets.ts | 234 +-- electron/ipc/register/export.test.ts | 4 +- electron/ipc/register/export.ts | 11 +- .../register/exportCaptionSidecars.test.ts | 6 +- .../ipc/register/exportCaptionSidecars.ts | 2 +- electron/ipc/register/permissions.ts | 144 +- electron/ipc/register/project.ts | 1293 +++++++++-------- electron/ipc/register/recording.ts | 596 ++++---- electron/ipc/register/sourceMapping.test.ts | 7 +- electron/ipc/register/sourceMapping.ts | 2 +- electron/ipc/register/sources.ts | 98 +- electron/ipc/utils.ts | 1 - .../bin/win32-x64/helpers-manifest.json | 66 +- .../render-tahoe-cursor-atlas.cjs | 94 +- electron/navigationPolicy.test.ts | 8 +- electron/permissionPolicy.test.ts | 23 +- scripts/benchmark-export-queues.mjs | 11 +- scripts/build-windows-capture.mjs | 13 +- scripts/build-windows-gpu-export.mjs | 17 +- scripts/create-release.mjs | 9 +- scripts/normalize-electron-main-cjs.mjs | 14 +- src/components/launch/hooks/useHudBarDrag.ts | 224 +-- .../launch/hooks/useWebcamPreviewOverlay.ts | 49 +- .../popovers/LaunchPopoverCoordinator.tsx | 14 +- src/components/launch/popovers/MicPopover.tsx | 15 +- .../launch/popovers/PopoverScaffold.tsx | 4 +- .../launch/popovers/WebcamPopover.tsx | 29 +- src/components/ui/button.tsx | 1 - src/components/ui/separator.tsx | 5 +- .../video-editor/AnnotationOverlay.tsx | 4 +- .../video-editor/AnnotationSettingsPanel.tsx | 1214 ++++++++-------- .../video-editor/ExportSettingsMenu.tsx | 41 +- src/components/video-editor/ExtensionIcon.tsx | 4 +- .../video-editor/GifOptionsPanel.tsx | 4 +- .../video-editor/KeyboardShortcutsHelp.tsx | 7 +- src/components/video-editor/TutorialHelp.tsx | 126 +- .../audio/audioResourceVersion.test.ts | 10 +- .../video-editor/audio/audioTypes.ts | 1 - .../video-editor/audio/clipAudio.ts | 18 +- .../video-editor/audio/useAudioPreviewSync.ts | 939 ++++++------ .../audio/useSourceAudioFallback.ts | 131 +- .../video-editor/audio/useVideoEditorAudio.ts | 6 +- .../audio/waveform/WaveformGenerator.ts | 42 +- .../audio/waveform/waveform.worker.ts | 4 +- .../video-editor/clipSpeedChange.test.ts | 64 +- .../video-editor/clipSpeedChange.ts | 4 +- src/components/video-editor/editorHistory.ts | 4 +- .../video-editor/mp4ExportRouting.ts | 6 +- .../video-editor/mp4ExportSettings.ts | 5 +- .../video-editor/smokeExportConfig.test.ts | 4 +- .../video-editor/smokeExportConfig.ts | 12 +- .../timeline/components/axis/TimelineAxis.tsx | 4 +- .../components/playhead/PlaybackCursor.tsx | 39 +- .../components/toolbar/TimelineToolbar.tsx | 117 +- .../hooks/actions/useTimelineAudioActions.ts | 6 +- .../hooks/actions/useTimelineZoomActions.ts | 4 +- .../timeline/hooks/useTimelineSelection.ts | 5 +- .../hooks/utils/timelineAudioPlacement.ts | 5 +- .../hooks/utils/timelineNotifications.ts | 3 +- .../timeline/model/timelineModel.test.ts | 63 +- src/components/video-editor/types.test.ts | 12 +- .../video-editor/useNvidiaCudaExportOptIn.ts | 18 +- .../videoPlayback/cursorFollowCamera.test.ts | 7 +- .../videoPlayback/motionSmoothing.ts | 90 +- .../videoPlayback/uploadedCursorAssets.ts | 3 +- .../videoPlayback/webcamSync.test.ts | 32 +- src/contexts/ThemeContext.test.ts | 5 +- src/contexts/ThemeContext.tsx | 12 +- src/i18n/config.ts | 13 +- src/i18n/locales/de/common.json | 42 +- src/i18n/locales/de/dialogs.json | 96 +- src/i18n/locales/de/editor.json | 234 +-- src/i18n/locales/de/extensions.json | 102 +- src/i18n/locales/de/launch.json | 110 +- src/i18n/locales/de/settings.json | 338 ++--- src/i18n/locales/de/shortcuts.json | 26 +- src/i18n/locales/de/timeline.json | 68 +- src/i18n/locales/en/extensions.json | 2 +- src/i18n/locales/pt-BR/extensions.json | 2 +- src/i18n/locales/ru/common.json | 2 +- src/i18n/locales/ru/dialogs.json | 2 +- src/i18n/locales/ru/editor.json | 2 +- src/i18n/locales/ru/extensions.json | 2 +- src/i18n/locales/ru/launch.json | 2 +- src/i18n/locales/ru/shortcuts.json | 2 +- src/i18n/locales/ru/timeline.json | 2 +- src/i18n/locales/zh-TW/common.json | 2 +- src/i18n/locales/zh-TW/dialogs.json | 2 +- src/i18n/locales/zh-TW/extensions.json | 2 +- src/i18n/locales/zh-TW/launch.json | 18 +- src/i18n/locales/zh-TW/shortcuts.json | 2 +- src/i18n/locales/zh-TW/timeline.json | 2 +- src/lib/exporter/annotationRenderer.ts | 7 +- src/lib/exporter/audioRoutingEngine.ts | 4 +- src/lib/exporter/mediaResource.test.ts | 2 +- src/lib/exporter/mediaResource.ts | 2 +- src/lib/exporter/modernFrameRenderer.test.ts | 14 +- src/lib/exporter/sourceTrackRoutingPolicy.ts | 5 +- src/lib/exporter/streamingDecoder.test.ts | 4 +- src/lib/exporter/temporalMotionBlur.test.ts | 1 - src/lib/pixiApplicationLifecycle.ts | 4 +- src/lib/wallpapers.ts | 5 +- 114 files changed, 3889 insertions(+), 3448 deletions(-) diff --git a/electron/ipc/captions/whisper.ts b/electron/ipc/captions/whisper.ts index c8e774c62..67b04cd58 100644 --- a/electron/ipc/captions/whisper.ts +++ b/electron/ipc/captions/whisper.ts @@ -3,7 +3,11 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import { get as httpsGet } from "node:https"; import type Electron from "electron"; -import { WHISPER_MODEL_DIR, WHISPER_MODEL_DOWNLOAD_URL, WHISPER_SMALL_MODEL_PATH } from "../constants"; +import { + WHISPER_MODEL_DIR, + WHISPER_MODEL_DOWNLOAD_URL, + WHISPER_SMALL_MODEL_PATH, +} from "../constants"; export function sendWhisperModelDownloadProgress( webContents: Electron.WebContents, @@ -106,7 +110,9 @@ export function downloadFileWithProgress( return request(url); } -export async function downloadWhisperSmallModel(webContents: Electron.WebContents): Promise { +export async function downloadWhisperSmallModel( + webContents: Electron.WebContents, +): Promise { await fs.mkdir(WHISPER_MODEL_DIR, { recursive: true }); const tempPath = `${WHISPER_SMALL_MODEL_PATH}.download`; diff --git a/electron/ipc/cursor/bounds.ts b/electron/ipc/cursor/bounds.ts index 02fb3c754..fbc7d2d50 100644 --- a/electron/ipc/cursor/bounds.ts +++ b/electron/ipc/cursor/bounds.ts @@ -119,7 +119,9 @@ export function parseXwininfoBounds(stdout: string): WindowBounds | null { }; } -export async function resolveLinuxWindowBounds(source: SelectedSource): Promise { +export async function resolveLinuxWindowBounds( + source: SelectedSource, +): Promise { const windowId = parseWindowId(source?.id); if (windowId) { @@ -153,7 +155,9 @@ export async function resolveLinuxWindowBounds(source: SelectedSource): Promise< } } -export async function resolveWindowsWindowBounds(source: SelectedSource): Promise { +export async function resolveWindowsWindowBounds( + source: SelectedSource, +): Promise { const windowId = parseWindowId(source?.id); const windowTitle = typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim(); @@ -259,7 +263,9 @@ export function startWindowBoundsCapture() { } void refreshSelectedWindowBounds(); - setWindowBoundsCaptureInterval(setInterval(() => { - void refreshSelectedWindowBounds(); - }, 250)); + setWindowBoundsCaptureInterval( + setInterval(() => { + void refreshSelectedWindowBounds(); + }, 250), + ); } diff --git a/electron/ipc/cursor/interaction.test.ts b/electron/ipc/cursor/interaction.test.ts index f126f6e33..4ea662414 100644 --- a/electron/ipc/cursor/interaction.test.ts +++ b/electron/ipc/cursor/interaction.test.ts @@ -32,7 +32,9 @@ describe("repairBundledUiohookBinaryForCurrentArch", () => { afterEach(async () => { await Promise.all( - tempRoots.splice(0).map((tempRoot) => fs.rm(tempRoot, { recursive: true, force: true })), + tempRoots + .splice(0) + .map((tempRoot) => fs.rm(tempRoot, { recursive: true, force: true })), ); }); @@ -50,9 +52,14 @@ describe("repairBundledUiohookBinaryForCurrentArch", () => { const log = vi.fn(); const repaired = repairBundledUiohookBinaryForCurrentArch( - Object.assign(new Error("mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64')"), { - code: "ERR_DLOPEN_FAILED", - }), + Object.assign( + new Error( + "mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64')", + ), + { + code: "ERR_DLOPEN_FAILED", + }, + ), { packageRoot, platform: "darwin", arch: "arm64", log }, ); diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index ebedfe72a..fd598b102 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -91,11 +91,7 @@ export async function writeCursorTelemetry(videoPath: string, samples: unknown) await fs.writeFile( telemetryPath, - JSON.stringify( - { version: CURSOR_TELEMETRY_VERSION, samples: normalizedSamples }, - null, - 2, - ), + JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: normalizedSamples }, null, 2), "utf-8", ); @@ -144,9 +140,7 @@ export function resumeCursorCapture(resumedAtMs: number) { } const pauseDurationMs = Math.max(0, resumedAtMs - cursorCapturePauseStartedAtMs); - setCursorCaptureAccumulatedPausedMs( - cursorCaptureAccumulatedPausedMs + pauseDurationMs, - ); + setCursorCaptureAccumulatedPausedMs(cursorCaptureAccumulatedPausedMs + pauseDurationMs); setCursorCapturePauseStartedAtMs(null); } @@ -217,7 +211,16 @@ export function getNormalizedCursorPoint() { } export function getHookCursorScreenPoint( - event: { x?: number; y?: number; data?: { x?: number; y?: number; screenX?: number; screenY?: number }; screenX?: number; screenY?: number } | null | undefined, + event: + | { + x?: number; + y?: number; + data?: { x?: number; y?: number; screenX?: number; screenY?: number }; + screenX?: number; + screenY?: number; + } + | null + | undefined, ): { x: number; y: number } | null { const rawX = event?.x ?? event?.data?.x ?? event?.screenX ?? event?.data?.screenX; const rawY = event?.y ?? event?.data?.y ?? event?.screenY ?? event?.data?.screenY; diff --git a/electron/ipc/ffmpeg/filters.ts b/electron/ipc/ffmpeg/filters.ts index 7e4375255..2d3370c7f 100644 --- a/electron/ipc/ffmpeg/filters.ts +++ b/electron/ipc/ffmpeg/filters.ts @@ -114,11 +114,10 @@ export function appendSyncedAudioFilter( filters.push(`adelay=${adjustment.delayMs}|${adjustment.delayMs}`); } - if ( - adjustment.mode === "delay" && - adjustment.durationDeltaMs > adjustment.delayMs + 20 - ) { - filters.push(`apad=pad_dur=${formatFfmpegSeconds(adjustment.durationDeltaMs - adjustment.delayMs)}`); + if (adjustment.mode === "delay" && adjustment.durationDeltaMs > adjustment.delayMs + 20) { + filters.push( + `apad=pad_dur=${formatFfmpegSeconds(adjustment.durationDeltaMs - adjustment.delayMs)}`, + ); } if (adjustment.mode === "tempo") { diff --git a/electron/ipc/monitorResolver.ts b/electron/ipc/monitorResolver.ts index e71f36c11..f14bbd38c 100644 --- a/electron/ipc/monitorResolver.ts +++ b/electron/ipc/monitorResolver.ts @@ -13,7 +13,7 @@ export interface WinMonitorHandle { /** * Retrieves raw HMONITOR handles from the Windows OS using a PowerShell bridge. - * This is necessary because Electron's display IDs are often internal hashes that + * This is necessary because Electron's display IDs are often internal hashes that * cannot be used directly with native Windows APIs like Graphics Capture (WGC). */ export function getMonitorHandles(): WinMonitorHandle[] { @@ -53,10 +53,14 @@ public class MonitorHelper { [MonitorHelper]::GetMonitors() `.trim(); - const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", psScript], { - encoding: "utf-8", - timeout: 5000, - }); + const result = spawnSync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", psScript], + { + encoding: "utf-8", + timeout: 5000, + }, + ); if (result.error || result.status !== 0) { // Silent failure is preferred; the caller will fall back to coordinate-based matching. diff --git a/electron/ipc/paths/binaries.ts b/electron/ipc/paths/binaries.ts index 3e15f3322..04bc577c5 100644 --- a/electron/ipc/paths/binaries.ts +++ b/electron/ipc/paths/binaries.ts @@ -4,10 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { app } from "electron"; -import { - nativeHelperMigrationPromise, - setNativeHelperMigrationPromise, -} from "../state"; +import { nativeHelperMigrationPromise, setNativeHelperMigrationPromise } from "../state"; const execFileAsync = promisify(execFile); @@ -131,7 +128,11 @@ export function getCursorMonitorExePath(): string { async function migrateLegacyNativeHelperBinaries(): Promise { const legacyToCurrentPaths: Array<[string, string]> = [ [ - path.join(app.getPath("userData"), "native-tools", "openscreen-screencapturekit-helper"), + path.join( + app.getPath("userData"), + "native-tools", + "openscreen-screencapturekit-helper", + ), getNativeCaptureHelperBinaryPath(), ], [ diff --git a/electron/ipc/project/session.ts b/electron/ipc/project/session.ts index 3c126e6d6..d5f83a839 100644 --- a/electron/ipc/project/session.ts +++ b/electron/ipc/project/session.ts @@ -15,7 +15,9 @@ export function getRecordingSessionManifestPath(videoPath: string) { return path.join(path.dirname(videoPath), `${baseName}${RECORDING_SESSION_MANIFEST_SUFFIX}`); } -export async function persistRecordingSessionManifest(session: RecordingSessionData): Promise { +export async function persistRecordingSessionManifest( + session: RecordingSessionData, +): Promise { const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath); if (!normalizedVideoPath) { return; @@ -51,8 +53,7 @@ export async function resolveRecordingSessionManifest( try { const content = await fs.readFile(manifestPath, "utf-8"); - const parsed = - parseJsonWithByteOrderMark>(content); + const parsed = parseJsonWithByteOrderMark>(content); if (parsed.version !== 1 && parsed.version !== 2) { return null; } @@ -138,5 +139,3 @@ export async function resolveRecordingSession( webcamPath: linkedWebcamPath, }; } - - diff --git a/electron/ipc/recording/diagnostics.ts b/electron/ipc/recording/diagnostics.ts index edf5f638e..985209f22 100644 --- a/electron/ipc/recording/diagnostics.ts +++ b/electron/ipc/recording/diagnostics.ts @@ -201,9 +201,7 @@ export async function probeMediaDurationSeconds(filePath: string): Promise((resolve, reject) => { const onClose = async (code: number | null) => { cleanup(); diff --git a/electron/ipc/recording/prune.ts b/electron/ipc/recording/prune.ts index d8004bd14..29787e8e2 100644 --- a/electron/ipc/recording/prune.ts +++ b/electron/ipc/recording/prune.ts @@ -82,10 +82,13 @@ async function loadSavedProjectMediaPaths() { editor?: { webcam?: { sourcePath?: unknown } }; }>(await fs.readFile(projectPath, "utf-8")); } catch (error) { - console.warn("[prune] Aborting recording prune because a saved project is unreadable", { - projectPath, - error, - }); + console.warn( + "[prune] Aborting recording prune because a saved project is unreadable", + { + projectPath, + error, + }, + ); throw error; } const candidatePaths = [ diff --git a/electron/ipc/recording/windows.ts b/electron/ipc/recording/windows.ts index 262d26daa..650cdca98 100644 --- a/electron/ipc/recording/windows.ts +++ b/electron/ipc/recording/windows.ts @@ -13,9 +13,7 @@ import { windowsCaptureTargetPath, windowsNativeCaptureActive, } from "../state"; -import { - AudioSyncAdjustment, -} from "../types"; +import { AudioSyncAdjustment } from "../types"; import { moveFileWithOverwrite } from "../utils"; import { emitRecordingInterrupted } from "./events"; @@ -135,7 +133,9 @@ export function waitForWindowsCaptureStop( const onClose = (code: number | null) => { finish(() => { - const match = windowsCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/); + const match = windowsCaptureOutputBuffer.match( + /Recording stopped\. Output path: (.+)/, + ); if (match?.[1]) { resolve(match[1].trim()); return; @@ -254,9 +254,7 @@ export async function muxNativeWindowsVideoWithAudio( } } - console.log( - `[PERF:MAIN] muxNativeWindowsVideoWithAudio: COMPLETED in ${Date.now() - start}ms`, - ); + console.log(`[PERF:MAIN] muxNativeWindowsVideoWithAudio: COMPLETED in ${Date.now() - start}ms`); return { muxed: false, diff --git a/electron/ipc/register/assets.ts b/electron/ipc/register/assets.ts index f18bf7b6a..fae8d708d 100644 --- a/electron/ipc/register/assets.ts +++ b/electron/ipc/register/assets.ts @@ -8,120 +8,122 @@ import { normalizePath } from "../utils"; import { getAssetRootPath } from "../project/manager"; export function registerAssetHandlers() { - async function resolveReadableLocalFilePath(filePath: string) { - const normalizedPath = normalizePath(filePath) - const resolvedPath = await fs.realpath(normalizedPath).catch(() => normalizedPath) - const stats = await fs.stat(resolvedPath) - if (!stats.isFile()) { - throw new Error('Path is not a readable file') - } - return normalizePath(resolvedPath) - } - - // Generate a tiny thumbnail for a wallpaper image and cache it in userData. - // Returns the cached thumbnail as raw JPEG bytes for fast grid rendering. - // Serialized to prevent concurrent nativeImage operations from eating memory. - const THUMB_SIZE = 96 - const thumbCacheDir = path.join(USER_DATA_PATH, 'wallpaper-thumbs') - let thumbGenerationQueue: Promise = Promise.resolve() - - ipcMain.handle('generate-wallpaper-thumbnail', async (_, filePath: string) => { - try { - const resolved = await resolveReadableLocalFilePath(filePath) - - // Deterministic cache key from file path + mtime - const stat = await fs.stat(resolved) - const cacheKey = Buffer.from(`${resolved}:${stat.mtimeMs}`).toString('base64url') - const thumbPath = path.join(thumbCacheDir, `${cacheKey}.jpg`) - - // Return cached thumbnail if it exists (no queue needed) - if (existsSync(thumbPath)) { - const data = await fs.readFile(thumbPath) - return { success: true, data } - } - - // Serialize nativeImage operations to avoid OOM from concurrent full-res decodes - let jpegData: Buffer - const generation = thumbGenerationQueue.then(async () => { - const { nativeImage } = await import('electron') - const img = nativeImage.createFromPath(resolved) - if (img.isEmpty()) { - throw new Error('Failed to load image') - } - const { width, height } = img.getSize() - const scale = THUMB_SIZE / Math.min(width, height) - const resized = img.resize({ - width: Math.round(width * scale), - height: Math.round(height * scale), - quality: 'good', - }) - jpegData = resized.toJPEG(70) - - // Cache to disk - await fs.mkdir(thumbCacheDir, { recursive: true }) - await fs.writeFile(thumbPath, jpegData) - }) - // Keep the queue moving even if one fails - thumbGenerationQueue = generation.catch(() => undefined) - await generation - - return { success: true, data: jpegData! } - } catch (error) { - return { success: false, error: String(error) } - } - }) - - // Return base path for assets so renderer can resolve file:// paths in production - ipcMain.handle('get-asset-base-path', () => { - try { - const assetPath = getAssetRootPath() - return pathToFileURL(`${assetPath}${path.sep}`).toString() - } catch (err) { - console.error('Failed to resolve asset base path:', err) - return null - } - }) - - ipcMain.handle('list-asset-directory', async (_, relativeDir: string) => { - try { - const normalizedRelativeDir = String(relativeDir ?? '') - .replace(/\\/g, '/') - .replace(/^\/+/, '') - - const assetRootPath = path.resolve(getAssetRootPath()) - const targetDirPath = path.resolve(assetRootPath, normalizedRelativeDir) - if (targetDirPath !== assetRootPath && !targetDirPath.startsWith(`${assetRootPath}${path.sep}`)) { - return { success: false, error: 'Invalid asset directory' } - } - - const entries = await fs.readdir(targetDirPath, { withFileTypes: true }) - const files = entries - .filter((entry) => entry.isFile()) - .map((entry) => entry.name) - .sort(new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare) - - return { success: true, files } - } catch (error) { - console.error('Failed to list asset directory:', error) - return { success: false, error: String(error) } - } - }) - - ipcMain.handle('read-local-file', async (_, filePath: string) => { - try { - // Intentionally more permissive than the media-server allowlist: this IPC - // is used for direct renderer-side local file reads after the app has - // already accepted a path, while URL-based media serving must stay scoped - // to approved/app-managed locations. We still canonicalize the path and - // require a real on-disk file so this cannot be used to read directories. - const resolved = await resolveReadableLocalFilePath(filePath) - - const data = await fs.readFile(resolved) - return { success: true, data } - } catch (error) { - console.error('Failed to read local file:', error) - return { success: false, error: String(error) } - } - }) - + async function resolveReadableLocalFilePath(filePath: string) { + const normalizedPath = normalizePath(filePath); + const resolvedPath = await fs.realpath(normalizedPath).catch(() => normalizedPath); + const stats = await fs.stat(resolvedPath); + if (!stats.isFile()) { + throw new Error("Path is not a readable file"); + } + return normalizePath(resolvedPath); + } + + // Generate a tiny thumbnail for a wallpaper image and cache it in userData. + // Returns the cached thumbnail as raw JPEG bytes for fast grid rendering. + // Serialized to prevent concurrent nativeImage operations from eating memory. + const THUMB_SIZE = 96; + const thumbCacheDir = path.join(USER_DATA_PATH, "wallpaper-thumbs"); + let thumbGenerationQueue: Promise = Promise.resolve(); + + ipcMain.handle("generate-wallpaper-thumbnail", async (_, filePath: string) => { + try { + const resolved = await resolveReadableLocalFilePath(filePath); + + // Deterministic cache key from file path + mtime + const stat = await fs.stat(resolved); + const cacheKey = Buffer.from(`${resolved}:${stat.mtimeMs}`).toString("base64url"); + const thumbPath = path.join(thumbCacheDir, `${cacheKey}.jpg`); + + // Return cached thumbnail if it exists (no queue needed) + if (existsSync(thumbPath)) { + const data = await fs.readFile(thumbPath); + return { success: true, data }; + } + + // Serialize nativeImage operations to avoid OOM from concurrent full-res decodes + let jpegData: Buffer; + const generation = thumbGenerationQueue.then(async () => { + const { nativeImage } = await import("electron"); + const img = nativeImage.createFromPath(resolved); + if (img.isEmpty()) { + throw new Error("Failed to load image"); + } + const { width, height } = img.getSize(); + const scale = THUMB_SIZE / Math.min(width, height); + const resized = img.resize({ + width: Math.round(width * scale), + height: Math.round(height * scale), + quality: "good", + }); + jpegData = resized.toJPEG(70); + + // Cache to disk + await fs.mkdir(thumbCacheDir, { recursive: true }); + await fs.writeFile(thumbPath, jpegData); + }); + // Keep the queue moving even if one fails + thumbGenerationQueue = generation.catch(() => undefined); + await generation; + + return { success: true, data: jpegData! }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + // Return base path for assets so renderer can resolve file:// paths in production + ipcMain.handle("get-asset-base-path", () => { + try { + const assetPath = getAssetRootPath(); + return pathToFileURL(`${assetPath}${path.sep}`).toString(); + } catch (err) { + console.error("Failed to resolve asset base path:", err); + return null; + } + }); + + ipcMain.handle("list-asset-directory", async (_, relativeDir: string) => { + try { + const normalizedRelativeDir = String(relativeDir ?? "") + .replace(/\\/g, "/") + .replace(/^\/+/, ""); + + const assetRootPath = path.resolve(getAssetRootPath()); + const targetDirPath = path.resolve(assetRootPath, normalizedRelativeDir); + if ( + targetDirPath !== assetRootPath && + !targetDirPath.startsWith(`${assetRootPath}${path.sep}`) + ) { + return { success: false, error: "Invalid asset directory" }; + } + + const entries = await fs.readdir(targetDirPath, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort(new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare); + + return { success: true, files }; + } catch (error) { + console.error("Failed to list asset directory:", error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("read-local-file", async (_, filePath: string) => { + try { + // Intentionally more permissive than the media-server allowlist: this IPC + // is used for direct renderer-side local file reads after the app has + // already accepted a path, while URL-based media serving must stay scoped + // to approved/app-managed locations. We still canonicalize the path and + // require a real on-disk file so this cannot be used to read directories. + const resolved = await resolveReadableLocalFilePath(filePath); + + const data = await fs.readFile(resolved); + return { success: true, data }; + } catch (error) { + console.error("Failed to read local file:", error); + return { success: false, error: String(error) }; + } + }); } diff --git a/electron/ipc/register/export.test.ts b/electron/ipc/register/export.test.ts index 33941eb18..df1e9d857 100644 --- a/electron/ipc/register/export.test.ts +++ b/electron/ipc/register/export.test.ts @@ -55,9 +55,7 @@ describe("moveExportedTempFile", () => { await moveExportedTempFile(tempPath, destinationPath); - await expect(fs.readFile(destinationPath, "utf8")).resolves.toBe( - "recordly-export", - ); + await expect(fs.readFile(destinationPath, "utf8")).resolves.toBe("recordly-export"); await expect(fs.access(tempPath)).rejects.toThrow(); }); diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index c4410a271..2eabb9786 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -75,12 +75,7 @@ export async function moveExportedTempFile(tempPath: string, destinationPath: st return; } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if ( - code !== "EXDEV" && - code !== "EPERM" && - code !== "ENOTEMPTY" && - code !== "EEXIST" - ) { + if (code !== "EXDEV" && code !== "EPERM" && code !== "ENOTEMPTY" && code !== "EEXIST") { throw error; } // Cross-device or Windows permission quirks — fall back to copy + unlink so @@ -113,9 +108,7 @@ export async function moveExportedTempFile(tempPath: string, destinationPath: st await fs.rename(partialDestinationPath, destinationPath); } catch (replaceError) { if (movedExistingDestination) { - await fs - .rename(backupDestinationPath, destinationPath) - .catch(() => undefined); + await fs.rename(backupDestinationPath, destinationPath).catch(() => undefined); } throw replaceError; } diff --git a/electron/ipc/register/exportCaptionSidecars.test.ts b/electron/ipc/register/exportCaptionSidecars.test.ts index 6839e9594..2fc571e13 100644 --- a/electron/ipc/register/exportCaptionSidecars.test.ts +++ b/electron/ipc/register/exportCaptionSidecars.test.ts @@ -56,7 +56,9 @@ describe("exportCaptionSidecars", () => { }); it("returns a warning result instead of throwing when sidecar writes fail", async () => { - const writeFileSpy = vi.spyOn(fs, "writeFile").mockRejectedValueOnce(new Error("disk full")); + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockRejectedValueOnce(new Error("disk full")); await expect( writeCaptionSidecarsBestEffort("/tmp/export.mp4", { @@ -109,4 +111,4 @@ describe("exportCaptionSidecars", () => { }), ).toBe("Video exported successfully"); }); -}); \ No newline at end of file +}); diff --git a/electron/ipc/register/exportCaptionSidecars.ts b/electron/ipc/register/exportCaptionSidecars.ts index 6711d3bb7..0560a9a96 100644 --- a/electron/ipc/register/exportCaptionSidecars.ts +++ b/electron/ipc/register/exportCaptionSidecars.ts @@ -154,4 +154,4 @@ export function withCaptionSidecarMessage( } return `${baseMessage} Captions could not be saved alongside the video.`; -} \ No newline at end of file +} diff --git a/electron/ipc/register/permissions.ts b/electron/ipc/register/permissions.ts index 07057c962..f3b8b86f1 100644 --- a/electron/ipc/register/permissions.ts +++ b/electron/ipc/register/permissions.ts @@ -2,86 +2,86 @@ import { ipcMain, shell, systemPreferences } from "electron"; import { getMacPrivacySettingsUrl } from "../utils"; export function registerPermissionHandlers() { - ipcMain.handle('open-external-url', async (_, url: string) => { - try { - // Security: only allow http/https URLs to prevent file:// or custom protocol abuse - const parsed = new URL(url) - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { - return { success: false, error: `Blocked non-HTTP URL: ${parsed.protocol}` } - } - await shell.openExternal(url) - return { success: true } - } catch (error) { - console.error('Failed to open URL:', error) - return { success: false, error: String(error) } - } - }) + ipcMain.handle("open-external-url", async (_, url: string) => { + try { + // Security: only allow http/https URLs to prevent file:// or custom protocol abuse + const parsed = new URL(url); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return { success: false, error: `Blocked non-HTTP URL: ${parsed.protocol}` }; + } + await shell.openExternal(url); + return { success: true }; + } catch (error) { + console.error("Failed to open URL:", error); + return { success: false, error: String(error) }; + } + }); - ipcMain.handle('get-accessibility-permission-status', () => { - if (process.platform !== 'darwin') { - return { success: true, trusted: true, prompted: false } - } + ipcMain.handle("get-accessibility-permission-status", () => { + if (process.platform !== "darwin") { + return { success: true, trusted: true, prompted: false }; + } - return { - success: true, - trusted: systemPreferences.isTrustedAccessibilityClient(false), - prompted: false, - } - }) + return { + success: true, + trusted: systemPreferences.isTrustedAccessibilityClient(false), + prompted: false, + }; + }); - ipcMain.handle('request-accessibility-permission', () => { - if (process.platform !== 'darwin') { - return { success: true, trusted: true, prompted: false } - } + ipcMain.handle("request-accessibility-permission", () => { + if (process.platform !== "darwin") { + return { success: true, trusted: true, prompted: false }; + } - return { - success: true, - trusted: systemPreferences.isTrustedAccessibilityClient(true), - prompted: true, - } - }) + return { + success: true, + trusted: systemPreferences.isTrustedAccessibilityClient(true), + prompted: true, + }; + }); - ipcMain.handle('get-screen-recording-permission-status', () => { - if (process.platform !== 'darwin') { - return { success: true, status: 'granted' } - } + ipcMain.handle("get-screen-recording-permission-status", () => { + if (process.platform !== "darwin") { + return { success: true, status: "granted" }; + } - try { - return { - success: true, - status: systemPreferences.getMediaAccessStatus('screen'), - } - } catch (error) { - console.error('Failed to get screen recording permission status:', error) - return { success: false, status: 'unknown', error: String(error) } - } - }) + try { + return { + success: true, + status: systemPreferences.getMediaAccessStatus("screen"), + }; + } catch (error) { + console.error("Failed to get screen recording permission status:", error); + return { success: false, status: "unknown", error: String(error) }; + } + }); - ipcMain.handle('open-screen-recording-preferences', async () => { - if (process.platform !== 'darwin') { - return { success: true } - } + ipcMain.handle("open-screen-recording-preferences", async () => { + if (process.platform !== "darwin") { + return { success: true }; + } - try { - await shell.openExternal(getMacPrivacySettingsUrl('screen')) - return { success: true } - } catch (error) { - console.error('Failed to open Screen Recording preferences:', error) - return { success: false, error: String(error) } - } - }) + try { + await shell.openExternal(getMacPrivacySettingsUrl("screen")); + return { success: true }; + } catch (error) { + console.error("Failed to open Screen Recording preferences:", error); + return { success: false, error: String(error) }; + } + }); - ipcMain.handle('open-accessibility-preferences', async () => { - if (process.platform !== 'darwin') { - return { success: true } - } + ipcMain.handle("open-accessibility-preferences", async () => { + if (process.platform !== "darwin") { + return { success: true }; + } - try { - await shell.openExternal(getMacPrivacySettingsUrl('accessibility')) - return { success: true } - } catch (error) { - console.error('Failed to open Accessibility preferences:', error) - return { success: false, error: String(error) } - } - }) + try { + await shell.openExternal(getMacPrivacySettingsUrl("accessibility")); + return { success: true }; + } catch (error) { + console.error("Failed to open Accessibility preferences:", error); + return { success: false, error: String(error) }; + } + }); } diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index f1fa43e26..a3a0cd078 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -5,26 +5,23 @@ import path from "node:path"; import { BrowserWindow, dialog, ipcMain, shell } from "electron"; import { RECORDINGS_DIR } from "../../appPaths"; import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer"; -import { - LEGACY_PROJECT_FILE_EXTENSIONS, - PROJECT_FILE_EXTENSION, -} from "../constants"; +import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION } from "../constants"; import { getProjectBackupPath, writeProjectFileAtomically } from "../project/atomicSave"; import { getProjectsDir, - getProjectThumbnailPath, + getProjectThumbnailPath, isPathInsideDirectory, isTrustedProjectPath, listProjectLibraryEntries, loadProjectFromPath, - loadRecentProjectPaths, + loadRecentProjectPaths, persistRecordingsDirectorySetting, rememberRecentProject, replaceApprovedSessionLocalReadPaths, rememberApprovedLocalReadPath, resolveApprovedLocalMediaPath, saveProjectThumbnail, - saveRecentProjectPaths, + saveRecentProjectPaths, } from "../project/manager"; import { persistRecordingSessionManifest, resolveRecordingSession } from "../project/session"; import { @@ -49,36 +46,36 @@ function normalizeRecordingTimeOffsetMs(value: unknown): number { } function normalizeBoolean(value: unknown, fallback = false): boolean { - return typeof value === "boolean" ? value : fallback; + return typeof value === "boolean" ? value : fallback; } /** * Produces a filesystem-safe project base name without the project extension. */ function normalizeProjectSaveName(projectName?: string | null) { - if (typeof projectName !== "string") { - return null; - } - - const trimmedName = projectName.trim(); - if (!trimmedName) { - return null; - } - - const withoutExtension = trimmedName.replace( - new RegExp(`\\.${PROJECT_FILE_EXTENSION}$`, "i"), - "", - ); - const withoutInvalidFilesystemChars = withoutExtension.replace(/[<>:"/\\|?*]/g, ""); - const withoutControlChars = Array.from(withoutInvalidFilesystemChars) - .filter((character) => character.charCodeAt(0) > 31) - .join(""); - const sanitizedName = withoutControlChars - .replace(/\s+/g, " ") - .replace(/[. ]+$/g, "") - .trim(); - - return sanitizedName || null; + if (typeof projectName !== "string") { + return null; + } + + const trimmedName = projectName.trim(); + if (!trimmedName) { + return null; + } + + const withoutExtension = trimmedName.replace( + new RegExp(`\\.${PROJECT_FILE_EXTENSION}$`, "i"), + "", + ); + const withoutInvalidFilesystemChars = withoutExtension.replace(/[<>:"/\\|?*]/g, ""); + const withoutControlChars = Array.from(withoutInvalidFilesystemChars) + .filter((character) => character.charCodeAt(0) > 31) + .join(""); + const sanitizedName = withoutControlChars + .replace(/\s+/g, " ") + .replace(/[. ]+$/g, "") + .trim(); + + return sanitizedName || null; } type NamedProjectSaveMode = "rename" | "copy"; @@ -91,609 +88,689 @@ function normalizeNamedProjectSaveMode(value: unknown): NamedProjectSaveMode { * Extracts the persisted source video path from a saved project payload. */ function getProjectVideoPath(projectData: unknown) { - if (!projectData || typeof projectData !== "object") { - return null; - } + if (!projectData || typeof projectData !== "object") { + return null; + } - const candidate = projectData as { videoPath?: unknown }; - return typeof candidate.videoPath === "string" ? candidate.videoPath : null; + const candidate = projectData as { videoPath?: unknown }; + return typeof candidate.videoPath === "string" ? candidate.videoPath : null; } function getProjectId(projectData: unknown) { - if (!projectData || typeof projectData !== "object") { - return null; - } - - const candidate = projectData as { projectId?: unknown }; - return typeof candidate.projectId === "string" && candidate.projectId.trim().length > 0 - ? candidate.projectId - : null; + if (!projectData || typeof projectData !== "object") { + return null; + } + + const candidate = projectData as { projectId?: unknown }; + return typeof candidate.projectId === "string" && candidate.projectId.trim().length > 0 + ? candidate.projectId + : null; } function withProjectId(projectData: unknown, projectId: string) { - if (!projectData || typeof projectData !== "object" || Array.isArray(projectData)) { - return projectData; - } - - return { - ...projectData, - projectId, - }; + if (!projectData || typeof projectData !== "object" || Array.isArray(projectData)) { + return projectData; + } + + return { + ...projectData, + projectId, + }; } function ensureProjectDataHasProjectId(projectData: unknown) { - const existingProjectId = getProjectId(projectData); - if (existingProjectId) { - return { - projectId: existingProjectId, - projectData, - }; - } - - const projectId = randomUUID(); - return { - projectId, - projectData: withProjectId(projectData, projectId), - }; + const existingProjectId = getProjectId(projectData); + if (existingProjectId) { + return { + projectId: existingProjectId, + projectData, + }; + } + + const projectId = randomUUID(); + return { + projectId, + projectData: withProjectId(projectData, projectId), + }; } async function resolveComparablePath(filePath: string) { - return fs.realpath(filePath).catch(() => path.resolve(filePath)); + return fs.realpath(filePath).catch(() => path.resolve(filePath)); } /** * Prevents a named save from silently overwriting a different project file. */ async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject( - targetProjectPath: string, - projectData: unknown, - activeProjectPath?: string | null, + targetProjectPath: string, + projectData: unknown, + activeProjectPath?: string | null, ) { - try { - await fs.stat(targetProjectPath); - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return { success: true }; - } - throw error; - } - - const targetResolvedPath = await resolveComparablePath(targetProjectPath); - if (activeProjectPath) { - const activeResolvedPath = await resolveComparablePath(activeProjectPath); - if (activeResolvedPath === targetResolvedPath) { - return { success: true }; - } - } - - const incomingProjectId = getProjectId(projectData); - const incomingVideoPath = getProjectVideoPath(projectData); - - try { - const existingProjectRaw = await fs.readFile(targetProjectPath, "utf-8"); - const existingProjectData = parseJsonWithByteOrderMark(existingProjectRaw); - const existingProjectId = getProjectId(existingProjectData); - const existingVideoPath = getProjectVideoPath(existingProjectData); - - if (existingProjectId && incomingProjectId) { - if (existingProjectId === incomingProjectId) { - return { success: true }; - } - - return { - success: false, - message: "A different project already uses this name", - }; - } - - if (existingVideoPath && incomingVideoPath && existingVideoPath !== incomingVideoPath) { - return { - success: false, - message: "A different project already uses this name", - }; - } - - if (!existingProjectId && !incomingProjectId && existingVideoPath && incomingVideoPath) { - return { - success: false, - message: "Unable to verify project identity for the chosen name", - }; - } - - return { - success: false, - message: "Unable to verify project identity for the chosen name", - }; - } catch (error) { - console.error("Failed to verify existing named project before overwrite:", error); - return { - success: false, - message: "Unable to verify project identity for the chosen name", - }; - } + try { + await fs.stat(targetProjectPath); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return { success: true }; + } + throw error; + } + + const targetResolvedPath = await resolveComparablePath(targetProjectPath); + if (activeProjectPath) { + const activeResolvedPath = await resolveComparablePath(activeProjectPath); + if (activeResolvedPath === targetResolvedPath) { + return { success: true }; + } + } + + const incomingProjectId = getProjectId(projectData); + const incomingVideoPath = getProjectVideoPath(projectData); + + try { + const existingProjectRaw = await fs.readFile(targetProjectPath, "utf-8"); + const existingProjectData = parseJsonWithByteOrderMark(existingProjectRaw); + const existingProjectId = getProjectId(existingProjectData); + const existingVideoPath = getProjectVideoPath(existingProjectData); + + if (existingProjectId && incomingProjectId) { + if (existingProjectId === incomingProjectId) { + return { success: true }; + } + + return { + success: false, + message: "A different project already uses this name", + }; + } + + if (existingVideoPath && incomingVideoPath && existingVideoPath !== incomingVideoPath) { + return { + success: false, + message: "A different project already uses this name", + }; + } + + if (!existingProjectId && !incomingProjectId && existingVideoPath && incomingVideoPath) { + return { + success: false, + message: "Unable to verify project identity for the chosen name", + }; + } + + return { + success: false, + message: "Unable to verify project identity for the chosen name", + }; + } catch (error) { + console.error("Failed to verify existing named project before overwrite:", error); + return { + success: false, + message: "Unable to verify project identity for the chosen name", + }; + } } export function registerProjectHandlers() { - ipcMain.handle('reveal-in-folder', async (_, filePath: string) => { - try { - // shell.showItemInFolder doesn't return a value, it throws on error - shell.showItemInFolder(filePath); - return { success: true }; - } catch (error) { - console.error(`Error revealing item in folder: ${filePath}`, error); - // Fallback to open the directory if revealing the item fails - // This might happen if the file was moved or deleted after export, - // or if the path is somehow invalid for showItemInFolder - try { - const openPathResult = await shell.openPath(path.dirname(filePath)); - if (openPathResult) { - // openPath returned an error message - return { success: false, error: openPathResult }; - } - return { success: true, message: 'Could not reveal item, but opened directory.' }; - } catch (openError) { - console.error(`Error opening directory: ${path.dirname(filePath)}`, openError); - return { success: false, error: String(error) }; - } - } - }); - - ipcMain.handle('open-recordings-folder', async () => { - try { - const recordingsDir = await getRecordingsDir(); - const openPathResult = await shell.openPath(recordingsDir); - if (openPathResult) { - return { success: false, error: openPathResult, message: 'Failed to open recordings folder.' }; - } - - return { success: true }; - } catch (error) { - console.error('Failed to open recordings folder:', error); - return { success: false, error: String(error), message: 'Failed to open recordings folder.' }; - } - }); - - ipcMain.handle('get-recordings-directory', async () => { - try { - const recordingsDir = await getRecordingsDir() - return { - success: true, - path: recordingsDir, - isDefault: recordingsDir === RECORDINGS_DIR, - } - } catch (error) { - return { - success: false, - path: RECORDINGS_DIR, - isDefault: true, - error: String(error), - } - } - }) - - ipcMain.handle('choose-recordings-directory', async () => { - try { - const current = await getRecordingsDir() - const result = await dialog.showOpenDialog({ - title: 'Choose recordings folder', - defaultPath: current, - properties: ['openDirectory', 'createDirectory', 'promptToCreate'], - }) - - if (result.canceled || result.filePaths.length === 0) { - return { success: false, canceled: true, path: current } - } - - const selectedPath = path.resolve(result.filePaths[0]) - await fs.mkdir(selectedPath, { recursive: true }) - await fs.access(selectedPath, fsConstants.W_OK) - await persistRecordingsDirectorySetting(selectedPath) - - return { success: true, path: selectedPath, isDefault: selectedPath === RECORDINGS_DIR } - } catch (error) { - return { success: false, error: String(error), message: 'Failed to set recordings folder' } - } - }) - - ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string, thumbnailDataUrl?: string | null) => { - try { - const projectsDir = await getProjectsDir() - const preparedProject = ensureProjectDataHasProjectId(projectData) - const trustedExistingProjectPath = existingProjectPath && - path.extname(existingProjectPath).toLowerCase() === `.${PROJECT_FILE_EXTENSION}` && - (isTrustedProjectPath(existingProjectPath) || isPathInsideDirectory(existingProjectPath, projectsDir)) - ? path.resolve(existingProjectPath) - : null - - if (trustedExistingProjectPath) { - await writeProjectFileAtomically( - trustedExistingProjectPath, - JSON.stringify(preparedProject.projectData, null, 2), - ) - setCurrentProjectPath(trustedExistingProjectPath) - await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl) - await rememberRecentProject(trustedExistingProjectPath) - return { - success: true, - path: trustedExistingProjectPath, - projectId: preparedProject.projectId, - message: 'Project saved successfully' - } - } - - if (existingProjectPath) { - return { - success: false, - message: 'Project path is no longer trusted. Use Save As to choose a project file.', - } - } - - const safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}` - const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}` - - const result = await dialog.showSaveDialog({ - title: 'Save Recordly Project', - defaultPath: path.join(projectsDir, defaultName), - filters: [ - { name: 'Recordly Project', extensions: [PROJECT_FILE_EXTENSION] }, - { name: 'JSON', extensions: ['json'] } - ], - properties: ['createDirectory', 'showOverwriteConfirmation'] - }) - - if (result.canceled || !result.filePath) { - return { - success: false, - canceled: true, - message: 'Save project canceled' - } - } - - await writeProjectFileAtomically( - result.filePath, - JSON.stringify(preparedProject.projectData, null, 2), - ) - setCurrentProjectPath(result.filePath) - await saveProjectThumbnail(result.filePath, thumbnailDataUrl) - await rememberRecentProject(result.filePath) - - return { - success: true, - path: result.filePath, - projectId: preparedProject.projectId, - message: 'Project saved successfully' - } - } catch (error) { - console.error('Failed to save project file:', error) - return { - success: false, - message: 'Failed to save project file', - error: String(error) - } - } - }) - - ipcMain.handle('save-project-file-named', async (_, projectData: unknown, projectName: string, thumbnailDataUrl?: string | null, mode?: unknown) => { - try { - const normalizedProjectName = normalizeProjectSaveName(projectName) - if (!normalizedProjectName) { - return { - success: false, - message: 'Project name is required', - } - } - - const projectsDir = await getProjectsDir() - const namedSaveMode = normalizeNamedProjectSaveMode(mode) - const activeProjectPath = isTrustedProjectPath(currentProjectPath) - ? currentProjectPath - : null - const targetProjectPath = path.join( - projectsDir, - `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`, - ) - const [activeResolvedPath, targetResolvedPath] = await Promise.all([ - activeProjectPath ? resolveComparablePath(activeProjectPath) : Promise.resolve(null), - resolveComparablePath(targetProjectPath), - ]) - const isSavingToDifferentPath = - !activeResolvedPath || activeResolvedPath !== targetResolvedPath - const preparedProject = - namedSaveMode === "copy" && isSavingToDifferentPath - ? (() => { - const projectId = randomUUID() - return { - projectId, - projectData: withProjectId(projectData, projectId), - } - })() - : ensureProjectDataHasProjectId(projectData) - - const overwriteCheck = await ensureNamedProjectSaveDoesNotOverwriteDifferentProject( - targetProjectPath, - preparedProject.projectData, - activeProjectPath, - ) - if (!overwriteCheck.success) { - return overwriteCheck - } - - await writeProjectFileAtomically( - targetProjectPath, - JSON.stringify(preparedProject.projectData, null, 2), - ) - await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl) - await rememberRecentProject(targetProjectPath) - - if (namedSaveMode === "rename" && activeProjectPath && isSavingToDifferentPath) { - await fs.unlink(activeProjectPath).catch((unlinkError: NodeJS.ErrnoException) => { - if (unlinkError.code !== 'ENOENT') { - throw unlinkError - } - }) - await fs.rm(getProjectThumbnailPath(activeProjectPath), { force: true }).catch(() => undefined) - await fs.rm(getProjectBackupPath(activeProjectPath), { force: true }).catch(() => undefined) - - const recentProjectPaths = await loadRecentProjectPaths() - const filteredRecentProjectPaths: string[] = [] - for (const recentProjectPath of recentProjectPaths) { - const recentResolvedPath = await resolveComparablePath(recentProjectPath) - if (recentResolvedPath !== activeResolvedPath) { - filteredRecentProjectPaths.push(recentProjectPath) - } - } - await saveRecentProjectPaths(filteredRecentProjectPaths) - } - - setCurrentProjectPath(targetProjectPath) - - return { - success: true, - path: targetProjectPath, - projectId: preparedProject.projectId, - message: 'Project saved successfully' - } - } catch (error) { - console.error('Failed to save named project file:', error) - return { - success: false, - message: 'Failed to save project file', - error: String(error) - } - } - }) - - ipcMain.handle('load-project-file', async () => { - try { - const projectsDir = await getProjectsDir() - const result = await dialog.showOpenDialog({ - title: 'Open Recordly Project', - defaultPath: projectsDir, - filters: [ - { name: 'Recordly Project', extensions: [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS] }, - { name: 'JSON', extensions: ['json'] }, - { name: 'All Files', extensions: ['*'] } - ], - properties: ['openFile'] - }) - - if (result.canceled || result.filePaths.length === 0) { - return { success: false, canceled: true, message: 'Open project canceled' } - } - - return await loadProjectFromPath(result.filePaths[0]) - } catch (error) { - console.error('Failed to load project file:', error) - return { - success: false, - message: 'Failed to load project file', - error: String(error) - } - } - }) - - ipcMain.handle('load-current-project-file', async () => { - try { - if (!currentProjectPath) { - return { success: false, message: 'No active project' } - } - - return await loadProjectFromPath(currentProjectPath) - } catch (error) { - console.error('Failed to load current project file:', error) - return { - success: false, - message: 'Failed to load current project file', - error: String(error), - } - } - }) - - ipcMain.handle('get-projects-directory', async () => { - try { - return { - success: true, - path: await getProjectsDir(), - } - } catch (error) { - return { - success: false, - error: String(error), - } - } - }) - - ipcMain.handle('list-project-files', async () => { - try { - const library = await listProjectLibraryEntries() - return { - success: true, - projectsDir: library.projectsDir, - entries: library.entries, - } - } catch (error) { - return { - success: false, - projectsDir: null, - entries: [], - error: String(error), - } - } - }) - - ipcMain.handle('open-project-file-at-path', async (_, filePath: string) => { - try { - return await loadProjectFromPath(filePath) - } catch (error) { - console.error('Failed to open project file at path:', error) - return { - success: false, - message: 'Failed to open project file', - error: String(error), - } - } - }) - - ipcMain.handle('open-projects-directory', async () => { - try { - const projectsDir = await getProjectsDir() - const openPathResult = await shell.openPath(projectsDir) - if (openPathResult) { - return { success: false, error: openPathResult, message: 'Failed to open projects folder.' } - } - - return { success: true, path: projectsDir } - } catch (error) { - console.error('Failed to open projects folder:', error) - return { success: false, error: String(error), message: 'Failed to open projects folder.' } - } - }) - ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean }) => { - setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path) - approveUserPath(currentVideoPath) - const resolvedSession = await resolveRecordingSession(currentVideoPath) - ?? { - videoPath: currentVideoPath!, - webcamPath: null, - timeOffsetMs: 0, - } - - const nextSession = { - ...resolvedSession, - hideOverlayCursorByDefault: - normalizeBoolean(options?.hideOverlayCursorByDefault) || - normalizeBoolean(resolvedSession.hideOverlayCursorByDefault), - } - - setCurrentRecordingSession(nextSession) - await replaceApprovedSessionLocalReadPaths([ - resolvedSession.videoPath, - resolvedSession.webcamPath, - ]) - - if (nextSession.webcamPath) { - await persistRecordingSessionManifest(nextSession) - } - - if (!options?.preserveProjectPath) { - setCurrentProjectPath(null) - } - - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send('recording-session-changed', nextSession); - } - } - - return { success: true, webcamPath: nextSession.webcamPath ?? null } - }) - - ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean }, options?: { preserveProjectPath?: boolean }) => { - const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath - setCurrentVideoPath(normalizedVideoPath) - setCurrentRecordingSession({ - videoPath: normalizedVideoPath, - webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null), - timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), - hideOverlayCursorByDefault: normalizeBoolean(session.hideOverlayCursorByDefault), - }); - await rememberApprovedLocalReadPath(currentRecordingSession!.videoPath) - await rememberApprovedLocalReadPath(currentRecordingSession!.webcamPath) - if (!options?.preserveProjectPath) { - setCurrentProjectPath(null) - } - await persistRecordingSessionManifest(currentRecordingSession!) - - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send('recording-session-changed', currentRecordingSession); - } - } - - return { success: true } - }) - - ipcMain.handle('get-current-recording-session', () => { - if (!currentRecordingSession) { - return { success: false } - } - - return { - success: true, - session: currentRecordingSession, - } - }) - - ipcMain.handle('get-current-video-path', () => { - return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false }; - }); - - ipcMain.handle('clear-current-video-path', () => { - setCurrentVideoPath(null); - setCurrentRecordingSession(null); - return { success: true }; - }); - - ipcMain.handle('delete-recording-file', async (_, filePath: string) => { - try { - if (!filePath) { - return { success: false, error: 'Only auto-generated recordings can be deleted' }; - } - const resolvedPath = await fs.realpath(filePath).catch(() => path.resolve(filePath)); + ipcMain.handle("reveal-in-folder", async (_, filePath: string) => { + try { + // shell.showItemInFolder doesn't return a value, it throws on error + shell.showItemInFolder(filePath); + return { success: true }; + } catch (error) { + console.error(`Error revealing item in folder: ${filePath}`, error); + // Fallback to open the directory if revealing the item fails + // This might happen if the file was moved or deleted after export, + // or if the path is somehow invalid for showItemInFolder + try { + const openPathResult = await shell.openPath(path.dirname(filePath)); + if (openPathResult) { + // openPath returned an error message + return { success: false, error: openPathResult }; + } + return { success: true, message: "Could not reveal item, but opened directory." }; + } catch (openError) { + console.error(`Error opening directory: ${path.dirname(filePath)}`, openError); + return { success: false, error: String(error) }; + } + } + }); + + ipcMain.handle("open-recordings-folder", async () => { + try { + const recordingsDir = await getRecordingsDir(); + const openPathResult = await shell.openPath(recordingsDir); + if (openPathResult) { + return { + success: false, + error: openPathResult, + message: "Failed to open recordings folder.", + }; + } + + return { success: true }; + } catch (error) { + console.error("Failed to open recordings folder:", error); + return { + success: false, + error: String(error), + message: "Failed to open recordings folder.", + }; + } + }); + + ipcMain.handle("get-recordings-directory", async () => { + try { + const recordingsDir = await getRecordingsDir(); + return { + success: true, + path: recordingsDir, + isDefault: recordingsDir === RECORDINGS_DIR, + }; + } catch (error) { + return { + success: false, + path: RECORDINGS_DIR, + isDefault: true, + error: String(error), + }; + } + }); + + ipcMain.handle("choose-recordings-directory", async () => { + try { + const current = await getRecordingsDir(); + const result = await dialog.showOpenDialog({ + title: "Choose recordings folder", + defaultPath: current, + properties: ["openDirectory", "createDirectory", "promptToCreate"], + }); + + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true, path: current }; + } + + const selectedPath = path.resolve(result.filePaths[0]); + await fs.mkdir(selectedPath, { recursive: true }); + await fs.access(selectedPath, fsConstants.W_OK); + await persistRecordingsDirectorySetting(selectedPath); + + return { + success: true, + path: selectedPath, + isDefault: selectedPath === RECORDINGS_DIR, + }; + } catch (error) { + return { + success: false, + error: String(error), + message: "Failed to set recordings folder", + }; + } + }); + + ipcMain.handle( + "save-project-file", + async ( + _, + projectData: unknown, + suggestedName?: string, + existingProjectPath?: string, + thumbnailDataUrl?: string | null, + ) => { + try { + const projectsDir = await getProjectsDir(); + const preparedProject = ensureProjectDataHasProjectId(projectData); + const trustedExistingProjectPath = + existingProjectPath && + path.extname(existingProjectPath).toLowerCase() === + `.${PROJECT_FILE_EXTENSION}` && + (isTrustedProjectPath(existingProjectPath) || + isPathInsideDirectory(existingProjectPath, projectsDir)) + ? path.resolve(existingProjectPath) + : null; + + if (trustedExistingProjectPath) { + await writeProjectFileAtomically( + trustedExistingProjectPath, + JSON.stringify(preparedProject.projectData, null, 2), + ); + setCurrentProjectPath(trustedExistingProjectPath); + await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl); + await rememberRecentProject(trustedExistingProjectPath); + return { + success: true, + path: trustedExistingProjectPath, + projectId: preparedProject.projectId, + message: "Project saved successfully", + }; + } + + if (existingProjectPath) { + return { + success: false, + message: + "Project path is no longer trusted. Use Save As to choose a project file.", + }; + } + + const safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}`; + const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}`; + + const result = await dialog.showSaveDialog({ + title: "Save Recordly Project", + defaultPath: path.join(projectsDir, defaultName), + filters: [ + { name: "Recordly Project", extensions: [PROJECT_FILE_EXTENSION] }, + { name: "JSON", extensions: ["json"] }, + ], + properties: ["createDirectory", "showOverwriteConfirmation"], + }); + + if (result.canceled || !result.filePath) { + return { + success: false, + canceled: true, + message: "Save project canceled", + }; + } + + await writeProjectFileAtomically( + result.filePath, + JSON.stringify(preparedProject.projectData, null, 2), + ); + setCurrentProjectPath(result.filePath); + await saveProjectThumbnail(result.filePath, thumbnailDataUrl); + await rememberRecentProject(result.filePath); + + return { + success: true, + path: result.filePath, + projectId: preparedProject.projectId, + message: "Project saved successfully", + }; + } catch (error) { + console.error("Failed to save project file:", error); + return { + success: false, + message: "Failed to save project file", + error: String(error), + }; + } + }, + ); + + ipcMain.handle( + "save-project-file-named", + async ( + _, + projectData: unknown, + projectName: string, + thumbnailDataUrl?: string | null, + mode?: unknown, + ) => { + try { + const normalizedProjectName = normalizeProjectSaveName(projectName); + if (!normalizedProjectName) { + return { + success: false, + message: "Project name is required", + }; + } + + const projectsDir = await getProjectsDir(); + const namedSaveMode = normalizeNamedProjectSaveMode(mode); + const activeProjectPath = isTrustedProjectPath(currentProjectPath) + ? currentProjectPath + : null; + const targetProjectPath = path.join( + projectsDir, + `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`, + ); + const [activeResolvedPath, targetResolvedPath] = await Promise.all([ + activeProjectPath + ? resolveComparablePath(activeProjectPath) + : Promise.resolve(null), + resolveComparablePath(targetProjectPath), + ]); + const isSavingToDifferentPath = + !activeResolvedPath || activeResolvedPath !== targetResolvedPath; + const preparedProject = + namedSaveMode === "copy" && isSavingToDifferentPath + ? (() => { + const projectId = randomUUID(); + return { + projectId, + projectData: withProjectId(projectData, projectId), + }; + })() + : ensureProjectDataHasProjectId(projectData); + + const overwriteCheck = await ensureNamedProjectSaveDoesNotOverwriteDifferentProject( + targetProjectPath, + preparedProject.projectData, + activeProjectPath, + ); + if (!overwriteCheck.success) { + return overwriteCheck; + } + + await writeProjectFileAtomically( + targetProjectPath, + JSON.stringify(preparedProject.projectData, null, 2), + ); + await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl); + await rememberRecentProject(targetProjectPath); + + if (namedSaveMode === "rename" && activeProjectPath && isSavingToDifferentPath) { + await fs + .unlink(activeProjectPath) + .catch((unlinkError: NodeJS.ErrnoException) => { + if (unlinkError.code !== "ENOENT") { + throw unlinkError; + } + }); + await fs + .rm(getProjectThumbnailPath(activeProjectPath), { force: true }) + .catch(() => undefined); + await fs + .rm(getProjectBackupPath(activeProjectPath), { force: true }) + .catch(() => undefined); + + const recentProjectPaths = await loadRecentProjectPaths(); + const filteredRecentProjectPaths: string[] = []; + for (const recentProjectPath of recentProjectPaths) { + const recentResolvedPath = await resolveComparablePath(recentProjectPath); + if (recentResolvedPath !== activeResolvedPath) { + filteredRecentProjectPaths.push(recentProjectPath); + } + } + await saveRecentProjectPaths(filteredRecentProjectPaths); + } + + setCurrentProjectPath(targetProjectPath); + + return { + success: true, + path: targetProjectPath, + projectId: preparedProject.projectId, + message: "Project saved successfully", + }; + } catch (error) { + console.error("Failed to save named project file:", error); + return { + success: false, + message: "Failed to save project file", + error: String(error), + }; + } + }, + ); + + ipcMain.handle("load-project-file", async () => { + try { + const projectsDir = await getProjectsDir(); + const result = await dialog.showOpenDialog({ + title: "Open Recordly Project", + defaultPath: projectsDir, + filters: [ + { + name: "Recordly Project", + extensions: [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS], + }, + { name: "JSON", extensions: ["json"] }, + { name: "All Files", extensions: ["*"] }, + ], + properties: ["openFile"], + }); + + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true, message: "Open project canceled" }; + } + + return await loadProjectFromPath(result.filePaths[0]); + } catch (error) { + console.error("Failed to load project file:", error); + return { + success: false, + message: "Failed to load project file", + error: String(error), + }; + } + }); + + ipcMain.handle("load-current-project-file", async () => { + try { + if (!currentProjectPath) { + return { success: false, message: "No active project" }; + } + + return await loadProjectFromPath(currentProjectPath); + } catch (error) { + console.error("Failed to load current project file:", error); + return { + success: false, + message: "Failed to load current project file", + error: String(error), + }; + } + }); + + ipcMain.handle("get-projects-directory", async () => { + try { + return { + success: true, + path: await getProjectsDir(), + }; + } catch (error) { + return { + success: false, + error: String(error), + }; + } + }); + + ipcMain.handle("list-project-files", async () => { + try { + const library = await listProjectLibraryEntries(); + return { + success: true, + projectsDir: library.projectsDir, + entries: library.entries, + }; + } catch (error) { + return { + success: false, + projectsDir: null, + entries: [], + error: String(error), + }; + } + }); + + ipcMain.handle("open-project-file-at-path", async (_, filePath: string) => { + try { + return await loadProjectFromPath(filePath); + } catch (error) { + console.error("Failed to open project file at path:", error); + return { + success: false, + message: "Failed to open project file", + error: String(error), + }; + } + }); + + ipcMain.handle("open-projects-directory", async () => { + try { + const projectsDir = await getProjectsDir(); + const openPathResult = await shell.openPath(projectsDir); + if (openPathResult) { + return { + success: false, + error: openPathResult, + message: "Failed to open projects folder.", + }; + } + + return { success: true, path: projectsDir }; + } catch (error) { + console.error("Failed to open projects folder:", error); + return { + success: false, + error: String(error), + message: "Failed to open projects folder.", + }; + } + }); + ipcMain.handle( + "set-current-video-path", + async ( + _, + path: string, + options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean }, + ) => { + setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path); + approveUserPath(currentVideoPath); + const resolvedSession = (await resolveRecordingSession(currentVideoPath)) ?? { + videoPath: currentVideoPath!, + webcamPath: null, + timeOffsetMs: 0, + }; + + const nextSession = { + ...resolvedSession, + hideOverlayCursorByDefault: + normalizeBoolean(options?.hideOverlayCursorByDefault) || + normalizeBoolean(resolvedSession.hideOverlayCursorByDefault), + }; + + setCurrentRecordingSession(nextSession); + await replaceApprovedSessionLocalReadPaths([ + resolvedSession.videoPath, + resolvedSession.webcamPath, + ]); + + if (nextSession.webcamPath) { + await persistRecordingSessionManifest(nextSession); + } + + if (!options?.preserveProjectPath) { + setCurrentProjectPath(null); + } + + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("recording-session-changed", nextSession); + } + } + + return { success: true, webcamPath: nextSession.webcamPath ?? null }; + }, + ); + + ipcMain.handle( + "set-current-recording-session", + async ( + _, + session: { + videoPath: string; + webcamPath?: string | null; + timeOffsetMs?: number; + hideOverlayCursorByDefault?: boolean; + }, + options?: { preserveProjectPath?: boolean }, + ) => { + const normalizedVideoPath = + normalizeVideoSourcePath(session.videoPath) ?? session.videoPath; + setCurrentVideoPath(normalizedVideoPath); + setCurrentRecordingSession({ + videoPath: normalizedVideoPath, + webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null), + timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), + hideOverlayCursorByDefault: normalizeBoolean(session.hideOverlayCursorByDefault), + }); + await rememberApprovedLocalReadPath(currentRecordingSession!.videoPath); + await rememberApprovedLocalReadPath(currentRecordingSession!.webcamPath); + if (!options?.preserveProjectPath) { + setCurrentProjectPath(null); + } + await persistRecordingSessionManifest(currentRecordingSession!); + + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("recording-session-changed", currentRecordingSession); + } + } + + return { success: true }; + }, + ); + + ipcMain.handle("get-current-recording-session", () => { + if (!currentRecordingSession) { + return { success: false }; + } + + return { + success: true, + session: currentRecordingSession, + }; + }); + + ipcMain.handle("get-current-video-path", () => { + return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false }; + }); + + ipcMain.handle("clear-current-video-path", () => { + setCurrentVideoPath(null); + setCurrentRecordingSession(null); + return { success: true }; + }); + + ipcMain.handle("delete-recording-file", async (_, filePath: string) => { + try { + if (!filePath) { + return { success: false, error: "Only auto-generated recordings can be deleted" }; + } + const resolvedPath = await fs.realpath(filePath).catch(() => path.resolve(filePath)); const recordingsDirRaw = await getRecordingsDir(); - const recordingsDir = await fs.realpath(recordingsDirRaw).catch(() => path.resolve(recordingsDirRaw)); - if (!isPathInsideDirectory(resolvedPath, recordingsDir) || !isAutoRecordingPath(resolvedPath)) { - return { success: false, error: 'Only auto-generated recordings can be deleted' }; - } - await fs.unlink(resolvedPath); - // Also delete the cursor telemetry sidecar if it exists - const telemetryPath = getTelemetryPathForVideo(resolvedPath); - await fs.unlink(telemetryPath).catch(() => undefined); + const recordingsDir = await fs + .realpath(recordingsDirRaw) + .catch(() => path.resolve(recordingsDirRaw)); + if ( + !isPathInsideDirectory(resolvedPath, recordingsDir) || + !isAutoRecordingPath(resolvedPath) + ) { + return { success: false, error: "Only auto-generated recordings can be deleted" }; + } + await fs.unlink(resolvedPath); + // Also delete the cursor telemetry sidecar if it exists + const telemetryPath = getTelemetryPathForVideo(resolvedPath); + await fs.unlink(telemetryPath).catch(() => undefined); const currentResolved = currentVideoPath ? await fs.realpath(currentVideoPath).catch(() => currentVideoPath) : null; if (currentResolved === resolvedPath) { - setCurrentVideoPath(null); - setCurrentRecordingSession(null); - } - return { success: true }; - } catch (error) { - return { success: false, error: String(error) }; - } - }); - - ipcMain.handle('get-local-media-url', async (_, filePath: string) => { - const baseUrl = getMediaServerBaseUrl(); - if (!baseUrl || !filePath) { - return { success: false as const }; - } - const resolved = await resolveApprovedLocalMediaPath(filePath); - if (!resolved) { - const normalized = path.resolve(filePath); - console.warn(`[get-local-media-url] Blocked disallowed path: ${normalized}`); - return { success: false as const }; - } - return { success: true as const, url: buildMediaUrl(baseUrl, resolved) }; - }); - + setCurrentVideoPath(null); + setCurrentRecordingSession(null); + } + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("get-local-media-url", async (_, filePath: string) => { + const baseUrl = getMediaServerBaseUrl(); + if (!baseUrl || !filePath) { + return { success: false as const }; + } + const resolved = await resolveApprovedLocalMediaPath(filePath); + if (!resolved) { + const normalized = path.resolve(filePath); + console.warn(`[get-local-media-url] Blocked disallowed path: ${normalized}`); + return { success: false as const }; + } + return { success: true as const, url: buildMediaUrl(baseUrl, resolved) }; + }); } diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index fa9b32f36..3e03d9709 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -435,13 +435,16 @@ export function registerRecordingHandlers( const recordingsDir = await getRecordingsDir(); const timestamp = Date.now(); const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`); - tempVideoPath = path.join(app.getPath("temp"), `recordly-native-${timestamp}.mp4`); - + tempVideoPath = path.join( + app.getPath("temp"), + `recordly-native-${timestamp}.mp4`, + ); + let captureOutput = ""; let systemAudioPath: string | null = null; let microphonePath: string | null = null; let orphanedMicAudioPath: string | null = null; - + const browserMicFallbackRequested = shouldStartWindowsBrowserMicrophoneFallback(options); const captureTarget = resolveWindowsCaptureTarget( @@ -484,7 +487,7 @@ export function registerRecordingHandlers( // Fallback to coordinate-based matching if handle resolution fails config.displayId = captureTarget.displayId; } - + config.displayX = Math.round(captureTarget.bounds.x); config.displayY = Math.round(captureTarget.bounds.y); config.displayW = Math.round(captureTarget.bounds.width); @@ -509,7 +512,10 @@ export function registerRecordingHandlers( if (options?.capturesMicrophone && !browserMicFallbackRequested) { microphonePath = path.join(recordingsDir, `recording-${timestamp}.mic.wav`); - tempMicPath = path.join(app.getPath("temp"), `recordly-native-${timestamp}.mic.wav`); + tempMicPath = path.join( + app.getPath("temp"), + `recordly-native-${timestamp}.mic.wav`, + ); config.captureMic = true; config.micOutputPath = tempMicPath; if (options.microphoneLabel) { @@ -907,333 +913,337 @@ export function registerRecordingHandlers( const start = Date.now(); console.log("[PERF:MAIN] Handler: stop-native-screen-recording: STARTED"); try { - // Windows native capture stop path - if (process.platform === "win32" && windowsNativeCaptureActive) { - let stagedTempVideoPath: string | null = null; - let stagedTempSystemAudioPath: string | null = null; - let stagedTempMicAudioPath: string | null = null; - try { - if (!windowsCaptureProcess) { - throw new Error("Native Windows capture process is not running"); - } - - const proc = windowsCaptureProcess; - const preferredVideoPath = windowsCaptureTargetPath; - const preferredOrphanedMicAudioPath = windowsOrphanedMicAudioPath; - const diagnosticsSystemAudioPath = windowsSystemAudioPath; - const diagnosticsMicAudioPath = windowsMicAudioPath; - setWindowsCaptureStopRequested(true); - proc.stdin.write("stop\n"); - const tempVideoPath = await waitForWindowsCaptureStop(proc); - stagedTempVideoPath = tempVideoPath; - const finalVideoPath = preferredVideoPath ?? tempVideoPath; + // Windows native capture stop path + if (process.platform === "win32" && windowsNativeCaptureActive) { + let stagedTempVideoPath: string | null = null; + let stagedTempSystemAudioPath: string | null = null; + let stagedTempMicAudioPath: string | null = null; + try { + if (!windowsCaptureProcess) { + throw new Error("Native Windows capture process is not running"); + } - // Native Windows capture results are initially written to a safe temporary path - // (to avoid encoding failures with non-ASCII characters). We move them to the final - // destination now using Node.js, which handles Unicode paths correctly. - if (tempVideoPath !== finalVideoPath) { - await moveFileWithOverwrite(tempVideoPath, finalVideoPath); - } + const proc = windowsCaptureProcess; + const preferredVideoPath = windowsCaptureTargetPath; + const preferredOrphanedMicAudioPath = windowsOrphanedMicAudioPath; + const diagnosticsSystemAudioPath = windowsSystemAudioPath; + const diagnosticsMicAudioPath = windowsMicAudioPath; + setWindowsCaptureStopRequested(true); + proc.stdin.write("stop\n"); + const tempVideoPath = await waitForWindowsCaptureStop(proc); + stagedTempVideoPath = tempVideoPath; + const finalVideoPath = preferredVideoPath ?? tempVideoPath; + + // Native Windows capture results are initially written to a safe temporary path + // (to avoid encoding failures with non-ASCII characters). We move them to the final + // destination now using Node.js, which handles Unicode paths correctly. + if (tempVideoPath !== finalVideoPath) { + await moveFileWithOverwrite(tempVideoPath, finalVideoPath); + } - if (windowsSystemAudioPath && tempVideoPath.endsWith(".mp4")) { - const tempAudioPath = tempVideoPath.replace(".mp4", ".system.wav"); - stagedTempSystemAudioPath = tempAudioPath; - const finalAudioPath = windowsSystemAudioPath; - if (await pathExists(tempAudioPath)) { - await moveFileWithOverwrite(tempAudioPath, finalAudioPath); - const tempJson = tempAudioPath + ".json"; - if (await pathExists(tempJson)) { - await moveFileWithOverwrite(tempJson, finalAudioPath + ".json"); + if (windowsSystemAudioPath && tempVideoPath.endsWith(".mp4")) { + const tempAudioPath = tempVideoPath.replace(".mp4", ".system.wav"); + stagedTempSystemAudioPath = tempAudioPath; + const finalAudioPath = windowsSystemAudioPath; + if (await pathExists(tempAudioPath)) { + await moveFileWithOverwrite(tempAudioPath, finalAudioPath); + const tempJson = tempAudioPath + ".json"; + if (await pathExists(tempJson)) { + await moveFileWithOverwrite(tempJson, finalAudioPath + ".json"); + } } } - } - if (windowsMicAudioPath && tempVideoPath.endsWith(".mp4")) { - const tempMicPath = tempVideoPath.replace(".mp4", ".mic.wav"); - stagedTempMicAudioPath = tempMicPath; - const finalMicPath = windowsMicAudioPath; - if (await pathExists(tempMicPath)) { - await moveFileWithOverwrite(tempMicPath, finalMicPath); - const tempJson = tempMicPath + ".json"; - if (await pathExists(tempJson)) { - await moveFileWithOverwrite(tempJson, finalMicPath + ".json"); + if (windowsMicAudioPath && tempVideoPath.endsWith(".mp4")) { + const tempMicPath = tempVideoPath.replace(".mp4", ".mic.wav"); + stagedTempMicAudioPath = tempMicPath; + const finalMicPath = windowsMicAudioPath; + if (await pathExists(tempMicPath)) { + await moveFileWithOverwrite(tempMicPath, finalMicPath); + const tempJson = tempMicPath + ".json"; + if (await pathExists(tempJson)) { + await moveFileWithOverwrite(tempJson, finalMicPath + ".json"); + } } } - } - const validation = await validateRecordedVideo(finalVideoPath); + const validation = await validateRecordedVideo(finalVideoPath); - setWindowsCaptureProcess(null); - setWindowsNativeCaptureActive(false); - setNativeScreenRecordingActive(false); - setWindowsCaptureTargetPath(null); - setWindowsCaptureStopRequested(false); - setWindowsCapturePaused(false); - setWindowsOrphanedMicAudioPath(null); - await cleanupWindowsOrphanedMicAudioPath(preferredOrphanedMicAudioPath); - setWindowsPendingVideoPath(finalVideoPath); - recordNativeCaptureDiagnostics({ - backend: "windows-wgc", - phase: "stop", - outputPath: finalVideoPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - fileSizeBytes: validation.fileSizeBytes, - }); - await writeWindowsRecordingDiagnostics(finalVideoPath, { - phase: "stop", - outputPath: finalVideoPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - details: { + setWindowsCaptureProcess(null); + setWindowsNativeCaptureActive(false); + setNativeScreenRecordingActive(false); + setWindowsCaptureTargetPath(null); + setWindowsCaptureStopRequested(false); + setWindowsCapturePaused(false); + setWindowsOrphanedMicAudioPath(null); + await cleanupWindowsOrphanedMicAudioPath(preferredOrphanedMicAudioPath); + setWindowsPendingVideoPath(finalVideoPath); + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "stop", + outputPath: finalVideoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, fileSizeBytes: validation.fileSizeBytes, - durationSeconds: validation.durationSeconds, - }, - }); + }); + await writeWindowsRecordingDiagnostics(finalVideoPath, { + phase: "stop", + outputPath: finalVideoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + details: { + fileSizeBytes: validation.fileSizeBytes, + durationSeconds: validation.durationSeconds, + }, + }); - // Persist cursor telemetry before returning so the editor can find it immediately - snapshotCursorTelemetryForPersistence(); - try { - await persistPendingCursorTelemetry(finalVideoPath); - } catch (error) { - console.warn("Failed to persist cursor telemetry during native stop:", error); - } + // Persist cursor telemetry before returning so the editor can find it immediately + snapshotCursorTelemetryForPersistence(); + try { + await persistPendingCursorTelemetry(finalVideoPath); + } catch (error) { + console.warn( + "Failed to persist cursor telemetry during native stop:", + error, + ); + } - return { success: true, path: finalVideoPath }; - } catch (error) { - console.error("Failed to stop native Windows capture:", error); - const fallbackPath = await resolveExistingPath( - windowsCaptureTargetPath, - stagedTempVideoPath, - ); - const recoveredSystemAudioPath = await resolveExistingPath( - windowsSystemAudioPath, - stagedTempSystemAudioPath, - ); - const recoveredMicAudioPath = await resolveExistingPath( - windowsMicAudioPath, - stagedTempMicAudioPath, - ); - const fallbackOrphanedMicAudioPath = windowsOrphanedMicAudioPath; - const diagnosticsSystemAudioPath = recoveredSystemAudioPath ?? windowsSystemAudioPath; - const diagnosticsMicAudioPath = recoveredMicAudioPath ?? windowsMicAudioPath; - setWindowsNativeCaptureActive(false); - setNativeScreenRecordingActive(false); - setWindowsCaptureProcess(null); - setWindowsCaptureTargetPath(null); - setWindowsCaptureStopRequested(false); - setWindowsCapturePaused(false); - setWindowsOrphanedMicAudioPath(null); + return { success: true, path: finalVideoPath }; + } catch (error) { + console.error("Failed to stop native Windows capture:", error); + const fallbackPath = await resolveExistingPath( + windowsCaptureTargetPath, + stagedTempVideoPath, + ); + const recoveredSystemAudioPath = await resolveExistingPath( + windowsSystemAudioPath, + stagedTempSystemAudioPath, + ); + const recoveredMicAudioPath = await resolveExistingPath( + windowsMicAudioPath, + stagedTempMicAudioPath, + ); + const fallbackOrphanedMicAudioPath = windowsOrphanedMicAudioPath; + const diagnosticsSystemAudioPath = + recoveredSystemAudioPath ?? windowsSystemAudioPath; + const diagnosticsMicAudioPath = recoveredMicAudioPath ?? windowsMicAudioPath; + setWindowsNativeCaptureActive(false); + setNativeScreenRecordingActive(false); + setWindowsCaptureProcess(null); + setWindowsCaptureTargetPath(null); + setWindowsCaptureStopRequested(false); + setWindowsCapturePaused(false); + setWindowsOrphanedMicAudioPath(null); - if (fallbackPath) { - try { - const validation = await validateRecordedVideo(fallbackPath); - setWindowsPendingVideoPath(fallbackPath); - setWindowsSystemAudioPath(recoveredSystemAudioPath); - setWindowsMicAudioPath(recoveredMicAudioPath); - await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath); - recordNativeCaptureDiagnostics({ - backend: "windows-wgc", - phase: "stop", - outputPath: fallbackPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - fileSizeBytes: validation.fileSizeBytes, - error: String(error), - }); - await writeWindowsRecordingDiagnostics(fallbackPath, { - phase: "stop", - outputPath: fallbackPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - error: String(error), - details: { + if (fallbackPath) { + try { + const validation = await validateRecordedVideo(fallbackPath); + setWindowsPendingVideoPath(fallbackPath); + setWindowsSystemAudioPath(recoveredSystemAudioPath); + setWindowsMicAudioPath(recoveredMicAudioPath); + await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath); + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, fileSizeBytes: validation.fileSizeBytes, - durationSeconds: validation.durationSeconds, - recoveredAfterStopFailure: true, - }, - }); - return { success: true, path: fallbackPath }; - } catch { - // File is absent or failed validation. + error: String(error), + }); + await writeWindowsRecordingDiagnostics(fallbackPath, { + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + error: String(error), + details: { + fileSizeBytes: validation.fileSizeBytes, + durationSeconds: validation.durationSeconds, + recoveredAfterStopFailure: true, + }, + }); + return { success: true, path: fallbackPath }; + } catch { + // File is absent or failed validation. + } } - } - setWindowsSystemAudioPath(null); - setWindowsMicAudioPath(null); - setWindowsPendingVideoPath(null); - await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath); + setWindowsSystemAudioPath(null); + setWindowsMicAudioPath(null); + setWindowsPendingVideoPath(null); + await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath); - recordNativeCaptureDiagnostics({ - backend: "windows-wgc", - phase: "stop", - outputPath: fallbackPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - fileSizeBytes: await getFileSizeIfPresent(fallbackPath), - error: String(error), - }); - await writeWindowsRecordingDiagnostics(fallbackPath, { - phase: "stop", - outputPath: fallbackPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - error: String(error), - details: { + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, fileSizeBytes: await getFileSizeIfPresent(fallbackPath), - }, - }); + error: String(error), + }); + await writeWindowsRecordingDiagnostics(fallbackPath, { + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + error: String(error), + details: { + fileSizeBytes: await getFileSizeIfPresent(fallbackPath), + }, + }); + return { + success: false, + message: "Failed to stop native Windows capture", + error: String(error), + }; + } + } + + if (process.platform !== "darwin") { return { success: false, - message: "Failed to stop native Windows capture", - error: String(error), + message: "Native screen recording is only available on macOS.", }; } - } - - 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." }; - } + if (!nativeScreenRecordingActive) { + const recovered = await recoverNativeMacCaptureOutput(); + if (recovered) { + return recovered; + } - try { - if (!nativeCaptureProcess) { - throw new Error("Native capture helper process is not running"); + return { success: false, message: "No native screen recording is active." }; } - 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); - } + try { + if (!nativeCaptureProcess) { + throw new Error("Native capture helper process is not running"); + } - if (preferredSystemAudioPath || preferredMicrophonePath) { + const process = nativeCaptureProcess; + const preferredVideoPath = nativeCaptureTargetPath; + const preferredSystemAudioPath = nativeCaptureSystemAudioPath; + const preferredMicrophonePath = nativeCaptureMicrophonePath; console.log( - "[stop-native] Attempting audio mux (merging separate tracks) into:", - finalVideoPath, + "[stop-native] Audio paths — system:", + preferredSystemAudioPath, + "mic:", + preferredMicrophonePath, ); - try { - await muxNativeMacRecordingWithAudio( + 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, - 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, ); + 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"); } - } 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); + 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), - }); + 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, - ); + // 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 } - return await finalizeStoredVideo(fallbackPath); - } catch { - // File doesn't exist or isn't accessible } - } - const recovered = await recoverNativeMacCaptureOutput(); - if (recovered) { - return recovered; - } + const recovered = await recoverNativeMacCaptureOutput(); + if (recovered) { + return recovered; + } return { success: false, diff --git a/electron/ipc/register/sourceMapping.test.ts b/electron/ipc/register/sourceMapping.test.ts index d0b68e750..84351a506 100644 --- a/electron/ipc/register/sourceMapping.test.ts +++ b/electron/ipc/register/sourceMapping.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - getScreenSourceIdForDisplay, - LINUX_PORTAL_SCREEN_SOURCE_ID, -} from "./sourceMapping"; +import { getScreenSourceIdForDisplay, LINUX_PORTAL_SCREEN_SOURCE_ID } from "./sourceMapping"; describe("getScreenSourceIdForDisplay", () => { it("keeps the live Electron screen source when one is available", () => { @@ -47,4 +44,4 @@ describe("getScreenSourceIdForDisplay", () => { }), ).toBe("screen:fallback:42"); }); -}); \ No newline at end of file +}); diff --git a/electron/ipc/register/sourceMapping.ts b/electron/ipc/register/sourceMapping.ts index a61b4cf72..8b13501a2 100644 --- a/electron/ipc/register/sourceMapping.ts +++ b/electron/ipc/register/sourceMapping.ts @@ -32,4 +32,4 @@ export function getScreenSourceIdForDisplay({ } return `screen:fallback:${displayId}`; -} \ No newline at end of file +} diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index 33c9ee74c..a92e55808 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -18,13 +18,11 @@ import { reassertHudOverlayMousePassthrough } from "../../windows"; const execFileAsync = promisify(execFile); const SOURCE_LIST_CACHE_TTL_MS = 1200; -let sourceListCache: - | { - key: string; - expiresAt: number; - value: Array>; - } - | null = null; +let sourceListCache: { + key: string; + expiresAt: number; + value: Array>; +} | null = null; function normalizeDesktopSourceName(value: string) { return value.trim().replace(/\s+/g, " ").toLowerCase(); @@ -53,7 +51,11 @@ export function registerSourceHandlers({ thumbnailSize: opts?.thumbnailSize, fetchWindowIcons: opts?.fetchWindowIcons, }); - if (sourceListCache && sourceListCache.key === cacheKey && sourceListCache.expiresAt > Date.now()) { + if ( + sourceListCache && + sourceListCache.key === cacheKey && + sourceListCache.expiresAt > Date.now() + ) { return sourceListCache.value; } @@ -236,13 +238,12 @@ export function registerSourceHandlers({ thumbnail: electronWindowSource?.thumbnail ? electronWindowSource.thumbnail.toDataURL() : null, - appIcon: - includeWindowIcons - ? (source.appIcon ?? - (electronWindowSource?.appIcon - ? electronWindowSource.appIcon.toDataURL() - : null)) - : null, + appIcon: includeWindowIcons + ? (source.appIcon ?? + (electronWindowSource?.appIcon + ? electronWindowSource.appIcon.toDataURL() + : null)) + : null, appName: source.appName, windowTitle: source.windowTitle, sourceType: "window" as const, @@ -483,15 +484,17 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
-` +`; try { - await highlightWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) + await highlightWin.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(html)}`, + ); } catch (loadError) { if (!highlightWin.isDestroyed()) { - highlightWin.close() + highlightWin.close(); } - throw loadError + throw loadError; } // The highlight window appearing (even with focusable:false) can corrupt @@ -501,8 +504,8 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} reassertHudOverlayMousePassthrough(); const highlightCloseTimer = setTimeout(() => { - if (!highlightWin.isDestroyed()) highlightWin.close() - }, 1700) + if (!highlightWin.isDestroyed()) highlightWin.close(); + }, 1700); highlightWin.on("closed", () => { clearTimeout(highlightCloseTimer); @@ -511,32 +514,31 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} reassertHudOverlayMousePassthrough(); }); - return { success: true } - } catch (error) { - console.error('Failed to show source highlight:', error) - return { success: false } - } - }) - - ipcMain.handle('get-selected-source', () => { - return selectedSource - }) - - ipcMain.handle('open-source-selector', () => { - const sourceSelectorWin = getSourceSelectorWindow() - if (sourceSelectorWin) { - sourceSelectorWin.focus() - return - } - createSourceSelectorWindow() - }) - ipcMain.handle('switch-to-editor', () => { - console.log('[switch-to-editor] Opening editor window') - const sourceSelectorWin = getSourceSelectorWindow() - if (sourceSelectorWin && !sourceSelectorWin.isDestroyed()) { - sourceSelectorWin.close() - } - createEditorWindow() - }) + return { success: true }; + } catch (error) { + console.error("Failed to show source highlight:", error); + return { success: false }; + } + }); + + ipcMain.handle("get-selected-source", () => { + return selectedSource; + }); + ipcMain.handle("open-source-selector", () => { + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin) { + sourceSelectorWin.focus(); + return; + } + createSourceSelectorWindow(); + }); + ipcMain.handle("switch-to-editor", () => { + console.log("[switch-to-editor] Opening editor window"); + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin && !sourceSelectorWin.isDestroyed()) { + sourceSelectorWin.close(); + } + createEditorWindow(); + }); } diff --git a/electron/ipc/utils.ts b/electron/ipc/utils.ts index 3f2efb065..23960f209 100644 --- a/electron/ipc/utils.ts +++ b/electron/ipc/utils.ts @@ -129,4 +129,3 @@ export function approveUserPath(filePath: string | null | undefined): void { // Ignore invalid paths; later reads will surface the underlying error. } } - diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index c47558692..62ee78b5a 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -1,35 +1,35 @@ { - "version": 1, - "platform": "win32", - "arch": "x64", - "helpers": { - "wgc-capture": { - "binaryName": "wgc-capture.exe", - "binarySha256": "4d89fdff8e3343998c7a3b4d75d964c1f75f93594aa097c6681e477d25ec9f01", - "sourceDir": "electron/native/wgc-capture", - "sourceFingerprint": "c6dac250c9d16f7aa881998353441b4ae3fb59731b802e3b8491a136e79726b0", - "updatedAt": "2026-07-11T11:58:45.856Z" - }, - "cursor-monitor": { - "binaryName": "cursor-monitor.exe", - "binarySha256": "f1d8f30e8d7bee19ecea91c9a90a95ea4824138b8336b18030fa0602a641d70d", - "sourceDir": "electron/native/cursor-monitor", - "sourceFingerprint": "45bb72e4039e061a354af87317d7c42ec3d2118369b3f4379046ff497b701e72", - "updatedAt": "2026-07-11T11:58:56.534Z" - }, - "recordly-gpu-export": { - "binaryName": "recordly-gpu-export.exe", - "binarySha256": "49a2ac588206305d0129e6263ce4be49780c50a9dc4efc7df63aad09178a919f", - "sourceDir": "electron/native/gpu-export-probe", - "sourceFingerprint": "37a5842eba63cdeccfddd02cc278a98207248fb0aa6b56153fb85ed152c908c1", - "updatedAt": "2026-07-11T11:58:51.659Z" - }, - "recordly-nvidia-cuda-compositor": { - "binaryName": "recordly-nvidia-cuda-compositor.exe", - "binarySha256": "250a3f8cac7c6ea38a873434d23d4b2be7d6555e42cc0b405aa26f774169159c", - "sourceDir": "electron/native/nvidia-cuda-compositor", - "sourceFingerprint": "de1219228ce326e96d1f4815a3763b10d5f235cc1286bc6542c99707a85d5947", - "updatedAt": "2026-05-27T11:29:32.957Z" - } - } + "version": 1, + "platform": "win32", + "arch": "x64", + "helpers": { + "wgc-capture": { + "binaryName": "wgc-capture.exe", + "binarySha256": "4d89fdff8e3343998c7a3b4d75d964c1f75f93594aa097c6681e477d25ec9f01", + "sourceDir": "electron/native/wgc-capture", + "sourceFingerprint": "c6dac250c9d16f7aa881998353441b4ae3fb59731b802e3b8491a136e79726b0", + "updatedAt": "2026-07-11T11:58:45.856Z" + }, + "cursor-monitor": { + "binaryName": "cursor-monitor.exe", + "binarySha256": "f1d8f30e8d7bee19ecea91c9a90a95ea4824138b8336b18030fa0602a641d70d", + "sourceDir": "electron/native/cursor-monitor", + "sourceFingerprint": "45bb72e4039e061a354af87317d7c42ec3d2118369b3f4379046ff497b701e72", + "updatedAt": "2026-07-11T11:58:56.534Z" + }, + "recordly-gpu-export": { + "binaryName": "recordly-gpu-export.exe", + "binarySha256": "49a2ac588206305d0129e6263ce4be49780c50a9dc4efc7df63aad09178a919f", + "sourceDir": "electron/native/gpu-export-probe", + "sourceFingerprint": "37a5842eba63cdeccfddd02cc278a98207248fb0aa6b56153fb85ed152c908c1", + "updatedAt": "2026-07-11T11:58:51.659Z" + }, + "recordly-nvidia-cuda-compositor": { + "binaryName": "recordly-nvidia-cuda-compositor.exe", + "binarySha256": "250a3f8cac7c6ea38a873434d23d4b2be7d6555e42cc0b405aa26f774169159c", + "sourceDir": "electron/native/nvidia-cuda-compositor", + "sourceFingerprint": "de1219228ce326e96d1f4815a3763b10d5f235cc1286bc6542c99707a85d5947", + "updatedAt": "2026-05-27T11:29:32.957Z" + } + } } diff --git a/electron/native/nvidia-cuda-compositor/render-tahoe-cursor-atlas.cjs b/electron/native/nvidia-cuda-compositor/render-tahoe-cursor-atlas.cjs index 5407d327a..96c72c4b1 100644 --- a/electron/native/nvidia-cuda-compositor/render-tahoe-cursor-atlas.cjs +++ b/electron/native/nvidia-cuda-compositor/render-tahoe-cursor-atlas.cjs @@ -4,31 +4,31 @@ const path = require("node:path"); const drawHeight = 256; const padding = 2; const cursorTypes = [ - "arrow", - "text", - "pointer", - "crosshair", - "open-hand", - "closed-hand", - "resize-ew", - "resize-ns", - "not-allowed", + "arrow", + "text", + "pointer", + "crosshair", + "open-hand", + "closed-hand", + "resize-ew", + "resize-ns", + "not-allowed", ]; const tahoeAssets = { - arrow: ["pointer-1__14-6.svg", 0.14, 0.06], - text: ["ibeam-1__50-44.svg", 0.5, 0.44], - pointer: ["pointinghand-1__40-10.svg", 0.4, 0.1], - crosshair: ["crosshair-1__50-50.svg", 0.5, 0.5], - "open-hand": ["openhand-1__55-57.svg", 0.55, 0.57], - "closed-hand": ["closedhand-1__50-46.svg", 0.5, 0.46], - "resize-ew": ["resizeeastwest-1__50-50.svg", 0.5, 0.5], - "resize-ns": ["resizenorthsouth-1__50-49.svg", 0.5, 0.49], - "not-allowed": ["notallowed-1__23-0.svg", 0.23, 0], + arrow: ["pointer-1__14-6.svg", 0.14, 0.06], + text: ["ibeam-1__50-44.svg", 0.5, 0.44], + pointer: ["pointinghand-1__40-10.svg", 0.4, 0.1], + crosshair: ["crosshair-1__50-50.svg", 0.5, 0.5], + "open-hand": ["openhand-1__55-57.svg", 0.55, 0.57], + "closed-hand": ["closedhand-1__50-46.svg", 0.5, 0.46], + "resize-ew": ["resizeeastwest-1__50-50.svg", 0.5, 0.5], + "resize-ns": ["resizenorthsouth-1__50-49.svg", 0.5, 0.49], + "not-allowed": ["notallowed-1__23-0.svg", 0.23, 0], }; function arg(name, fallback = "") { - const index = process.argv.indexOf(name); - return index >= 0 && index + 1 < process.argv.length ? process.argv[index + 1] : fallback; + const index = process.argv.indexOf(name); + return index >= 0 && index + 1 < process.argv.length ? process.argv[index + 1] : fallback; } const repoRoot = arg("--repo-root"); @@ -36,41 +36,43 @@ const atlasRgbaPath = arg("--output-rgba"); const atlasMetadataPath = arg("--output-metadata"); if (!repoRoot || !atlasRgbaPath || !atlasMetadataPath) { - console.error("Usage: electron render-tahoe-cursor-atlas.cjs --repo-root --output-rgba --output-metadata "); - process.exit(1); + console.error( + "Usage: electron render-tahoe-cursor-atlas.cjs --repo-root --output-rgba --output-metadata ", + ); + process.exit(1); } const assets = cursorTypes.map((type, index) => { - const [fileName, anchorX, anchorY] = tahoeAssets[type]; - return { - type, - index, - filePath: path.join(repoRoot, "src", "assets", "cursors", "tahoe", fileName), - anchorX, - anchorY, - }; + const [fileName, anchorX, anchorY] = tahoeAssets[type]; + return { + type, + index, + filePath: path.join(repoRoot, "src", "assets", "cursors", "tahoe", fileName), + anchorX, + anchorY, + }; }); app.disableHardwareAcceleration(); app.whenReady().then(async () => { - const window = new BrowserWindow({ - show: false, - width: 1, - height: 1, - webPreferences: { - nodeIntegration: true, - contextIsolation: false, - backgroundThrottling: false, - }, - }); + const window = new BrowserWindow({ + show: false, + width: 1, + height: 1, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + backgroundThrottling: false, + }, + }); - ipcMain.once("atlas-ready", (_event, result) => { - console.log(JSON.stringify(result)); - app.quit(); - }); + ipcMain.once("atlas-ready", (_event, result) => { + console.log(JSON.stringify(result)); + app.quit(); + }); - const html = ` + const html = ` `; - await window.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html)); + await window.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html)); }); diff --git a/electron/navigationPolicy.test.ts b/electron/navigationPolicy.test.ts index e021c96ab..5c23c7375 100644 --- a/electron/navigationPolicy.test.ts +++ b/electron/navigationPolicy.test.ts @@ -224,7 +224,9 @@ describe("navigation event handlers", () => { // history.replaceState() changes getURL() without crossing a document-navigation boundary. currentUrl = "file:///opt/Recordly/dist/index.html?windowType=source-selector"; - const willNavigate = on.mock.calls.find(([eventName]) => eventName === "will-navigate")?.[1]; + const willNavigate = on.mock.calls.find( + ([eventName]) => eventName === "will-navigate", + )?.[1]; if (typeof willNavigate !== "function") { throw new Error("will-navigate handler was not registered"); } @@ -250,7 +252,9 @@ describe("navigation event handlers", () => { ); const didNavigate = on.mock.calls.find(([eventName]) => eventName === "did-navigate")?.[1]; - const willNavigate = on.mock.calls.find(([eventName]) => eventName === "will-navigate")?.[1]; + const willNavigate = on.mock.calls.find( + ([eventName]) => eventName === "will-navigate", + )?.[1]; if (typeof didNavigate !== "function" || typeof willNavigate !== "function") { throw new Error("navigation handlers were not registered"); } diff --git a/electron/permissionPolicy.test.ts b/electron/permissionPolicy.test.ts index 45ffc82f0..36b5dce63 100644 --- a/electron/permissionPolicy.test.ts +++ b/electron/permissionPolicy.test.ts @@ -169,17 +169,18 @@ describe("shouldGrantDisplayCapture", () => { ).toBe(true); }); - it.each(["null", "file://", "file:///"])( - "accepts Chromium's packaged file origin form: %s", - (securityOrigin) => { - expect( - shouldGrantDisplayCapture( - makeRequest({ currentDocumentUrl: FILE_HUD_URL, securityOrigin }), - TRUSTED_DOCUMENT_BASE_URLS, - ), - ).toBe(true); - }, - ); + it.each([ + "null", + "file://", + "file:///", + ])("accepts Chromium's packaged file origin form: %s", (securityOrigin) => { + expect( + shouldGrantDisplayCapture( + makeRequest({ currentDocumentUrl: FILE_HUD_URL, securityOrigin }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }); it.each([ ["another BrowserWindow", { isTrustedCaptureWindow: false }], diff --git a/scripts/benchmark-export-queues.mjs b/scripts/benchmark-export-queues.mjs index 6628b9d8c..57b030c69 100644 --- a/scripts/benchmark-export-queues.mjs +++ b/scripts/benchmark-export-queues.mjs @@ -201,7 +201,12 @@ function parseExportQuality(rawValue) { return null; } - if (rawValue === "medium" || rawValue === "good" || rawValue === "high" || rawValue === "source") { + if ( + rawValue === "medium" || + rawValue === "good" || + rawValue === "high" || + rawValue === "source" + ) { return rawValue; } @@ -928,7 +933,9 @@ async function main() { printRequestedConfigTable(benchmarkRequests); if (providedInputPath) { - console.log(`[benchmark-export-queues] Using provided input video: ${providedInputPath}`); + console.log( + `[benchmark-export-queues] Using provided input video: ${providedInputPath}`, + ); await fs.copyFile(providedInputPath, inputPath); } else { console.log(`[benchmark-export-queues] Generating fixture video: ${inputPath}`); diff --git a/scripts/build-windows-capture.mjs b/scripts/build-windows-capture.mjs index 82f9c9391..14cc83461 100644 --- a/scripts/build-windows-capture.mjs +++ b/scripts/build-windows-capture.mjs @@ -128,11 +128,14 @@ try { prefix: "build-windows-capture", clearCache: clearCmakeCache, configure: (generator, toolset) => - execSync(`${cmake} .. -G "${generator}" -A ${generatorArch}${toolset ? ` -T ${toolset}` : ""}`, { - cwd: buildDir, - stdio: "inherit", - timeout: 120000, - }), + execSync( + `${cmake} .. -G "${generator}" -A ${generatorArch}${toolset ? ` -T ${toolset}` : ""}`, + { + cwd: buildDir, + stdio: "inherit", + timeout: 120000, + }, + ), }); } catch (error) { console.error("[build-windows-capture] CMake configure failed:", error.message); diff --git a/scripts/build-windows-gpu-export.mjs b/scripts/build-windows-gpu-export.mjs index 27730fbf3..63e756be3 100644 --- a/scripts/build-windows-gpu-export.mjs +++ b/scripts/build-windows-gpu-export.mjs @@ -97,7 +97,9 @@ if (!cmake) { binaryName: "recordly-gpu-export.exe", }); if (!verification.ok) { - console.error(formatNativeHelperManifestWarning("build-windows-gpu-export", verification)); + console.error( + formatNativeHelperManifestWarning("build-windows-gpu-export", verification), + ); process.exit(1); } console.log(`[build-windows-gpu-export] Using bundled helper: ${bundledExePath}`); @@ -123,11 +125,14 @@ try { prefix: "build-windows-gpu-export", clearCache: clearCmakeCache, configure: (generator, toolset) => - execSync(`${cmake} .. -G "${generator}" -A ${generatorArch}${toolset ? ` -T ${toolset}` : ""}`, { - cwd: buildDir, - stdio: "inherit", - timeout: 120000, - }), + execSync( + `${cmake} .. -G "${generator}" -A ${generatorArch}${toolset ? ` -T ${toolset}` : ""}`, + { + cwd: buildDir, + stdio: "inherit", + timeout: 120000, + }, + ), }); } catch (error) { console.error("[build-windows-gpu-export] CMake configure failed:", error.message); diff --git a/scripts/create-release.mjs b/scripts/create-release.mjs index 3a586bded..25c36c6d2 100644 --- a/scripts/create-release.mjs +++ b/scripts/create-release.mjs @@ -72,9 +72,12 @@ function loadNotes({ notes, notesFile }) { } function resolveGhBinary() { - const candidates = [process.env.GH_BIN, "gh", "/opt/homebrew/bin/gh", "/usr/local/bin/gh"].filter( - Boolean, - ); + const candidates = [ + process.env.GH_BIN, + "gh", + "/opt/homebrew/bin/gh", + "/usr/local/bin/gh", + ].filter(Boolean); for (const candidate of candidates) { try { diff --git a/scripts/normalize-electron-main-cjs.mjs b/scripts/normalize-electron-main-cjs.mjs index fcbca31f3..082ab4262 100644 --- a/scripts/normalize-electron-main-cjs.mjs +++ b/scripts/normalize-electron-main-cjs.mjs @@ -76,9 +76,7 @@ function convertNamedExports(namedSpec, indent = "") { } function convertExportLine(line) { - const singleLineMatch = line.match( - /^([ \t]*)export\s*\{\s*([^}]*)\s*\}\s*;?[ \t]*$/, - ); + const singleLineMatch = line.match(/^([ \t]*)export\s*\{\s*([^}]*)\s*\}\s*;?[ \t]*$/); if (singleLineMatch) { const [, indent, namedSpec] = singleLineMatch; return convertNamedExports(namedSpec, indent); @@ -235,10 +233,7 @@ function replaceImportMetaUrlInCode(line, state) { continue; } - if ( - line.startsWith(token, index) && - hasTokenBoundary(line, index, index + token.length) - ) { + if (line.startsWith(token, index) && hasTokenBoundary(line, index, index + token.length)) { normalizedLine += IMPORT_META_URL_CJS_REPLACEMENT; changed = true; index += token.length - 1; @@ -322,10 +317,7 @@ function containsImportMetaInCode(line, state) { continue; } - if ( - line.startsWith(token, index) && - hasTokenBoundary(line, index, index + token.length) - ) { + if (line.startsWith(token, index) && hasTokenBoundary(line, index, index + token.length)) { return true; } } diff --git a/src/components/launch/hooks/useHudBarDrag.ts b/src/components/launch/hooks/useHudBarDrag.ts index 6f6124484..9c93aa7f1 100644 --- a/src/components/launch/hooks/useHudBarDrag.ts +++ b/src/components/launch/hooks/useHudBarDrag.ts @@ -1,12 +1,8 @@ +import { type PointerEvent, type RefObject, useCallback, useEffect, useRef, useState } from "react"; import { - type PointerEvent, - type RefObject, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { mergeHudInteractiveBounds, shouldRestoreHudMousePassthroughAfterDrag } from "../hudMousePassthrough"; + mergeHudInteractiveBounds, + shouldRestoreHudMousePassthroughAfterDrag, +} from "../hudMousePassthrough"; import { clampHudOffsetToViewport } from "../hudViewportBounds"; const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 }; @@ -24,20 +20,17 @@ export function useHudBarDrag({ const [isHudDragging, setIsHudDragging] = useState(false); const hudBarTransformRef = useRef(null); const recordingHudOffsetRef = useRef(DEFAULT_RECORDING_HUD_OFFSET); - const hudDragStartRef = useRef< - | { - pointerId: number; - startX: number; - startY: number; - originX: number; - originY: number; - initialLeft: number; - initialTop: number; - hudWidth: number; - hudHeight: number; - } - | null - >(null); + const hudDragStartRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + originX: number; + originY: number; + initialLeft: number; + initialTop: number; + hudWidth: number; + hudHeight: number; + } | null>(null); const isHudDraggingRef = useRef(false); const hudDragMoveRafRef = useRef(null); const hudDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); @@ -55,11 +48,10 @@ export function useHudBarDrag({ } const bounds = hudBarRef.current.getBoundingClientRect(); - const nextOffset = clampHudOffsetToViewport( - recordingHudOffsetRef.current, - bounds, - { width: window.innerWidth, height: window.innerHeight }, - ); + const nextOffset = clampHudOffsetToViewport(recordingHudOffsetRef.current, bounds, { + width: window.innerWidth, + height: window.innerHeight, + }); if ( nextOffset.x === recordingHudOffsetRef.current.x && nextOffset.y === recordingHudOffsetRef.current.y @@ -90,32 +82,35 @@ export function useHudBarDrag({ }; }, [hudBarRef, keepHudBarInsideViewport]); - const handleHudBarPointerDown = useCallback((event: PointerEvent) => { - if (event.button !== 0) { - return; - } + const handleHudBarPointerDown = useCallback( + (event: PointerEvent) => { + if (event.button !== 0) { + return; + } - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - isHudDraggingRef.current = true; - setIsHudDragging(true); - window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); - if (!hudBarRef.current) { - return; - } - const hudRect = hudBarRef.current.getBoundingClientRect(); - hudDragStartRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: recordingHudOffsetRef.current.x, - originY: recordingHudOffsetRef.current.y, - initialLeft: hudRect.left, - initialTop: hudRect.top, - hudWidth: hudRect.width, - hudHeight: hudRect.height, - }; - }, [hudBarRef]); + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + isHudDraggingRef.current = true; + setIsHudDragging(true); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + if (!hudBarRef.current) { + return; + } + const hudRect = hudBarRef.current.getBoundingClientRect(); + hudDragStartRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: recordingHudOffsetRef.current.x, + originY: recordingHudOffsetRef.current.y, + initialLeft: hudRect.left, + initialTop: hudRect.top, + hudWidth: hudRect.width, + hudHeight: hudRect.height, + }; + }, + [hudBarRef], + ); const handleHudBarPointerMove = useCallback((event: PointerEvent) => { const dragState = hudDragStartRef.current; @@ -162,66 +157,75 @@ export function useHudBarDrag({ }); }, []); - const handleHudBarPointerUp = useCallback((event: PointerEvent) => { - const dragState = hudDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } + const handleHudBarPointerUp = useCallback( + (event: PointerEvent) => { + const dragState = hudDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } - const pointer = hudDragPendingPointerRef.current || { clientX: event.clientX, clientY: event.clientY }; - const deltaX = pointer.clientX - dragState.startX; - const deltaY = pointer.clientY - dragState.startY; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - const clampedLeft = Math.min( - Math.max(0, dragState.initialLeft + deltaX), - Math.max(0, viewportWidth - dragState.hudWidth), - ); - const clampedTop = Math.min( - Math.max(0, dragState.initialTop + deltaY), - Math.max(0, viewportHeight - dragState.hudHeight), - ); - - recordingHudOffsetRef.current = { - x: dragState.originX + (clampedLeft - dragState.initialLeft), - y: dragState.originY + (clampedTop - dragState.initialTop), - }; + const pointer = hudDragPendingPointerRef.current || { + clientX: event.clientX, + clientY: event.clientY, + }; + const deltaX = pointer.clientX - dragState.startX; + const deltaY = pointer.clientY - dragState.startY; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; - if (hudDragMoveRafRef.current !== null) { - cancelAnimationFrame(hudDragMoveRafRef.current); - hudDragMoveRafRef.current = null; - } - hudDragPendingPointerRef.current = null; - - hudDragStartRef.current = null; - const wasDragging = isHudDraggingRef.current; - isHudDraggingRef.current = false; - setRecordingHudOffset({ ...recordingHudOffsetRef.current }); - setIsHudDragging(false); - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - const hudBounds = mergeHudInteractiveBounds( - [ - hudContentRef.current?.getBoundingClientRect(), - hudBarRef.current?.getBoundingClientRect(), - recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(), - ].map((bounds) => - bounds - ? { - left: bounds.left, - top: bounds.top, - right: bounds.right, - bottom: bounds.bottom, - } - : null, - ), - ); - if (wasDragging && shouldRestoreHudMousePassthroughAfterDrag(hudBounds, event.clientX, event.clientY)) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }, [hudBarRef, hudContentRef, recordingWebcamPreviewContainerRef]); + const clampedLeft = Math.min( + Math.max(0, dragState.initialLeft + deltaX), + Math.max(0, viewportWidth - dragState.hudWidth), + ); + const clampedTop = Math.min( + Math.max(0, dragState.initialTop + deltaY), + Math.max(0, viewportHeight - dragState.hudHeight), + ); + + recordingHudOffsetRef.current = { + x: dragState.originX + (clampedLeft - dragState.initialLeft), + y: dragState.originY + (clampedTop - dragState.initialTop), + }; + + if (hudDragMoveRafRef.current !== null) { + cancelAnimationFrame(hudDragMoveRafRef.current); + hudDragMoveRafRef.current = null; + } + hudDragPendingPointerRef.current = null; + + hudDragStartRef.current = null; + const wasDragging = isHudDraggingRef.current; + isHudDraggingRef.current = false; + setRecordingHudOffset({ ...recordingHudOffsetRef.current }); + setIsHudDragging(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + const hudBounds = mergeHudInteractiveBounds( + [ + hudContentRef.current?.getBoundingClientRect(), + hudBarRef.current?.getBoundingClientRect(), + recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(), + ].map((bounds) => + bounds + ? { + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + } + : null, + ), + ); + if ( + wasDragging && + shouldRestoreHudMousePassthroughAfterDrag(hudBounds, event.clientX, event.clientY) + ) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, + [hudBarRef, hudContentRef, recordingWebcamPreviewContainerRef], + ); useEffect(() => { return () => { diff --git a/src/components/launch/hooks/useWebcamPreviewOverlay.ts b/src/components/launch/hooks/useWebcamPreviewOverlay.ts index 7c93899a0..50d5c60e1 100644 --- a/src/components/launch/hooks/useWebcamPreviewOverlay.ts +++ b/src/components/launch/hooks/useWebcamPreviewOverlay.ts @@ -61,32 +61,29 @@ export function useWebcamPreviewOverlay({ } }, [webcamEnabled]); - const handleWebcamPreviewPointerDown = useCallback( - (event: PointerEvent) => { - if (event.button !== 0) { - return; - } + const handleWebcamPreviewPointerDown = useCallback((event: PointerEvent) => { + if (event.button !== 0) { + return; + } - const previewRect = event.currentTarget.getBoundingClientRect(); + const previewRect = event.currentTarget.getBoundingClientRect(); - event.preventDefault(); - window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); - webcamPreviewDragStartRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: webcamPreviewOffsetRef.current.x, - originY: webcamPreviewOffsetRef.current.y, - initialLeft: previewRect.left, - initialTop: previewRect.top, - previewWidth: previewRect.width, - previewHeight: previewRect.height, - dragging: false, - }; - event.currentTarget.setPointerCapture(event.pointerId); - }, - [], - ); + event.preventDefault(); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + webcamPreviewDragStartRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: webcamPreviewOffsetRef.current.x, + originY: webcamPreviewOffsetRef.current.y, + initialLeft: previewRect.left, + initialTop: previewRect.top, + previewWidth: previewRect.width, + previewHeight: previewRect.height, + dragging: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, []); const handleWebcamPreviewPointerMove = useCallback((event: PointerEvent) => { const dragState = webcamPreviewDragStartRef.current; @@ -225,12 +222,12 @@ export function useWebcamPreviewOverlay({ width: { ideal: 320 }, height: { ideal: 320 }, frameRate: { ideal: 24, max: 30 }, - } + } : { width: { ideal: 320 }, height: { ideal: 320 }, frameRate: { ideal: 24, max: 30 }, - }, + }, audio: false, }); diff --git a/src/components/launch/popovers/LaunchPopoverCoordinator.tsx b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx index 55aa5f47c..7087f78c3 100644 --- a/src/components/launch/popovers/LaunchPopoverCoordinator.tsx +++ b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx @@ -1,4 +1,12 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; interface LaunchPopoverCoordinatorValue { openId: string | null; @@ -48,7 +56,9 @@ export function LaunchPopoverCoordinatorProvider({ children }: { children: React export function useLaunchPopoverCoordinator() { const context = useContext(LaunchPopoverCoordinatorContext); if (!context) { - throw new Error("useLaunchPopoverCoordinator must be used within LaunchPopoverCoordinatorProvider"); + throw new Error( + "useLaunchPopoverCoordinator must be used within LaunchPopoverCoordinatorProvider", + ); } return context; } diff --git a/src/components/launch/popovers/MicPopover.tsx b/src/components/launch/popovers/MicPopover.tsx index 02247d662..cc5eb0f0f 100644 --- a/src/components/launch/popovers/MicPopover.tsx +++ b/src/components/launch/popovers/MicPopover.tsx @@ -53,7 +53,9 @@ export function MicPopover({ >
{t("recording.microphone")}
: } + icon={ + systemAudioEnabled ? : + } selected={systemAudioEnabled} onClick={onToggleSystemAudio} > @@ -73,7 +75,9 @@ export function MicPopover({ )} {!microphoneEnabled && ( -
{t("recording.selectMicToEnable")}
+
+ {t("recording.selectMicToEnable")} +
)} {devices.map((device) => ( onSelectDevice(device.deviceId)} /> ))} {devices.length === 0 && ( -
{t("recording.noMicrophonesFound")}
+
+ {t("recording.noMicrophonesFound")} +
)} ); diff --git a/src/components/launch/popovers/PopoverScaffold.tsx b/src/components/launch/popovers/PopoverScaffold.tsx index be349192b..3be3dfccc 100644 --- a/src/components/launch/popovers/PopoverScaffold.tsx +++ b/src/components/launch/popovers/PopoverScaffold.tsx @@ -54,7 +54,9 @@ export function MicDeviceRow({ className={`${styles.ddItem} ${selected ? styles.ddItemSelected : ""}`} onClick={onSelect} > - {selected ? : } + + {selected ? : } + {device.label} diff --git a/src/components/launch/popovers/WebcamPopover.tsx b/src/components/launch/popovers/WebcamPopover.tsx index 945ffac61..0c04ed89c 100644 --- a/src/components/launch/popovers/WebcamPopover.tsx +++ b/src/components/launch/popovers/WebcamPopover.tsx @@ -66,15 +66,20 @@ export function WebcamPopover({
{webcamEnabled && ( <> - } onClick={() => { - onDisableWebcam(); - requestClose(POPOVER_ID); - }}> + } + onClick={() => { + onDisableWebcam(); + requestClose(POPOVER_ID); + }} + > {t("recording.turnOffWebcam")} {canToggleFloatingPreview ? ( : } + icon={ + showFloatingWebcamPreview ? : + } selected={showFloatingWebcamPreview} onClick={onToggleFloatingPreview} > @@ -86,7 +91,9 @@ export function WebcamPopover({ )} {!webcamEnabled && ( -
{t("recording.selectWebcamToEnable")}
+
+ {t("recording.selectWebcamToEnable")} +
)} {showWebcamControls && (
@@ -106,7 +113,8 @@ export function WebcamPopover({ key={device.deviceId} icon={ webcamEnabled && - (webcamDeviceId === device.deviceId || selectedVideoDeviceId === device.deviceId) ? ( + (webcamDeviceId === device.deviceId || + selectedVideoDeviceId === device.deviceId) ? (