diff --git a/.github/workflows/antislop.yml b/.github/workflows/antislop.yml deleted file mode 100644 index 1fea1f6cc..000000000 --- a/.github/workflows/antislop.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: PR Quality - -on: - pull_request_target: - types: [opened, reopened, edited, synchronize] - -permissions: - contents: read - issues: read - pull-requests: write - -jobs: - anti-slop: - runs-on: ubuntu-latest - steps: - - uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 - with: - max-failures: 4 - - require-description: true - max-description-length: 2000 - max-emoji-count: 2 - max-code-references: 5 - - max-commit-message-length: 300 - require-commit-author-match: true - - min-account-age: 30 - min-profile-completeness: 4 - min-global-merge-ratio: 30 - - max-daily-forks: 7 - detect-spam-usernames: true - - blocked-source-branches: | - main - master - - blocked-terms: | - ignore previous instructions - you are a helpful AI - as an AI language model - - exempt-draft-prs: true - exempt-bots: | - dependabot[bot] - renovate[bot] - github-actions[bot] - exempt-author-association: OWNER,MEMBER,COLLABORATOR - - success-add-pr-labels: checked - failure-add-pr-labels: slop - - failure-pr-message: | - ⚠️ This pull request has been flagged by **Anti-Slop**. - Our automated checks detected patterns commonly associated with - low-quality or automated/AI submissions (failure count reached). - No automatic closure — a maintainer will review it. - If this is legitimate work, please add more context, link issues, or ping us. - - close-pr: false 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/extensions/errorUtils.test.ts b/electron/extensions/errorUtils.test.ts deleted file mode 100644 index 5b834b3b9..000000000 --- a/electron/extensions/errorUtils.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { formatMarketplaceHttpError } from "./errorUtils"; - -describe("formatMarketplaceHttpError", () => { - it("hides upstream HTML when the marketplace is unavailable", () => { - const html = "SSL handshake failed"; - - const message = formatMarketplaceHttpError({ - status: 525, - contentType: "text/html; charset=UTF-8", - body: html, - }); - - expect(message).toBe( - "Marketplace is temporarily unavailable (HTTP 525). Please try again later.", - ); - expect(message).not.toContain(html); - }); - - it("keeps a short JSON error for client-side request failures", () => { - expect( - formatMarketplaceHttpError({ - status: 400, - contentType: "application/json", - body: JSON.stringify({ error: "Invalid search query" }), - }), - ).toBe("Marketplace request failed (HTTP 400): Invalid search query"); - }); - - it("uses a JSON message when an error field is absent", () => { - expect( - formatMarketplaceHttpError({ - status: 409, - contentType: "application/json", - body: JSON.stringify({ message: "Extension version already exists" }), - }), - ).toBe("Marketplace request failed (HTTP 409): Extension version already exists"); - }); - - it("prefers a string error when both JSON detail fields are present", () => { - expect( - formatMarketplaceHttpError({ - status: 400, - contentType: "application/json", - body: JSON.stringify({ error: "Primary detail", message: "Secondary detail" }), - }), - ).toBe("Marketplace request failed (HTTP 400): Primary detail"); - }); - - it("hides malformed JSON bodies", () => { - const body = '{"error":"internal route details"'; - const message = formatMarketplaceHttpError({ - status: 400, - contentType: "application/json", - body, - }); - - expect(message).toBe("Marketplace request failed (HTTP 400)."); - expect(message).not.toContain(body); - }); - - it("bounds long JSON details and marks truncation without splitting Unicode", () => { - const detail = `🚀${"x".repeat(200)}`; - const message = formatMarketplaceHttpError({ - status: 400, - contentType: "application/problem+json", - body: JSON.stringify({ error: detail }), - }); - - expect(message).toBe(`Marketplace request failed (HTTP 400): 🚀${"x".repeat(198)}…`); - expect(Array.from(message.split(": ")[1])).toHaveLength(200); - }); - - it("does not expose non-JSON response bodies", () => { - expect( - formatMarketplaceHttpError({ - status: 404, - contentType: "text/plain", - body: "internal route details", - }), - ).toBe("Marketplace request failed (HTTP 404)."); - }); -}); diff --git a/electron/extensions/errorUtils.ts b/electron/extensions/errorUtils.ts deleted file mode 100644 index 7d44675c3..000000000 --- a/electron/extensions/errorUtils.ts +++ /dev/null @@ -1,43 +0,0 @@ -export function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -const MAX_MARKETPLACE_ERROR_DETAIL_LENGTH = 200; - -export function formatMarketplaceHttpError({ - status, - contentType, - body, -}: { - status: number; - contentType: string | null; - body: string; -}): string { - if (status >= 500) { - return `Marketplace is temporarily unavailable (HTTP ${status}). Please try again later.`; - } - - let detail: string | null = null; - if (contentType?.toLowerCase().includes("json")) { - try { - const payload: unknown = JSON.parse(body); - if (payload && typeof payload === "object") { - const { error, message } = payload as { error?: unknown; message?: unknown }; - const value = typeof error === "string" ? error : message; - if (typeof value === "string" && value.trim()) { - const normalized = value.trim().replace(/\s+/g, " "); - const codePoints = Array.from(normalized); - detail = - codePoints.length > MAX_MARKETPLACE_ERROR_DETAIL_LENGTH - ? `${codePoints.slice(0, MAX_MARKETPLACE_ERROR_DETAIL_LENGTH - 1).join("")}…` - : normalized; - } - } - } catch { - // Malformed or non-API responses are intentionally not exposed to the renderer. - } - } - - const summary = `Marketplace request failed (HTTP ${status})`; - return detail ? `${summary}: ${detail}` : `${summary}.`; -} diff --git a/electron/extensions/extensionIpc.ts b/electron/extensions/extensionIpc.ts deleted file mode 100644 index d1a32b673..000000000 --- a/electron/extensions/extensionIpc.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Extension IPC Handlers — Main Process - * - * Registers IPC handlers for extension management (discover, install, - * uninstall, enable/disable) and exposes them to the renderer via preload. - */ - -import { BrowserWindow, dialog, ipcMain, shell } from "electron"; -import { - discoverExtensions, - getExtension, - getExtensionsDirectory, - getRegisteredExtensions, - installExtensionFromPath, - setExtensionStatus, - uninstallExtension, -} from "./extensionLoader"; -import { - downloadAndInstallExtension, - fetchPendingReviews, - getMarketplaceExtension, - searchMarketplace, - submitExtensionForReview, - updateReviewStatus, -} from "./extensionMarketplace"; -import { getErrorMessage } from "./errorUtils"; -import type { ExtensionInfo, MarketplaceReviewStatus } from "./extensionTypes"; - -/** - * Serialize extension info for IPC transfer (strip non-serializable fields). - */ -function serializeExtensionInfo(info: ExtensionInfo) { - return { - manifest: info.manifest, - status: info.status, - path: info.path, - error: info.error, - builtin: info.builtin ?? false, - }; -} - -/** - * Register all extension-related IPC handlers. - * Call this once during app initialization (in main.ts). - */ -export function registerExtensionIpcHandlers(): void { - // Discover all extensions (builtin + user-installed) - ipcMain.handle("extensions:discover", async () => { - const extensions = await discoverExtensions(); - return extensions.map(serializeExtensionInfo); - }); - - // List currently registered extensions - ipcMain.handle("extensions:list", () => { - return getRegisteredExtensions().map(serializeExtensionInfo); - }); - - // Get a specific extension by ID - ipcMain.handle("extensions:get", (_event, id: string) => { - const ext = getExtension(id); - return ext ? serializeExtensionInfo(ext) : null; - }); - - // Enable an extension - ipcMain.handle("extensions:enable", async (_event, id: string) => { - return setExtensionStatus(id, "active"); - }); - - // Disable an extension - ipcMain.handle("extensions:disable", async (_event, id: string) => { - return setExtensionStatus(id, "disabled"); - }); - - // Install an extension from a folder picker - ipcMain.handle("extensions:install-from-folder", async (event) => { - const window = BrowserWindow.fromWebContents(event.sender); - const result = await dialog.showOpenDialog(window!, { - title: "Select Extension Folder", - properties: ["openDirectory"], - message: "Select a folder containing a recordly-extension.json manifest", - }); - - if (result.canceled || result.filePaths.length === 0) { - return { success: false, reason: "cancelled" }; - } - - const info = await installExtensionFromPath(result.filePaths[0]); - if (!info) { - return { - success: false, - reason: "Invalid extension: missing or invalid recordly-extension.json", - }; - } - - return { success: true, extension: serializeExtensionInfo(info) }; - }); - - // Uninstall an extension - ipcMain.handle("extensions:uninstall", async (_event, id: string) => { - const success = await uninstallExtension(id); - return { success }; - }); - - // Get extensions directory path - ipcMain.handle("extensions:get-directory", () => { - return getExtensionsDirectory(); - }); - - // Open extensions directory in file manager - ipcMain.handle("extensions:open-directory", async () => { - const dir = getExtensionsDirectory(); - await shell.openPath(dir); - return { success: true }; - }); - - // ── Marketplace ───────────────────────────────────────────────────── - - // Search/browse marketplace - ipcMain.handle( - "extensions:marketplace-search", - async ( - _event, - params: { - query?: string; - tags?: string[]; - sort?: "popular" | "recent" | "rating"; - page?: number; - pageSize?: number; - }, - ) => { - try { - return await searchMarketplace(params); - } catch (error: unknown) { - return { - extensions: [], - total: 0, - page: 1, - pageSize: 20, - error: getErrorMessage(error), - }; - } - }, - ); - - // Get a specific marketplace extension - ipcMain.handle("extensions:marketplace-get", async (_event, id: string) => { - return getMarketplaceExtension(id); - }); - - // Download and install a marketplace extension - ipcMain.handle( - "extensions:marketplace-install", - async (_event, extensionId: string, downloadUrl: string) => { - return downloadAndInstallExtension(extensionId, downloadUrl); - }, - ); - - // Submit an extension for marketplace review - ipcMain.handle("extensions:marketplace-submit", async (_event, extensionId: string) => { - return submitExtensionForReview(extensionId); - }); - - // ── Admin Review System ───────────────────────────────────────────── - - // Fetch pending reviews (admin only) - ipcMain.handle( - "extensions:reviews-list", - async ( - _event, - params: { - status?: MarketplaceReviewStatus; - page?: number; - pageSize?: number; - }, - ) => { - try { - return await fetchPendingReviews(params); - } catch (error: unknown) { - return { reviews: [], total: 0, error: getErrorMessage(error) }; - } - }, - ); - - // Update review status (admin only) - ipcMain.handle( - "extensions:review-update", - async (_event, reviewId: string, status: MarketplaceReviewStatus, notes?: string) => { - return updateReviewStatus(reviewId, status, notes); - }, - ); -} diff --git a/electron/extensions/extensionLoader.ts b/electron/extensions/extensionLoader.ts deleted file mode 100644 index f3e8d64eb..000000000 --- a/electron/extensions/extensionLoader.ts +++ /dev/null @@ -1,387 +0,0 @@ -/** - * Extension Loader — Main Process - * - * Discovers, validates, and manages extensions installed in the - * extensions directory (~/.recordly/extensions/ or userData/extensions/). - * - * Extensions are loaded from disk by reading their manifest files. - * The actual extension code runs in the renderer process and uses the - * permission-gated host API exposed by the renderer. - */ - -import { existsSync } from "node:fs"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { app } from "electron"; -import type { ExtensionInfo, ExtensionManifest, ExtensionStatus } from "./extensionTypes"; - -const EXTENSIONS_DIR_NAME = "extensions"; -const MANIFEST_FILE_NAME = "recordly-extension.json"; -const BUILTIN_EXTENSIONS_DIR = "builtin-extensions"; -const EXTENSION_STATE_FILE_NAME = "extension-state.json"; - -/** In-memory registry of loaded extensions */ -const extensionRegistry = new Map(); - -type PersistedExtensionStatus = Extract; - -/** - * Returns the directory where user-installed extensions live. - */ -export function getExtensionsDirectory(): string { - return path.join(app.getPath("userData"), EXTENSIONS_DIR_NAME); -} - -/** - * Returns the built-in extensions directory (shipped with the app). - */ -function getBuiltinExtensionsDirectory(): string { - if (app.isPackaged) { - return path.join(process.resourcesPath, BUILTIN_EXTENSIONS_DIR); - } - return path.join(app.getAppPath(), "public", BUILTIN_EXTENSIONS_DIR); -} - -function getExtensionStateFilePath(): string { - return path.join(app.getPath("userData"), EXTENSION_STATE_FILE_NAME); -} - -function isPersistedExtensionStatus(value: unknown): value is PersistedExtensionStatus { - return value === "active" || value === "disabled" || value === "installed"; -} - -async function readPersistedExtensionStatuses(): Promise> { - const stateFile = getExtensionStateFilePath(); - if (!existsSync(stateFile)) { - return {}; - } - - try { - const raw = await fs.readFile(stateFile, "utf-8"); - const parsed = JSON.parse(raw); - - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return {}; - } - - const entries = Object.entries(parsed).filter( - ([key, value]) => typeof key === "string" && isPersistedExtensionStatus(value), - ); - - return Object.fromEntries(entries) as Record; - } catch { - return {}; - } -} - -async function writePersistedExtensionStatuses( - statuses: Record, -): Promise { - const stateFile = getExtensionStateFilePath(); - await fs.mkdir(path.dirname(stateFile), { recursive: true }); - await fs.writeFile(stateFile, JSON.stringify(statuses, null, 2), "utf-8"); -} - -async function updatePersistedExtensionStatus( - id: string, - status: PersistedExtensionStatus | null, -): Promise { - const statuses = await readPersistedExtensionStatuses(); - - if (status) { - statuses[id] = status; - } else { - delete statuses[id]; - } - - await writePersistedExtensionStatuses(statuses); -} - -/** - * Ensure the extensions directory exists. - */ -async function ensureExtensionsDirectory(): Promise { - const dir = getExtensionsDirectory(); - await fs.mkdir(dir, { recursive: true }); -} - -/** - * Validate an extension manifest for required fields and safe values. - */ -function validateManifest(manifest: unknown, extensionPath: string): ExtensionManifest | null { - if (!manifest || typeof manifest !== "object") { - return null; - } - - const m = manifest as Record; - - // Required fields - if (typeof m.id !== "string" || m.id.length === 0) return null; - // Only allow safe characters in extension IDs - if (!/^[a-z0-9][a-z0-9._-]*$/i.test(m.id)) return null; - if (typeof m.name !== "string" || m.name.length === 0) return null; - if (typeof m.version !== "string") return null; - if (typeof m.main !== "string" || m.main.length === 0) return null; - - // Validate main entry doesn't escape extension directory - const resolvedMain = path.resolve(extensionPath, m.main); - const relativeMain = path.relative(extensionPath, resolvedMain); - if (relativeMain.startsWith("..") || path.isAbsolute(relativeMain)) { - console.warn(`[extensions] Extension ${m.id}: main entry escapes extension directory`); - return null; - } - - // Validate permissions array - const validPermissions = new Set([ - "render", - "cursor", - "audio", - "timeline", - "ui", - "assets", - "export", - ]); - const permissions = Array.isArray(m.permissions) ? m.permissions : []; - const safePermissions = permissions.filter( - (p): p is string => typeof p === "string" && validPermissions.has(p), - ); - - return { - id: m.id as string, - name: m.name as string, - version: m.version as string, - description: typeof m.description === "string" ? m.description : "", - author: typeof m.author === "string" ? m.author : undefined, - homepage: typeof m.homepage === "string" ? m.homepage : undefined, - license: typeof m.license === "string" ? m.license : undefined, - engine: typeof m.engine === "string" ? m.engine : undefined, - icon: typeof m.icon === "string" ? m.icon : undefined, - main: m.main as string, - permissions: safePermissions as ExtensionManifest["permissions"], - contributes: - typeof m.contributes === "object" && m.contributes !== null - ? (m.contributes as ExtensionManifest["contributes"]) - : undefined, - }; -} - -/** - * Scan a directory for extensions (each subdirectory with a manifest). - */ -async function scanExtensionsIn(directory: string, builtin: boolean): Promise { - const results: ExtensionInfo[] = []; - - if (!existsSync(directory)) { - return results; - } - - let entries: string[]; - try { - entries = await fs.readdir(directory); - } catch { - return results; - } - - for (const entry of entries) { - const extDir = path.join(directory, entry); - - // Skip non-directories (lstat — don't follow symlinks) - let stat; - try { - stat = await fs.lstat(extDir); - } catch { - continue; - } - if (!stat.isDirectory()) continue; - - const manifestPath = path.join(extDir, MANIFEST_FILE_NAME); - if (!existsSync(manifestPath)) continue; - - try { - const raw = await fs.readFile(manifestPath, "utf-8"); - const parsed = JSON.parse(raw); - const manifest = validateManifest(parsed, extDir); - - if (!manifest) { - results.push({ - manifest: { - id: entry, - name: entry, - version: "0.0.0", - main: "", - permissions: [], - description: "Invalid manifest", - }, - status: "error", - path: extDir, - error: "Invalid or incomplete manifest", - builtin, - }); - continue; - } - - // Check that the entry file exists - const entryPath = path.join(extDir, manifest.main); - if (!existsSync(entryPath)) { - results.push({ - manifest, - status: "error", - path: extDir, - error: `Entry file not found: ${manifest.main}`, - builtin, - }); - continue; - } - - results.push({ - manifest, - status: "installed", - path: extDir, - builtin, - }); - } catch (err) { - results.push({ - manifest: { - id: entry, - name: entry, - version: "0.0.0", - main: "", - permissions: [], - description: "Failed to load", - }, - status: "error", - path: extDir, - error: String(err), - builtin, - }); - } - } - - return results; -} - -/** - * Discover and register all available extensions (builtin + user-installed). - */ -export async function discoverExtensions(): Promise { - await ensureExtensionsDirectory(); - - const builtinDir = getBuiltinExtensionsDirectory(); - const userDir = getExtensionsDirectory(); - - const [builtinExts, userExts] = await Promise.all([ - scanExtensionsIn(builtinDir, true), - scanExtensionsIn(userDir, false), - ]); - - const persistedStatuses = await readPersistedExtensionStatuses(); - const applyPersistedStatus = (ext: ExtensionInfo): ExtensionInfo => { - if (ext.status === "error") { - return ext; - } - - return { - ...ext, - status: persistedStatuses[ext.manifest.id] ?? (ext.builtin ? "active" : "installed"), - }; - }; - - const normalizedBuiltinExts = builtinExts.map(applyPersistedStatus); - const normalizedUserExts = userExts.map(applyPersistedStatus); - - // User extensions override builtin ones with the same ID - extensionRegistry.clear(); - for (const ext of normalizedBuiltinExts) { - extensionRegistry.set(ext.manifest.id, ext); - } - for (const ext of normalizedUserExts) { - extensionRegistry.set(ext.manifest.id, ext); - } - - return Array.from(extensionRegistry.values()); -} - -/** - * Get all registered extensions. - */ -export function getRegisteredExtensions(): ExtensionInfo[] { - return Array.from(extensionRegistry.values()); -} - -/** - * Get a specific extension by ID. - */ -export function getExtension(id: string): ExtensionInfo | undefined { - return extensionRegistry.get(id); -} - -/** - * Enable or disable an extension. - */ -export async function setExtensionStatus(id: string, status: ExtensionStatus): Promise { - const ext = extensionRegistry.get(id); - if (!ext) return false; - ext.status = status; - - if (status === "active" || status === "disabled" || status === "installed") { - await updatePersistedExtensionStatus(id, status); - } - - return true; -} - -/** - * Install an extension from a directory (copy to extensions dir). - */ -export async function installExtensionFromPath(sourcePath: string): Promise { - const manifestPath = path.join(sourcePath, MANIFEST_FILE_NAME); - if (!existsSync(manifestPath)) { - return null; - } - - let manifest: ExtensionManifest | null; - try { - const raw = await fs.readFile(manifestPath, "utf-8"); - manifest = validateManifest(JSON.parse(raw), sourcePath); - } catch { - return null; - } - - if (!manifest) return null; - - const targetDir = path.join(getExtensionsDirectory(), manifest.id); - - // Remove existing version if present - if (existsSync(targetDir)) { - await fs.rm(targetDir, { recursive: true, force: true }); - } - - await fs.cp(sourcePath, targetDir, { recursive: true }); - - const info: ExtensionInfo = { - manifest, - status: "installed", - path: targetDir, - }; - - extensionRegistry.set(manifest.id, info); - await updatePersistedExtensionStatus(manifest.id, "installed"); - return info; -} - -/** - * Uninstall a user extension (cannot uninstall builtin). - */ -export async function uninstallExtension(id: string): Promise { - const ext = extensionRegistry.get(id); - if (!ext || ext.builtin) return false; - - try { - await fs.rm(ext.path, { recursive: true, force: true }); - extensionRegistry.delete(id); - await updatePersistedExtensionStatus(id, null); - return true; - } catch { - return false; - } -} diff --git a/electron/extensions/extensionMarketplace.ts b/electron/extensions/extensionMarketplace.ts deleted file mode 100644 index b7a38e46b..000000000 --- a/electron/extensions/extensionMarketplace.ts +++ /dev/null @@ -1,362 +0,0 @@ -/** - * Extension Marketplace — Main Process - * - * Handles fetching, downloading, and installing extensions from the - * Recordly marketplace API. Also provides admin review endpoints. - */ - -import { createWriteStream, existsSync } from "node:fs"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { Readable } from "node:stream"; -import { pipeline } from "node:stream/promises"; -import type { ReadableStream as NodeReadableStream } from "node:stream/web"; -import { app } from "electron"; -import { formatMarketplaceHttpError, getErrorMessage } from "./errorUtils"; -import { getRegisteredExtensions, installExtensionFromPath } from "./extensionLoader"; -import type { - ExtensionReview, - MarketplaceExtension, - MarketplaceReviewStatus, - MarketplaceSearchResult, -} from "./extensionTypes"; - -// --------------------------------------------------------------------------- -// Configuration -// --------------------------------------------------------------------------- - -const MARKETPLACE_API_BASE = "https://marketplace.recordly.dev/extensions/api/v1"; -const REQUEST_TIMEOUT_MS = 15_000; - -// --------------------------------------------------------------------------- -// Zip-slip protection: recursively verify all extracted files stay within the -// expected directory. Rejects symlinks that point outside and any entry whose -// real path escapes the root. -// --------------------------------------------------------------------------- - -async function assertNoEscapedFiles(dir: string, root: string): Promise { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); - const real = await fs.realpath(entryPath); - if (!real.startsWith(root + path.sep) && real !== root) { - // Nuke the escaped file/symlink and throw - await fs.rm(entryPath, { recursive: true, force: true }).catch(() => undefined); - throw new Error( - `Zip-slip detected: ${entry.name} resolves outside extraction directory`, - ); - } - if (entry.isDirectory()) { - await assertNoEscapedFiles(entryPath, root); - } - } -} - -function getMarketplaceUrl(): string { - // Allow explicit override for local marketplace development. - if (process.env.RECORDLY_MARKETPLACE_URL) return process.env.RECORDLY_MARKETPLACE_URL; - return MARKETPLACE_API_BASE; -} - -function getAdminKey(): string | undefined { - return process.env.RECORDLY_ADMIN_KEY; -} - -// --------------------------------------------------------------------------- -// HTTP helpers -// --------------------------------------------------------------------------- - -async function marketplaceFetch( - endpoint: string, - options: { method?: string; body?: unknown; timeout?: number; admin?: boolean } = {}, -): Promise { - const url = `${getMarketplaceUrl()}${endpoint}`; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), options.timeout ?? REQUEST_TIMEOUT_MS); - - try { - const headers: Record = { - "Content-Type": "application/json", - "X-Recordly-Version": app.getVersion(), - "X-Recordly-Platform": process.platform, - }; - - // Attach admin key for privileged endpoints - if (options.admin) { - const key = getAdminKey(); - if (!key) throw new Error("Admin key not configured (set RECORDLY_ADMIN_KEY env var)"); - headers["X-Admin-Key"] = key; - } - - const response = await fetch(url, { - method: options.method ?? "GET", - headers, - body: options.body ? JSON.stringify(options.body) : undefined, - signal: controller.signal, - }); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw new Error( - formatMarketplaceHttpError({ - status: response.status, - contentType: response.headers.get("content-type"), - body: text, - }), - ); - } - - return (await response.json()) as T; - } finally { - clearTimeout(timeoutId); - } -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** - * Search/browse marketplace extensions. - */ -export async function searchMarketplace(params: { - query?: string; - tags?: string[]; - sort?: "popular" | "recent" | "rating"; - page?: number; - pageSize?: number; -}): Promise { - const searchParams = new URLSearchParams(); - if (params.query) searchParams.set("query", params.query); - if (params.tags?.length) searchParams.set("tags", params.tags.join(",")); - if (params.sort) searchParams.set("sort", params.sort); - if (params.page) searchParams.set("page", String(params.page)); - if (params.pageSize) searchParams.set("pageSize", String(params.pageSize)); - - const qs = searchParams.toString(); - const result = await marketplaceFetch( - `/extensions${qs ? `?${qs}` : ""}`, - ); - - // Mark installed extensions - const installed = getRegisteredExtensions(); - const installedIds = new Set(installed.map((e) => e.manifest.id)); - for (const ext of result.extensions) { - ext.installed = installedIds.has(ext.id); - } - - return result; -} - -/** - * Get a single marketplace extension by ID. - */ -export async function getMarketplaceExtension(id: string): Promise { - try { - const ext = await marketplaceFetch( - `/extensions/${encodeURIComponent(id)}`, - ); - const installed = getRegisteredExtensions(); - ext.installed = installed.some((e) => e.manifest.id === ext.id); - return ext; - } catch { - return null; - } -} - -/** - * Download and install a marketplace extension. - * Downloads the zip, extracts it to a temp dir, then installs from there. - */ -export async function downloadAndInstallExtension( - extensionId: string, - downloadUrl: string, -): Promise<{ success: boolean; error?: string }> { - // Validate download URL against allowed marketplace origins - const allowedOrigins = [ - "https://marketplace.recordly.dev", - "https://recordly.dev", - ...(app.isPackaged ? [] : ["http://localhost:3001"]), - ]; - try { - const url = new URL(downloadUrl); - if (!allowedOrigins.some((o) => url.origin === o)) { - return { success: false, error: `Untrusted download origin: ${url.origin}` }; - } - } catch { - return { success: false, error: "Invalid download URL" }; - } - - const tempDir = path.join(app.getPath("temp"), `recordly-ext-${extensionId}-${Date.now()}`); - const zipPath = path.join(tempDir, "extension.zip"); - - try { - // Create temp directory - await fs.mkdir(tempDir, { recursive: true }); - - // Download the archive - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 60_000); - - let response: Response; - try { - response = await fetch(downloadUrl, { - signal: controller.signal, - headers: { - "X-Recordly-Version": app.getVersion(), - }, - }); - } finally { - clearTimeout(timeoutId); - } - - if (!response.ok) { - throw new Error(`Download failed with status ${response.status}`); - } - - if (!response.body) { - throw new Error("Download response has no body"); - } - - // Write to disk - const fileStream = createWriteStream(zipPath); - await pipeline(Readable.fromWeb(response.body as NodeReadableStream), fileStream); - - // Extract the zip — use the built-in decompress or shell unzip - const extractDir = path.join(tempDir, "extracted"); - await fs.mkdir(extractDir, { recursive: true }); - - // Use Node's built-in unzip capability via child_process (execFile — no shell) - const { execFile } = await import("node:child_process"); - await new Promise((resolve, reject) => { - if (process.platform === "win32") { - // Use -LiteralPath to avoid PowerShell injection via single-quote in paths - execFile( - "powershell", - [ - "-NoProfile", - "-NonInteractive", - "-command", - "Expand-Archive", - "-LiteralPath", - zipPath, - "-DestinationPath", - extractDir, - "-Force", - ], - (error) => { - if (error) reject(error); - else resolve(); - }, - ); - } else { - execFile("unzip", ["-o", zipPath, "-d", extractDir], (error) => { - if (error) reject(error); - else resolve(); - }); - } - }); - - // Security: verify no extracted file escaped the extraction directory - // (protects against zip-slip / path traversal entries in malicious archives) - // Use fs.realpath so the root matches what fs.realpath returns for children - // (on macOS /var is a symlink to /private/var — path.resolve does not - // resolve symlinks, so root and children would mismatch). - const resolvedExtractDir = await fs.realpath(extractDir); - await assertNoEscapedFiles(resolvedExtractDir, resolvedExtractDir); - - // Find the manifest — it might be in a subfolder - const entries = await fs.readdir(extractDir, { withFileTypes: true }); - let manifestDir = extractDir; - - // If there's a single directory, look inside it for the manifest. - const dirs = entries.filter((e) => e.isDirectory()); - if (dirs.length === 1 && !existsSync(path.join(extractDir, "recordly-extension.json"))) { - manifestDir = path.join(extractDir, dirs[0].name); - } - - // Verify manifest exists - if (!existsSync(path.join(manifestDir, "recordly-extension.json"))) { - throw new Error( - "Downloaded extension does not contain a recordly-extension.json manifest", - ); - } - - // Install from the extracted directory - const info = await installExtensionFromPath(manifestDir); - if (!info) { - throw new Error("Extension validation failed after download"); - } - - // Track download count (fire-and-forget — CDN may cache the GET, so POST separately) - fetch(`${getMarketplaceUrl()}/extensions/${encodeURIComponent(extensionId)}/download`, { - method: "POST", - headers: { "X-Recordly-Version": app.getVersion() }, - }).catch(() => undefined); - - return { success: true }; - } catch (error: unknown) { - return { success: false, error: getErrorMessage(error) }; - } finally { - // Clean up temp directory - await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); - } -} - -// --------------------------------------------------------------------------- -// Review System (Admin) -// --------------------------------------------------------------------------- - -/** - * Fetch extensions pending review (admin only). - */ -export async function fetchPendingReviews(params: { - status?: MarketplaceReviewStatus; - page?: number; - pageSize?: number; -}): Promise<{ reviews: ExtensionReview[]; total: number }> { - const searchParams = new URLSearchParams(); - if (params.status) searchParams.set("status", params.status); - if (params.page) searchParams.set("page", String(params.page)); - if (params.pageSize) searchParams.set("pageSize", String(params.pageSize)); - - const qs = searchParams.toString(); - return marketplaceFetch<{ reviews: ExtensionReview[]; total: number }>( - `/admin/reviews${qs ? `?${qs}` : ""}`, - { admin: true }, - ); -} - -/** - * Update the review status of a submitted extension (admin only). - */ -export async function updateReviewStatus( - reviewId: string, - status: MarketplaceReviewStatus, - notes?: string, -): Promise<{ success: boolean }> { - return marketplaceFetch<{ success: boolean }>( - `/admin/reviews/${encodeURIComponent(reviewId)}`, - { - method: "PATCH", - body: { status, notes }, - admin: true, - }, - ); -} - -/** - * Submit an extension for marketplace review. - */ -export async function submitExtensionForReview( - extensionId: string, -): Promise<{ success: boolean; reviewId?: string; error?: string }> { - try { - return await marketplaceFetch<{ success: boolean; reviewId?: string }>( - `/extensions/${encodeURIComponent(extensionId)}/submit`, - { method: "POST" }, - ); - } catch (error: unknown) { - return { success: false, error: getErrorMessage(error) }; - } -} diff --git a/electron/extensions/extensionTypes.ts b/electron/extensions/extensionTypes.ts deleted file mode 100644 index c89e3a780..000000000 --- a/electron/extensions/extensionTypes.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Re-export of extension types for use in the main process. - * The canonical types live in src/lib/extensions/types.ts. - */ - -export type { - ExtensionContributions, - ExtensionInfo, - ExtensionManifest, - ExtensionPermission, - ExtensionReview, - ExtensionStatus, - MarketplaceExtension, - MarketplaceReviewStatus, - MarketplaceSearchResult, -} from "../../src/lib/extensions/types"; 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/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/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/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/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/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/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) ? (