diff --git a/electron/appPaths.ts b/electron/appPaths.ts
index 75f7b51bf..68d6cfc39 100644
--- a/electron/appPaths.ts
+++ b/electron/appPaths.ts
@@ -8,4 +8,4 @@ if (process.env["VITE_DEV_SERVER_URL"]) {
}
export const USER_DATA_PATH = app.getPath("userData");
-export const RECORDINGS_DIR = path.join(USER_DATA_PATH, "recordings");
\ No newline at end of file
+export const RECORDINGS_DIR = path.join(USER_DATA_PATH, "recordings");
diff --git a/electron/cursorHider.ts b/electron/cursorHider.ts
index 228aa7dc5..266d111a9 100644
--- a/electron/cursorHider.ts
+++ b/electron/cursorHider.ts
@@ -1,4 +1,4 @@
-import { spawnSync } from 'node:child_process'
+import { spawnSync } from "node:child_process";
const PY_HIDE_WIN = `
import ctypes, sys
@@ -25,7 +25,7 @@ for _ in range(32):
user32.ShowCursor(False)
sys.exit(0)
-`.trim()
+`.trim();
const PY_SHOW_WIN = `
import ctypes, sys
@@ -52,90 +52,93 @@ for _ in range(32):
user32.ShowCursor(True)
sys.exit(0)
-`.trim()
+`.trim();
function getPowerShellCommand(show: boolean) {
- const desiredFlag = show ? 1 : 0
- const showLiteral = show ? '$true' : '$false'
-
- return [
- '$signature = @"',
- 'using System;',
- 'using System.Runtime.InteropServices;',
- 'public struct POINT { public int X; public int Y; }',
- 'public struct CURSORINFO { public int cbSize; public int flags; public IntPtr hCursor; public POINT ptScreenPos; }',
- 'public static class CursorNative {',
- ' [DllImport("user32.dll")] public static extern int ShowCursor(bool show);',
- ' [DllImport("user32.dll")] public static extern bool GetCursorInfo(ref CURSORINFO info);',
- '}',
- '"@;',
- 'Add-Type -TypeDefinition $signature -Language CSharp -ErrorAction SilentlyContinue | Out-Null;',
- '$info = New-Object CURSORINFO;',
- '$info.cbSize = [Runtime.InteropServices.Marshal]::SizeOf([type]CURSORINFO);',
- 'for ($i = 0; $i -lt 32; $i++) {',
- ' if ([CursorNative]::GetCursorInfo([ref]$info) -and (($info.flags -band 1) -eq ' + desiredFlag + ')) { exit 0 }',
- ' [CursorNative]::ShowCursor(' + showLiteral + ') | Out-Null;',
- '}',
- 'exit 0',
- ].join(' ')
+ const desiredFlag = show ? 1 : 0;
+ const showLiteral = show ? "$true" : "$false";
+
+ return [
+ '$signature = @"',
+ "using System;",
+ "using System.Runtime.InteropServices;",
+ "public struct POINT { public int X; public int Y; }",
+ "public struct CURSORINFO { public int cbSize; public int flags; public IntPtr hCursor; public POINT ptScreenPos; }",
+ "public static class CursorNative {",
+ ' [DllImport("user32.dll")] public static extern int ShowCursor(bool show);',
+ ' [DllImport("user32.dll")] public static extern bool GetCursorInfo(ref CURSORINFO info);',
+ "}",
+ '"@;',
+ "Add-Type -TypeDefinition $signature -Language CSharp -ErrorAction SilentlyContinue | Out-Null;",
+ "$info = New-Object CURSORINFO;",
+ "$info.cbSize = [Runtime.InteropServices.Marshal]::SizeOf([type]CURSORINFO);",
+ "for ($i = 0; $i -lt 32; $i++) {",
+ " if ([CursorNative]::GetCursorInfo([ref]$info) -and (($info.flags -band 1) -eq " +
+ desiredFlag +
+ ")) { exit 0 }",
+ " [CursorNative]::ShowCursor(" + showLiteral + ") | Out-Null;",
+ "}",
+ "exit 0",
+ ].join(" ");
}
function runPythonSnippet(code: string) {
- for (const executable of ['python', 'python3', 'py']) {
- const result = spawnSync(executable, ['-c', code], { timeout: 5000 })
- if (!result.error && result.status === 0) {
- return true
- }
- }
-
- return false
+ for (const executable of ["python", "python3", "py"]) {
+ const result = spawnSync(executable, ["-c", code], { timeout: 5000 });
+ if (!result.error && result.status === 0) {
+ return true;
+ }
+ }
+
+ return false;
}
function runPowerShellSnippet(command: string) {
- const result = spawnSync(
- 'powershell.exe',
- ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', command],
- { timeout: 8000 },
- )
+ const result = spawnSync(
+ "powershell.exe",
+ ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", command],
+ { timeout: 8000 },
+ );
- return !result.error && result.status === 0
+ return !result.error && result.status === 0;
}
-let cursorHidden = false
+let cursorHidden = false;
export function hideCursor() {
- if (process.platform !== 'win32' || cursorHidden) {
- return false
- }
-
- try {
- const didHide = runPythonSnippet(PY_HIDE_WIN)
- || runPowerShellSnippet(getPowerShellCommand(false))
-
- if (didHide) {
- cursorHidden = true
- }
-
- return didHide
- } catch (error) {
- console.error('[cursorHider] Failed to hide Windows cursor:', error)
- return false
- }
+ if (process.platform !== "win32" || cursorHidden) {
+ return false;
+ }
+
+ try {
+ const didHide =
+ runPythonSnippet(PY_HIDE_WIN) || runPowerShellSnippet(getPowerShellCommand(false));
+
+ if (didHide) {
+ cursorHidden = true;
+ }
+
+ return didHide;
+ } catch (error) {
+ console.error("[cursorHider] Failed to hide Windows cursor:", error);
+ return false;
+ }
}
export function showCursor() {
- if (process.platform !== 'win32' || !cursorHidden) {
- return false
- }
-
- try {
- const didShow = runPythonSnippet(PY_SHOW_WIN)
- || runPowerShellSnippet(getPowerShellCommand(true))
- return didShow
- } catch (error) {
- console.error('[cursorHider] Failed to show Windows cursor:', error)
- return false
- } finally {
- cursorHidden = false
- }
-}
\ No newline at end of file
+ if (process.platform !== "win32" || !cursorHidden) {
+ return false;
+ }
+
+ try {
+ const didShow =
+ runPythonSnippet(PY_SHOW_WIN) || runPowerShellSnippet(getPowerShellCommand(true));
+ if (didShow) {
+ cursorHidden = false;
+ }
+ return didShow;
+ } catch (error) {
+ console.error("[cursorHider] Failed to show Windows cursor:", error);
+ return false;
+ }
+}
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index c6929171f..c6abd25be 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -1,5 +1,6 @@
///
+// biome-ignore lint/style/noNamespace: NodeJS.ProcessEnv augmentation requires a namespace declaration.
declare namespace NodeJS {
interface ProcessEnv {
/**
@@ -60,6 +61,14 @@ 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;
+
interface Window {
electronAPI: {
hudOverlaySetIgnoreMouse: (ignore: boolean) => void;
@@ -73,15 +82,18 @@ interface Window {
setHudOverlayCaptureProtection: (
enabled: boolean,
) => Promise<{ success: boolean; enabled: boolean }>;
+ getAssetBasePath: () => Promise;
getSources: (opts: Electron.SourcesOptions) => Promise;
switchToEditor: () => Promise;
openSourceSelector: () => Promise;
- selectSource: (source: any) => Promise;
- showSourceHighlight: (source: any) => Promise<{ success: boolean }>;
- getSelectedSource: () => Promise;
- onSelectedSourceChanged: (callback: (source: any) => void) => () => void;
+ selectSource: (source: ProcessedDesktopSource) => Promise;
+ showSourceHighlight: (source: ProcessedDesktopSource) => Promise<{ success: boolean }>;
+ getSelectedSource: () => Promise;
+ onSelectedSourceChanged: (
+ callback: (source: ProcessedDesktopSource | null) => void,
+ ) => () => void;
startNativeScreenRecording: (
- source: any,
+ source: ProcessedDesktopSource,
options?: {
capturesSystemAudio?: boolean;
capturesMicrophone?: boolean;
@@ -123,7 +135,7 @@ interface Window {
error?: string;
}>;
startFfmpegRecording: (
- source: any,
+ source: ProcessedDesktopSource,
) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>;
stopFfmpegRecording: () => Promise<{
success: boolean;
@@ -145,7 +157,6 @@ interface Window {
files?: string[];
error?: string;
}>;
- getAssetBasePath: () => Promise;
readLocalFile: (
filePath: string,
) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>;
@@ -158,6 +169,7 @@ interface Window {
frameRate: number;
bitrate: number;
encodingMode: "fast" | "balanced" | "quality";
+ inputMode?: "rawvideo" | "h264-stream";
}) => Promise<{
success: boolean;
sessionId?: string;
@@ -459,14 +471,14 @@ 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;
+ 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?: any;
+ extension?: RendererExtensionInfo;
message?: string;
error?: string;
canceled?: boolean;
@@ -480,8 +492,8 @@ interface Window {
sort?: string;
page?: number;
pageSize?: number;
- }) => Promise;
- extensionsMarketplaceGet: (id: string) => Promise;
+ }) => Promise;
+ extensionsMarketplaceGet: (id: string) => Promise;
extensionsMarketplaceInstall: (
extensionId: string,
downloadUrl: string,
@@ -490,13 +502,13 @@ interface Window {
extensionId: string,
) => Promise<{ success: boolean; reviewId?: string; error?: string }>;
extensionsReviewsList: (params: {
- status?: string;
+ status?: RendererMarketplaceReviewStatus;
page?: number;
pageSize?: number;
- }) => Promise<{ reviews: any[]; total: number }>;
+ }) => Promise<{ reviews: RendererExtensionReview[]; total: number; error?: string }>;
extensionsReviewUpdate: (
reviewId: string,
- status: string,
+ status: RendererMarketplaceReviewStatus,
notes?: string,
) => Promise<{ success: boolean; error?: string }>;
};
@@ -518,6 +530,7 @@ interface CursorTelemetryPoint {
timeMs: number;
cx: number;
cy: number;
+ pressure?: number;
interactionType?:
| "move"
| "click"
diff --git a/electron/extensions/errorUtils.ts b/electron/extensions/errorUtils.ts
new file mode 100644
index 000000000..ae5ccfdac
--- /dev/null
+++ b/electron/extensions/errorUtils.ts
@@ -0,0 +1,3 @@
+export function getErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
\ No newline at end of file
diff --git a/electron/extensions/extensionIpc.ts b/electron/extensions/extensionIpc.ts
index 7d6a6b454..d1a32b673 100644
--- a/electron/extensions/extensionIpc.ts
+++ b/electron/extensions/extensionIpc.ts
@@ -23,6 +23,7 @@ import {
submitExtensionForReview,
updateReviewStatus,
} from "./extensionMarketplace";
+import { getErrorMessage } from "./errorUtils";
import type { ExtensionInfo, MarketplaceReviewStatus } from "./extensionTypes";
/**
@@ -129,13 +130,13 @@ export function registerExtensionIpcHandlers(): void {
) => {
try {
return await searchMarketplace(params);
- } catch (err: unknown) {
+ } catch (error: unknown) {
return {
extensions: [],
total: 0,
page: 1,
pageSize: 20,
- error: err instanceof Error ? err.message : String(err),
+ error: getErrorMessage(error),
};
}
},
@@ -174,12 +175,8 @@ export function registerExtensionIpcHandlers(): void {
) => {
try {
return await fetchPendingReviews(params);
- } catch (err: unknown) {
- return {
- reviews: [],
- total: 0,
- error: err instanceof Error ? err.message : String(err),
- };
+ } catch (error: unknown) {
+ return { reviews: [], total: 0, error: getErrorMessage(error) };
}
},
);
diff --git a/electron/extensions/extensionMarketplace.ts b/electron/extensions/extensionMarketplace.ts
index 9a951bc1d..e8bc66ddf 100644
--- a/electron/extensions/extensionMarketplace.ts
+++ b/electron/extensions/extensionMarketplace.ts
@@ -5,25 +5,27 @@
* Recordly marketplace API. Also provides admin review endpoints.
*/
-import path from 'node:path';
-import fs from 'node:fs/promises';
-import { createWriteStream, existsSync } from 'node:fs';
-import { app } from 'electron';
-import { pipeline } from 'node:stream/promises';
-import { Readable } from 'node:stream';
-import { installExtensionFromPath, getRegisteredExtensions } from './extensionLoader';
+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 { getErrorMessage } from "./errorUtils";
+import { getRegisteredExtensions, installExtensionFromPath } from "./extensionLoader";
import type {
- MarketplaceExtension,
- MarketplaceSearchResult,
- MarketplaceReviewStatus,
- ExtensionReview,
-} from './extensionTypes';
+ ExtensionReview,
+ MarketplaceExtension,
+ MarketplaceReviewStatus,
+ MarketplaceSearchResult,
+} from "./extensionTypes";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
-const MARKETPLACE_API_BASE = 'https://marketplace.recordly.dev/extensions/api/v1';
+const MARKETPLACE_API_BASE = "https://marketplace.recordly.dev/extensions/api/v1";
const REQUEST_TIMEOUT_MS = 15_000;
// ---------------------------------------------------------------------------
@@ -33,29 +35,31 @@ const REQUEST_TIMEOUT_MS = 15_000;
// ---------------------------------------------------------------------------
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(() => {});
- throw new Error(`Zip-slip detected: ${entry.name} resolves outside extraction directory`);
- }
- if (entry.isDirectory()) {
- await assertNoEscapedFiles(entryPath, root);
- }
- }
+ 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;
+ // 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;
+ return process.env.RECORDLY_ADMIN_KEY;
}
// ---------------------------------------------------------------------------
@@ -63,43 +67,43 @@ function getAdminKey(): string | undefined {
// ---------------------------------------------------------------------------
async function marketplaceFetch(
- endpoint: string,
- options: { method?: string; body?: unknown; timeout?: number; admin?: boolean } = {},
+ 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(`Marketplace API error ${response.status}: ${text}`);
- }
-
- return await response.json() as T;
- } finally {
- clearTimeout(timeoutId);
- }
+ 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(`Marketplace API error ${response.status}: ${text}`);
+ }
+
+ return (await response.json()) as T;
+ } finally {
+ clearTimeout(timeoutId);
+ }
}
// ---------------------------------------------------------------------------
@@ -110,46 +114,48 @@ async function marketplaceFetch(
* Search/browse marketplace extensions.
*/
export async function searchMarketplace(params: {
- query?: string;
- tags?: string[];
- sort?: 'popular' | 'recent' | 'rating';
- page?: number;
- pageSize?: number;
+ 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;
+ 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;
- }
+ 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;
+ }
}
/**
@@ -157,130 +163,138 @@ export async function getMarketplaceExtension(id: string): Promise {
- // 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 any), 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
- 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(`${MARKETPLACE_API_BASE}/extensions/${encodeURIComponent(extensionId)}/download`, {
- method: 'POST',
- headers: { 'X-Recordly-Version': app.getVersion() },
- }).catch(() => {});
-
- return { success: true };
- } catch (err: any) {
- return { success: false, error: err.message ?? String(err) };
- } finally {
- // Clean up temp directory
- await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
- }
+ // 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);
+ }
}
// ---------------------------------------------------------------------------
@@ -291,47 +305,52 @@ export async function downloadAndInstallExtension(
* Fetch extensions pending review (admin only).
*/
export async function fetchPendingReviews(params: {
- status?: MarketplaceReviewStatus;
- page?: number;
- pageSize?: number;
+ 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 },
- );
+ 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,
+ reviewId: string,
+ status: MarketplaceReviewStatus,
+ notes?: string,
): Promise<{ success: boolean }> {
- return marketplaceFetch<{ success: boolean }>(`/admin/reviews/${encodeURIComponent(reviewId)}`, {
- method: 'PATCH',
- body: { status, notes },
- admin: true,
- });
+ 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 (err: any) {
- return { success: false, error: err.message ?? String(err) };
- }
+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
index 8f21218ef..c89e3a780 100644
--- a/electron/extensions/extensionTypes.ts
+++ b/electron/extensions/extensionTypes.ts
@@ -1,99 +1,16 @@
/**
* Re-export of extension types for use in the main process.
* The canonical types live in src/lib/extensions/types.ts.
- * This file mirrors the subset needed by the electron layer.
*/
-export type ExtensionPermission =
- | "render"
- | "cursor"
- | "audio"
- | "timeline"
- | "ui"
- | "assets"
- | "export";
-
-export interface ExtensionManifest {
- id: string;
- name: string;
- version: string;
- description: string;
- author?: string;
- homepage?: string;
- license?: string;
- engine?: string;
- icon?: string;
- main: string;
- permissions: ExtensionPermission[];
- contributes?: ExtensionContributions;
-}
-
-export interface ExtensionContributions {
- /** Informational manifest metadata only; runtime registration still happens from activate(). */
- cursorStyles?: {
- id: string;
- label: string;
- defaultImage: string;
- clickImage?: string;
- hotspot?: { x: number; y: number };
- }[];
- sounds?: { id: string; label: string; category: string; file: string; durationMs?: number }[];
- wallpapers?: { id: string; label: string; file: string; thumbnail?: string; isVideo?: boolean }[];
- webcamFrames?: { id: string; label: string; file: string; thumbnail?: string }[];
-}
-
-export type ExtensionStatus = "installed" | "active" | "disabled" | "error";
-
-export interface ExtensionInfo {
- manifest: ExtensionManifest;
- status: ExtensionStatus;
- path: string;
- error?: string;
- builtin?: boolean;
-}
-
-// ---------------------------------------------------------------------------
-// Marketplace Types
-// ---------------------------------------------------------------------------
-
-export type MarketplaceReviewStatus = "pending" | "approved" | "rejected" | "flagged";
-
-export interface MarketplaceExtension {
- id: string;
- name: string;
- version: string;
- description: string;
- author: string;
- downloadUrl: string;
- iconUrl?: string;
- screenshots?: string[];
- downloads: number;
- rating: number;
- ratingCount: number;
- tags: string[];
- permissions: ExtensionPermission[];
- reviewStatus: MarketplaceReviewStatus;
- publishedAt: string;
- updatedAt: string;
- installed?: boolean;
-}
-
-export interface MarketplaceSearchResult {
- extensions: MarketplaceExtension[];
- total: number;
- page: number;
- pageSize: number;
-}
-
-export interface ExtensionReview {
- id: string;
- extensionId: string;
- extensionName: string;
- version: string;
- author: string;
- submittedAt: string;
- status: MarketplaceReviewStatus;
- reviewNotes?: string;
- manifest: ExtensionManifest;
- downloadUrl: string;
-}
+export type {
+ ExtensionContributions,
+ ExtensionInfo,
+ ExtensionManifest,
+ ExtensionPermission,
+ ExtensionReview,
+ ExtensionStatus,
+ MarketplaceExtension,
+ MarketplaceReviewStatus,
+ MarketplaceSearchResult,
+} from "../../src/lib/extensions/types";
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index fc138e34e..9e7d56be7 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -22,6 +22,7 @@ import { RECORDINGS_DIR, USER_DATA_PATH } from "../appPaths";
import { hideCursor, showCursor } from "../cursorHider";
import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../windows";
import {
+ buildNativeH264StreamExportArgs,
buildNativeVideoExportArgs,
buildTrimmedSourceAudioFilter,
getEditedAudioExtension,
@@ -167,6 +168,17 @@ let nativeScreenRecordingActive = false;
let currentVideoPath: string | null = null;
let currentRecordingSession: RecordingSessionData | null = null;
const approvedLocalReadPaths = new Set();
+function approveUserPath(filePath: string | null | undefined) {
+ if (!filePath) {
+ return;
+ }
+
+ try {
+ approvedLocalReadPaths.add(path.resolve(filePath));
+ } catch {
+ // Ignore invalid paths; later reads will surface the underlying error.
+ }
+}
let nativeCaptureProcess: ChildProcessWithoutNullStreams | null = null;
let nativeCaptureOutputBuffer = "";
let nativeCaptureTargetPath: string | null = null;
@@ -631,6 +643,21 @@ async function loadProjectFromPath(projectPath: string) {
currentProjectPath = normalizedPath;
currentVideoPath = mediaSources.videoPath;
+ const projectObj = project as Record;
+ const editorObj = projectObj?.editor as Record | undefined;
+ const audioTracks = editorObj?.audioTracks as { sourcePath?: unknown }[] | undefined;
+ const approvedProjectPaths: Array = [
+ mediaSources.videoPath,
+ mediaSources.webcamPath,
+ ];
+ if (Array.isArray(audioTracks)) {
+ for (const track of audioTracks) {
+ if (typeof track?.sourcePath === "string") {
+ approvedProjectPaths.push(track.sourcePath);
+ }
+ }
+ }
+ await replaceApprovedSessionLocalReadPaths(approvedProjectPaths);
currentRecordingSession = {
videoPath: mediaSources.videoPath,
webcamPath: mediaSources.webcamPath,
@@ -675,12 +702,7 @@ function isPathInsideDirectory(candidatePath: string, directoryPath: string) {
}
function isAllowedLocalReadPath(candidatePath: string) {
- const allowedPrefixes = [
- RECORDINGS_DIR,
- USER_DATA_PATH,
- getAssetRootPath(),
- app.getPath("temp"),
- ];
+ const allowedPrefixes = [RECORDINGS_DIR, USER_DATA_PATH, getAssetRootPath(), app.getPath("temp")];
return (
existsSync(candidatePath) ||
@@ -1083,11 +1105,7 @@ let nativeHelperMigrationPromise: Promise | null = null;
async function migrateLegacyNativeHelperBinaries() {
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(),
],
[
@@ -1319,26 +1337,9 @@ function loadFfmpegStatic() {
return null;
}
-function resolveSystemFfmpegBinaryPath() {
- const locator = process.platform === "win32" ? "where" : "which";
- const result = spawnSync(locator, ["ffmpeg"], {
- encoding: "utf-8",
- windowsHide: true,
- });
-
- if (result.status !== 0) {
- return null;
- }
-
- const candidate = result.stdout
- .split(/\r?\n/)
- .map((line) => line.trim())
- .find((line) => line.length > 0);
-
- return candidate || null;
-}
+type HookEventName = "mousedown" | "mouseup" | "mousemove";
-type HookCursorEvent = {
+type HookMouseEvent = {
button?: number;
mouseButton?: number;
x?: number;
@@ -1355,61 +1356,107 @@ type HookCursorEvent = {
};
};
-type HookEventName = "mousedown" | "mouseup" | "mousemove";
-
-type HookCursorHandler = (event: HookCursorEvent) => void;
+type HookEventListener = (event: HookMouseEvent) => void;
type UiohookLike = {
- on?: (eventName: HookEventName, listener: HookCursorHandler) => void;
- off?: (eventName: HookEventName, listener: HookCursorHandler) => void;
- removeListener?: (eventName: HookEventName, listener: HookCursorHandler) => void;
- start?: () => void;
+ on: (eventName: HookEventName, listener: HookEventListener) => void;
+ off?: (eventName: HookEventName, listener: HookEventListener) => void;
+ removeListener?: (eventName: HookEventName, listener: HookEventListener) => void;
+ start: () => void;
stop?: () => void;
+};
+
+type UiohookModuleNamespace = {
uIOhook?: UiohookLike;
uiohook?: UiohookLike;
Uiohook?: UiohookLike;
- default?: UiohookLike;
+ default?: UiohookLike | UiohookModuleNamespace;
};
-function loadUiohookModule() {
- const moduleExports = nodeRequire("uiohook-napi") as UiohookLike;
- return (
- moduleExports.uIOhook ??
- moduleExports.uiohook ??
- moduleExports.Uiohook ??
- moduleExports.default?.uIOhook ??
- moduleExports.default?.uiohook ??
- moduleExports.default ??
- moduleExports
- );
+function isUiohookLike(value: unknown): value is UiohookLike {
+ const candidate = value as Partial | null;
+ return typeof candidate?.on === "function" && typeof candidate?.start === "function";
}
-function getFfmpegBinaryPath() {
- const ffmpegStatic = loadFfmpegStatic();
- if (ffmpegStatic && typeof ffmpegStatic === "string") {
- const bundledPath = app.isPackaged
- ? ffmpegStatic.replace(/\.asar([/\\])/, ".asar.unpacked$1")
- : ffmpegStatic;
-
- if (existsSync(bundledPath)) {
- return bundledPath;
- }
+function resolveSystemFfmpegBinaryPath() {
+ const locator = process.platform === 'win32' ? 'where' : 'which'
+ const result = spawnSync(locator, ['ffmpeg'], {
+ encoding: 'utf-8',
+ windowsHide: true,
+ })
+
+ if (result.status !== 0) {
+ return null
+ }
+
+ const candidate = result.stdout
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .find((line) => line.length > 0)
+
+ return candidate || null
+}
+
+function loadUiohookModule() {
+ const moduleExports = nodeRequire("uiohook-napi") as UiohookModuleNamespace;
+ const defaultExport = moduleExports.default;
+
+ if (moduleExports.uIOhook) {
+ return moduleExports.uIOhook;
}
- const systemFfmpeg = resolveSystemFfmpegBinaryPath();
- if (systemFfmpeg) {
- return systemFfmpeg;
+ if (moduleExports.uiohook) {
+ return moduleExports.uiohook;
}
- throw new Error(
- "FFmpeg binary is unavailable. Install ffmpeg-static for this platform or make ffmpeg available on PATH.",
- );
+ if (moduleExports.Uiohook) {
+ return moduleExports.Uiohook;
+ }
+
+ if (isUiohookLike(defaultExport)) {
+ return defaultExport;
+ }
+
+ if (defaultExport?.uIOhook) {
+ return defaultExport.uIOhook;
+ }
+
+ if (defaultExport?.uiohook) {
+ return defaultExport.uiohook;
+ }
+
+ if (defaultExport?.Uiohook) {
+ return defaultExport.Uiohook;
+ }
+
+ return null;
+}
+
+function getFfmpegBinaryPath() {
+ const ffmpegStatic = loadFfmpegStatic()
+ if (ffmpegStatic && typeof ffmpegStatic === 'string') {
+ const bundledPath = app.isPackaged
+ ? ffmpegStatic.replace(/\.asar([\/\\])/, '.asar.unpacked$1')
+ : ffmpegStatic
+
+ if (existsSync(bundledPath)) {
+ return bundledPath
+ }
+ }
+
+ const systemFfmpeg = resolveSystemFfmpegBinaryPath()
+ if (systemFfmpeg) {
+ return systemFfmpeg
+ }
+
+ throw new Error('FFmpeg binary is unavailable. Install ffmpeg-static for this platform or make ffmpeg available on PATH.')
}
type NativeVideoExportSession = {
ffmpegProcess: ChildProcessByStdio;
outputPath: string;
inputByteSize: number;
+ inputMode: 'rawvideo' | 'h264-stream';
maxQueuedWriteBytes: number;
stderrOutput: string;
encoderName: string;
@@ -1445,6 +1492,7 @@ export function cleanupNativeVideoExportSessions() {
}
function getNativeVideoExportMaxQueuedWriteBytes(inputByteSize: number) {
+ if (inputByteSize === 0) return 8 * 1024 * 1024; // H264 stream: variable-size chunks
return Math.min(64 * 1024 * 1024, Math.max(16 * 1024 * 1024, inputByteSize * 4));
}
@@ -1591,7 +1639,7 @@ async function writeNativeVideoExportFrame(
session: NativeVideoExportSession,
frameData: Uint8Array | ArrayBuffer,
) {
- if (getNativeVideoExportFrameLength(frameData) !== session.inputByteSize) {
+ if (session.inputMode !== 'h264-stream' && getNativeVideoExportFrameLength(frameData) !== session.inputByteSize) {
throw new Error(
`Native video export expected ${session.inputByteSize} bytes per frame but received ${getNativeVideoExportFrameLength(frameData)}`,
);
@@ -1827,31 +1875,31 @@ async function muxNativeVideoExportAudio(
}
async function muxExportedVideoAudioBuffer(
- videoData: ArrayBuffer,
- options: NativeVideoExportFinishOptions,
+ videoData: ArrayBuffer,
+ options: NativeVideoExportFinishOptions,
) {
- const tempVideoPath = path.join(
- app.getPath("temp"),
- `recordly-export-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`,
- );
-
- try {
- await fs.writeFile(tempVideoPath, Buffer.from(videoData));
- const finalizedPath = await muxNativeVideoExportAudio(tempVideoPath, options);
- const muxedData = await fs.readFile(finalizedPath);
- return new Uint8Array(muxedData);
- } finally {
- await Promise.allSettled([
- removeTemporaryExportFile(tempVideoPath),
- removeTemporaryExportFile(`${tempVideoPath}.muxed.mp4`),
- removeTemporaryExportFile(
- path.join(
- path.dirname(tempVideoPath),
- `${path.basename(tempVideoPath, path.extname(tempVideoPath))}-final.mp4`,
- ),
- ),
- ]);
- }
+ const tempVideoPath = path.join(
+ app.getPath('temp'),
+ `recordly-export-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`,
+ )
+
+ try {
+ await fs.writeFile(tempVideoPath, Buffer.from(videoData))
+ const finalizedPath = await muxNativeVideoExportAudio(tempVideoPath, options)
+ const muxedData = await fs.readFile(finalizedPath)
+ return new Uint8Array(muxedData)
+ } finally {
+ await Promise.allSettled([
+ removeTemporaryExportFile(tempVideoPath),
+ removeTemporaryExportFile(`${tempVideoPath}.muxed.mp4`),
+ removeTemporaryExportFile(
+ path.join(
+ path.dirname(tempVideoPath),
+ `${path.basename(tempVideoPath, path.extname(tempVideoPath))}-final.mp4`,
+ ),
+ ),
+ ])
+ }
}
/** Probe the duration of a media file (in seconds) using the container header. */
@@ -3818,7 +3866,7 @@ function normalizeHookMouseButton(rawButton: unknown): 1 | 2 | 3 {
return 1;
}
-function getHookMouseButton(event: HookCursorEvent | null | undefined): 1 | 2 | 3 {
+function getHookMouseButton(event: HookMouseEvent | null | undefined): 1 | 2 | 3 {
return normalizeHookMouseButton(
event?.button ?? event?.mouseButton ?? event?.data?.button ?? event?.data?.mouseButton,
);
@@ -3983,7 +4031,7 @@ function getNormalizedCursorPoint() {
}
function getHookCursorScreenPoint(
- event: HookCursorEvent | null | undefined,
+ event: HookMouseEvent | 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;
@@ -4203,7 +4251,7 @@ async function startInteractionCapture() {
return;
}
- const onMouseDown = (event: HookCursorEvent) => {
+ const onMouseDown = (event: HookMouseEvent) => {
if (!isCursorCaptureActive) {
return;
}
@@ -4241,7 +4289,7 @@ async function startInteractionCapture() {
pushCursorSample(point.cx, point.cy, timeMs, interactionType);
};
- const onMouseUp = (_event: HookCursorEvent) => {
+ const onMouseUp = () => {
if (!isCursorCaptureActive) {
return;
}
@@ -4255,7 +4303,7 @@ async function startInteractionCapture() {
pushCursorSample(point.cx, point.cy, timeMs, "mouseup");
};
- const onMouseMove = (event: HookCursorEvent) => {
+ const onMouseMove = (event: HookMouseEvent) => {
if (process.platform !== "linux" || !isCursorCaptureActive) {
return;
}
@@ -4670,2488 +4718,2266 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
-