From 44f59bfa8901cec17cb34bfcaf03b0be464d3bd6 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 16 Mar 2026 10:41:35 +0100 Subject: [PATCH] feat: add unified native bridge foundation --- docs/architecture/native-bridge.md | 39 +++ electron/electron-env.d.ts | 3 + electron/ipc/nativeBridge.ts | 229 ++++++++++++++++++ electron/native-bridge/cursor/adapter.ts | 20 ++ .../cursor/telemetryCursorAdapter.ts | 48 ++++ .../native-bridge/services/cursorService.ts | 46 ++++ .../native-bridge/services/projectService.ts | 80 ++++++ .../native-bridge/services/systemService.ts | 43 ++++ electron/native-bridge/store.ts | 88 +++++++ electron/preload.ts | 5 +- src/components/launch/LaunchWindow.tsx | 5 +- src/hooks/useScreenRecorder.ts | 3 +- src/native/client.ts | 133 ++++++++++ src/native/contracts.ts | 209 ++++++++++++++++ src/native/hooks/useCursorRecordingData.ts | 61 +++++ src/native/hooks/useCursorTelemetry.ts | 61 +++++ src/native/index.ts | 4 + 17 files changed, 1072 insertions(+), 5 deletions(-) create mode 100644 docs/architecture/native-bridge.md create mode 100644 electron/ipc/nativeBridge.ts create mode 100644 electron/native-bridge/cursor/adapter.ts create mode 100644 electron/native-bridge/cursor/telemetryCursorAdapter.ts create mode 100644 electron/native-bridge/services/cursorService.ts create mode 100644 electron/native-bridge/services/projectService.ts create mode 100644 electron/native-bridge/services/systemService.ts create mode 100644 electron/native-bridge/store.ts create mode 100644 src/native/client.ts create mode 100644 src/native/contracts.ts create mode 100644 src/native/hooks/useCursorRecordingData.ts create mode 100644 src/native/hooks/useCursorTelemetry.ts create mode 100644 src/native/index.ts diff --git a/docs/architecture/native-bridge.md b/docs/architecture/native-bridge.md new file mode 100644 index 0000000..ef320f7 --- /dev/null +++ b/docs/architecture/native-bridge.md @@ -0,0 +1,39 @@ +# Native Bridge Architecture + +## Goal + +Provide a single, resilient source of truth for platform-native capabilities while keeping Electron transport thin and renderer APIs unified. + +## Layers + +1. Native adapters +Platform-specific providers implement stable domain interfaces such as cursor telemetry or system asset discovery. + +2. Main-process services +Services orchestrate adapters, own runtime state, and expose domain-level operations. + +3. Unified IPC transport +Renderer code talks to a single `native-bridge:invoke` channel using versioned contracts. + +4. Renderer client +React code should consume `src/native/client.ts` rather than binding directly to ad hoc Electron APIs. + +## Principles + +- Single source of truth: runtime-native state lives in the Electron main process. +- Capability-first: renderer can query support before attempting native behavior. +- Versioned contracts: requests and responses are explicit and evolve predictably. +- Resilience: every response uses a consistent result envelope with stable error codes. + +## Current rollout + +This repository now contains the initial scaffold: + +- shared contracts in `src/native/contracts.ts` +- renderer SDK in `src/native/client.ts` +- main-process state store in `electron/native-bridge/store.ts` +- cursor telemetry adapter in `electron/native-bridge/cursor/telemetryCursorAdapter.ts` +- domain services in `electron/native-bridge/services/*` +- unified handler registration in `electron/ipc/nativeBridge.ts` + +The legacy `window.electronAPI` surface still exists for backward compatibility. New native-facing features should prefer the unified bridge client. \ No newline at end of file diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 1d528cd..686575d 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -24,6 +24,9 @@ declare namespace NodeJS { // Used in Renderer process, expose in `preload.ts` interface Window { electronAPI: { + invokeNativeBridge: ( + request: import("../src/native/contracts").NativeBridgeRequest, + ) => Promise>; getSources: (opts: Electron.SourcesOptions) => Promise; switchToEditor: () => Promise; switchToHud: () => Promise; diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts new file mode 100644 index 0000000..ba6258a --- /dev/null +++ b/electron/ipc/nativeBridge.ts @@ -0,0 +1,229 @@ +import { ipcMain } from "electron"; +import { + NATIVE_BRIDGE_CHANNEL, + NATIVE_BRIDGE_VERSION, + type NativeBridgeErrorCode, + type NativeBridgeRequest, + type NativeBridgeResponse, + type NativePlatform, + type ProjectFileResult, + type ProjectPathResult, +} from "../../src/native/contracts"; +import type { CursorTelemetryLoadResult } from "../native-bridge/cursor/adapter"; +import { TelemetryCursorAdapter } from "../native-bridge/cursor/telemetryCursorAdapter"; +import { CursorService } from "../native-bridge/services/cursorService"; +import { ProjectService } from "../native-bridge/services/projectService"; +import { SystemService } from "../native-bridge/services/systemService"; +import { NativeBridgeStateStore } from "../native-bridge/store"; + +export interface NativeBridgeContext { + getPlatform: () => NodeJS.Platform; + getCurrentProjectPath: () => string | null; + getCurrentVideoPath: () => string | null; + saveProjectFile: ( + projectData: unknown, + suggestedName?: string, + existingProjectPath?: string, + ) => Promise; + loadProjectFile: () => Promise; + loadCurrentProjectFile: () => Promise; + setCurrentVideoPath: (path: string) => ProjectPathResult; + getCurrentVideoPathResult: () => ProjectPathResult; + clearCurrentVideoPath: () => ProjectPathResult; + resolveAssetBasePath: () => string | null; + resolveVideoPath: (videoPath?: string | null) => string | null; + loadCursorRecordingData: ( + videoPath: string, + ) => Promise; + loadCursorTelemetry: (videoPath: string) => Promise; +} + +function normalizePlatform(platform: NodeJS.Platform): NativePlatform { + if (platform === "darwin" || platform === "win32") { + return platform; + } + + return "linux"; +} + +function createMeta(requestId?: string) { + return { + version: NATIVE_BRIDGE_VERSION, + requestId: requestId || `native-${Date.now()}`, + timestampMs: Date.now(), + } as const; +} + +function createSuccessResponse(requestId: string | undefined, data: TData) { + return { + ok: true, + data, + meta: createMeta(requestId), + } satisfies NativeBridgeResponse; +} + +function createErrorResponse( + requestId: string | undefined, + code: NativeBridgeErrorCode, + message: string, + retryable = false, +) { + return { + ok: false, + error: { + code, + message, + retryable, + }, + meta: createMeta(requestId), + } satisfies NativeBridgeResponse; +} + +function isBridgeRequest(value: unknown): value is NativeBridgeRequest { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Partial; + return typeof candidate.domain === "string" && typeof candidate.action === "string"; +} + +export function registerNativeBridgeHandlers(context: NativeBridgeContext) { + ipcMain.removeHandler(NATIVE_BRIDGE_CHANNEL); + + const platform = normalizePlatform(context.getPlatform()); + const store = new NativeBridgeStateStore(platform); + const projectService = new ProjectService({ + store, + getCurrentProjectPath: context.getCurrentProjectPath, + getCurrentVideoPath: context.getCurrentVideoPath, + saveProjectFile: context.saveProjectFile, + loadProjectFile: context.loadProjectFile, + loadCurrentProjectFile: context.loadCurrentProjectFile, + setCurrentVideoPath: context.setCurrentVideoPath, + getCurrentVideoPathResult: context.getCurrentVideoPathResult, + clearCurrentVideoPath: context.clearCurrentVideoPath, + }); + const cursorService = new CursorService({ + store, + adapter: new TelemetryCursorAdapter({ + loadRecordingData: context.loadCursorRecordingData, + resolveVideoPath: context.resolveVideoPath, + loadTelemetry: context.loadCursorTelemetry, + }), + }); + const systemService = new SystemService({ + store, + getPlatform: () => platform, + getAssetBasePath: context.resolveAssetBasePath, + getCursorCapabilities: () => cursorService.getCapabilities(), + }); + + ipcMain.handle(NATIVE_BRIDGE_CHANNEL, async (_, request: unknown) => { + if (!isBridgeRequest(request)) { + return createErrorResponse(undefined, "INVALID_REQUEST", "Invalid native bridge request."); + } + + const requestId = request.requestId; + const domain = request.domain as string; + + try { + switch (request.domain) { + case "system": { + const action = request.action as string; + switch (request.action) { + case "getPlatform": + return createSuccessResponse(requestId, systemService.getPlatform()); + case "getAssetBasePath": + return createSuccessResponse(requestId, systemService.getAssetBasePath()); + case "getCapabilities": + return createSuccessResponse(requestId, await systemService.getCapabilities()); + default: + return createErrorResponse( + requestId, + "UNSUPPORTED_ACTION", + `Unsupported system action: ${action}`, + ); + } + } + + case "project": { + const action = request.action as string; + switch (request.action) { + case "getCurrentContext": + return createSuccessResponse(requestId, projectService.getCurrentContext()); + case "saveProjectFile": + return createSuccessResponse( + requestId, + await projectService.saveProjectFile( + request.payload.projectData, + request.payload.suggestedName, + request.payload.existingProjectPath, + ), + ); + case "loadProjectFile": + return createSuccessResponse(requestId, await projectService.loadProjectFile()); + case "loadCurrentProjectFile": + return createSuccessResponse( + requestId, + await projectService.loadCurrentProjectFile(), + ); + case "setCurrentVideoPath": + return createSuccessResponse( + requestId, + projectService.setCurrentVideoPath(request.payload.path), + ); + case "getCurrentVideoPath": + return createSuccessResponse(requestId, projectService.getCurrentVideoPath()); + case "clearCurrentVideoPath": + return createSuccessResponse(requestId, projectService.clearCurrentVideoPath()); + default: + return createErrorResponse( + requestId, + "UNSUPPORTED_ACTION", + `Unsupported project action: ${action}`, + ); + } + } + + case "cursor": { + const action = request.action as string; + switch (request.action) { + case "getCapabilities": + return createSuccessResponse(requestId, await cursorService.getCapabilities()); + case "getTelemetry": + return createSuccessResponse( + requestId, + await cursorService.getTelemetry(request.payload?.videoPath), + ); + case "getRecordingData": + return createSuccessResponse( + requestId, + await cursorService.getRecordingData(request.payload?.videoPath), + ); + default: + return createErrorResponse( + requestId, + "UNSUPPORTED_ACTION", + `Unsupported cursor action: ${action}`, + ); + } + } + + default: + return createErrorResponse( + requestId, + "UNSUPPORTED_ACTION", + `Unsupported bridge domain: ${domain}`, + ); + } + } catch (error) { + return createErrorResponse( + requestId, + "INTERNAL_ERROR", + error instanceof Error ? error.message : "Unknown native bridge error.", + true, + ); + } + }); +} diff --git a/electron/native-bridge/cursor/adapter.ts b/electron/native-bridge/cursor/adapter.ts new file mode 100644 index 0000000..cdb88e2 --- /dev/null +++ b/electron/native-bridge/cursor/adapter.ts @@ -0,0 +1,20 @@ +import type { + CursorCapabilities, + CursorProviderKind, + CursorRecordingData, + CursorTelemetryPoint, +} from "../../../src/native/contracts"; + +export interface CursorTelemetryLoadResult { + success: boolean; + samples: CursorTelemetryPoint[]; + message?: string; + error?: string; +} + +export interface CursorNativeAdapter { + readonly kind: CursorProviderKind; + getCapabilities(): Promise; + getRecordingData(videoPath?: string | null): Promise; + getTelemetry(videoPath?: string | null): Promise; +} diff --git a/electron/native-bridge/cursor/telemetryCursorAdapter.ts b/electron/native-bridge/cursor/telemetryCursorAdapter.ts new file mode 100644 index 0000000..d083995 --- /dev/null +++ b/electron/native-bridge/cursor/telemetryCursorAdapter.ts @@ -0,0 +1,48 @@ +import type { CursorCapabilities, CursorRecordingData } from "../../../src/native/contracts"; +import type { CursorNativeAdapter, CursorTelemetryLoadResult } from "./adapter"; + +interface TelemetryCursorAdapterOptions { + loadRecordingData: (videoPath: string) => Promise; + resolveVideoPath: (videoPath?: string | null) => string | null; + loadTelemetry: (videoPath: string) => Promise; +} + +export class TelemetryCursorAdapter implements CursorNativeAdapter { + readonly kind = "none" as const; + + constructor(private readonly options: TelemetryCursorAdapterOptions) {} + + async getCapabilities(): Promise { + return { + telemetry: true, + systemAssets: false, + provider: this.kind, + }; + } + + async getRecordingData(videoPath?: string | null): Promise { + const resolvedVideoPath = this.options.resolveVideoPath(videoPath); + if (!resolvedVideoPath) { + return { + version: 2, + provider: this.kind, + samples: [], + assets: [], + }; + } + + return this.options.loadRecordingData(resolvedVideoPath); + } + + async getTelemetry(videoPath?: string | null) { + const resolvedVideoPath = this.options.resolveVideoPath(videoPath); + if (!resolvedVideoPath) { + return { + success: true, + samples: [], + } satisfies CursorTelemetryLoadResult; + } + + return this.options.loadTelemetry(resolvedVideoPath); + } +} diff --git a/electron/native-bridge/services/cursorService.ts b/electron/native-bridge/services/cursorService.ts new file mode 100644 index 0000000..e3e9a25 --- /dev/null +++ b/electron/native-bridge/services/cursorService.ts @@ -0,0 +1,46 @@ +import type { + CursorCapabilities, + CursorRecordingData, + CursorTelemetryPoint, +} from "../../../src/native/contracts"; +import type { CursorNativeAdapter } from "../cursor/adapter"; +import type { NativeBridgeStateStore } from "../store"; + +interface CursorServiceOptions { + store: NativeBridgeStateStore; + adapter: CursorNativeAdapter; +} + +export class CursorService { + constructor(private readonly options: CursorServiceOptions) {} + + async getCapabilities(): Promise { + const capabilities = await this.options.adapter.getCapabilities(); + this.options.store.setCursorCapabilities(capabilities); + return capabilities; + } + + async getTelemetry(videoPath?: string | null): Promise { + const result = await this.options.adapter.getTelemetry(videoPath); + if (!result.success) { + throw new Error(result.message || result.error || "Failed to load cursor telemetry"); + } + + const resolvedVideoPath = videoPath ?? this.options.store.getState().project.currentVideoPath; + if (resolvedVideoPath) { + this.options.store.markCursorTelemetryLoaded(resolvedVideoPath, result.samples.length); + } + + return result.samples; + } + + async getRecordingData(videoPath?: string | null): Promise { + const data = await this.options.adapter.getRecordingData(videoPath); + const resolvedVideoPath = videoPath ?? this.options.store.getState().project.currentVideoPath; + if (resolvedVideoPath) { + this.options.store.markCursorTelemetryLoaded(resolvedVideoPath, data.samples.length); + } + + return data; + } +} diff --git a/electron/native-bridge/services/projectService.ts b/electron/native-bridge/services/projectService.ts new file mode 100644 index 0000000..e8d1cd5 --- /dev/null +++ b/electron/native-bridge/services/projectService.ts @@ -0,0 +1,80 @@ +import type { + ProjectContext, + ProjectFileResult, + ProjectPathResult, +} from "../../../src/native/contracts"; +import type { NativeBridgeStateStore } from "../store"; + +interface ProjectServiceOptions { + store: NativeBridgeStateStore; + getCurrentProjectPath: () => string | null; + getCurrentVideoPath: () => string | null; + saveProjectFile: ( + projectData: unknown, + suggestedName?: string, + existingProjectPath?: string, + ) => Promise; + loadProjectFile: () => Promise; + loadCurrentProjectFile: () => Promise; + setCurrentVideoPath: (path: string) => ProjectPathResult; + getCurrentVideoPathResult: () => ProjectPathResult; + clearCurrentVideoPath: () => ProjectPathResult; +} + +export class ProjectService { + constructor(private readonly options: ProjectServiceOptions) {} + + getCurrentContext(): ProjectContext { + const context = { + currentProjectPath: this.options.getCurrentProjectPath(), + currentVideoPath: this.options.getCurrentVideoPath(), + }; + + this.options.store.setProjectContext(context); + return context; + } + + async saveProjectFile( + projectData: unknown, + suggestedName?: string, + existingProjectPath?: string, + ) { + const result = await this.options.saveProjectFile( + projectData, + suggestedName, + existingProjectPath, + ); + this.getCurrentContext(); + return result; + } + + async loadProjectFile() { + const result = await this.options.loadProjectFile(); + this.getCurrentContext(); + return result; + } + + async loadCurrentProjectFile() { + const result = await this.options.loadCurrentProjectFile(); + this.getCurrentContext(); + return result; + } + + setCurrentVideoPath(path: string) { + const result = this.options.setCurrentVideoPath(path); + this.getCurrentContext(); + return result; + } + + getCurrentVideoPath() { + const result = this.options.getCurrentVideoPathResult(); + this.getCurrentContext(); + return result; + } + + clearCurrentVideoPath() { + const result = this.options.clearCurrentVideoPath(); + this.getCurrentContext(); + return result; + } +} diff --git a/electron/native-bridge/services/systemService.ts b/electron/native-bridge/services/systemService.ts new file mode 100644 index 0000000..50eff28 --- /dev/null +++ b/electron/native-bridge/services/systemService.ts @@ -0,0 +1,43 @@ +import type { + CursorCapabilities, + NativePlatform, + SystemCapabilities, +} from "../../../src/native/contracts"; +import { NATIVE_BRIDGE_VERSION } from "../../../src/native/contracts"; +import type { NativeBridgeStateStore } from "../store"; + +interface SystemServiceOptions { + store: NativeBridgeStateStore; + getPlatform: () => NativePlatform; + getAssetBasePath: () => string | null; + getCursorCapabilities: () => Promise; +} + +export class SystemService { + constructor(private readonly options: SystemServiceOptions) {} + + getPlatform() { + return this.options.getPlatform(); + } + + getAssetBasePath() { + return this.options.getAssetBasePath(); + } + + async getCapabilities(): Promise { + const platform = this.getPlatform(); + const cursorCapabilities = await this.options.getCursorCapabilities(); + + const capabilities: SystemCapabilities = { + bridgeVersion: NATIVE_BRIDGE_VERSION, + platform, + cursor: cursorCapabilities, + project: { + currentContext: true, + }, + }; + + this.options.store.setSystemCapabilities(capabilities); + return capabilities; + } +} diff --git a/electron/native-bridge/store.ts b/electron/native-bridge/store.ts new file mode 100644 index 0000000..dcdbed1 --- /dev/null +++ b/electron/native-bridge/store.ts @@ -0,0 +1,88 @@ +import type { + CursorCapabilities, + NativePlatform, + ProjectContext, + SystemCapabilities, +} from "../../src/native/contracts"; + +export interface NativeBridgeState { + system: { + platform: NativePlatform; + capabilities: SystemCapabilities | null; + }; + project: ProjectContext; + cursor: { + capabilities: CursorCapabilities | null; + lastTelemetryLoad: { + videoPath: string; + sampleCount: number; + loadedAt: number; + } | null; + }; +} + +export class NativeBridgeStateStore { + private state: NativeBridgeState; + + constructor(platform: NativePlatform) { + this.state = { + system: { + platform, + capabilities: null, + }, + project: { + currentProjectPath: null, + currentVideoPath: null, + }, + cursor: { + capabilities: null, + lastTelemetryLoad: null, + }, + }; + } + + getState() { + return this.state; + } + + setProjectContext(project: ProjectContext) { + this.state = { + ...this.state, + project, + }; + } + + setSystemCapabilities(capabilities: SystemCapabilities) { + this.state = { + ...this.state, + system: { + ...this.state.system, + capabilities, + }, + }; + } + + setCursorCapabilities(capabilities: CursorCapabilities) { + this.state = { + ...this.state, + cursor: { + ...this.state.cursor, + capabilities, + }, + }; + } + + markCursorTelemetryLoaded(videoPath: string, sampleCount: number) { + this.state = { + ...this.state, + cursor: { + ...this.state.cursor, + lastTelemetryLoad: { + videoPath, + sampleCount, + loadedAt: Date.now(), + }, + }, + }; + } +} diff --git a/electron/preload.ts b/electron/preload.ts index 5980b4c..5ff110d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; -import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; +import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts"; // Asset base URL is passed from the main process via webPreferences.additionalArguments // (see windows.ts). Sandboxed preloads cannot import node:path / node:url, so we @@ -10,6 +10,9 @@ const assetBaseUrl = assetBaseUrlArg ? assetBaseUrlArg.slice(ASSET_BASE_URL_ARG_ contextBridge.exposeInMainWorld("electronAPI", { assetBaseUrl, + invokeNativeBridge: (request: NativeBridgeRequest) => { + return ipcRenderer.invoke(NATIVE_BRIDGE_CHANNEL, request) as Promise; + }, hudOverlayHide: () => { ipcRenderer.send("hud-overlay-hide"); }, diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 6a14fc0..260f4cb 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -20,6 +20,7 @@ import { import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; +import { nativeBridgeClient } from "@/native"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useCameraDevices } from "../../hooks/useCameraDevices"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; @@ -293,13 +294,13 @@ export function LaunchWindow() { } if (result.success && result.path) { - await window.electronAPI.setCurrentVideoPath(result.path); + await nativeBridgeClient.project.setCurrentVideoPath(result.path); await window.electronAPI.switchToEditor(); } }; const openProjectFile = async () => { - const result = await window.electronAPI.loadProjectFile(); + const result = await nativeBridgeClient.project.loadProjectFile(); if (result.canceled || !result.success) return; await window.electronAPI.switchToEditor(); }; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index f14be62..c1bcc2d 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1,8 +1,7 @@ import { fixWebmDuration } from "@fix-webm-duration/fix"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { useScopedT } from "@/contexts/I18nContext"; -import { requestCameraAccess } from "@/lib/requestCameraAccess"; +import { nativeBridgeClient } from "@/native"; const TARGET_FRAME_RATE = 60; const MIN_FRAME_RATE = 30; diff --git a/src/native/client.ts b/src/native/client.ts new file mode 100644 index 0000000..3f53ce4 --- /dev/null +++ b/src/native/client.ts @@ -0,0 +1,133 @@ +import { + type CursorCapabilities, + type CursorRecordingData, + type CursorTelemetryPoint, + NATIVE_BRIDGE_CHANNEL, + type NativeBridgeRequest, + type NativeBridgeResponse, + type NativePlatform, + type ProjectContext, + type ProjectFileResult, + type ProjectPathResult, + type SystemCapabilities, +} from "./contracts"; + +function createRequestId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + return `req-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function getElectronBridge() { + if (!window.electronAPI?.invokeNativeBridge) { + throw new Error( + `Native bridge unavailable. Expected ${NATIVE_BRIDGE_CHANNEL} transport in preload.`, + ); + } + + return window.electronAPI.invokeNativeBridge; +} + +export async function invokeNativeBridge( + request: NativeBridgeRequest, +): Promise> { + const invoke = getElectronBridge(); + return invoke({ + ...request, + requestId: request.requestId ?? createRequestId(), + }); +} + +export async function requireNativeBridgeData(request: NativeBridgeRequest): Promise { + const response = await invokeNativeBridge(request); + if (!response.ok) { + throw new Error(response.error.message); + } + + return response.data; +} + +export const nativeBridgeClient = { + rawInvoke: invokeNativeBridge, + system: { + getPlatform: () => + requireNativeBridgeData({ + domain: "system", + action: "getPlatform", + }), + getAssetBasePath: () => + requireNativeBridgeData({ + domain: "system", + action: "getAssetBasePath", + }), + getCapabilities: () => + requireNativeBridgeData({ + domain: "system", + action: "getCapabilities", + }), + }, + project: { + getCurrentContext: () => + requireNativeBridgeData({ + domain: "project", + action: "getCurrentContext", + }), + saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => + requireNativeBridgeData({ + domain: "project", + action: "saveProjectFile", + payload: { + projectData, + suggestedName, + existingProjectPath, + }, + }), + loadProjectFile: () => + requireNativeBridgeData({ + domain: "project", + action: "loadProjectFile", + }), + loadCurrentProjectFile: () => + requireNativeBridgeData({ + domain: "project", + action: "loadCurrentProjectFile", + }), + setCurrentVideoPath: (path: string) => + requireNativeBridgeData({ + domain: "project", + action: "setCurrentVideoPath", + payload: { path }, + }), + getCurrentVideoPath: () => + requireNativeBridgeData({ + domain: "project", + action: "getCurrentVideoPath", + }), + clearCurrentVideoPath: () => + requireNativeBridgeData({ + domain: "project", + action: "clearCurrentVideoPath", + }), + }, + cursor: { + getCapabilities: () => + requireNativeBridgeData({ + domain: "cursor", + action: "getCapabilities", + }), + getRecordingData: (videoPath?: string) => + requireNativeBridgeData({ + domain: "cursor", + action: "getRecordingData", + payload: videoPath ? { videoPath } : {}, + }), + getTelemetry: (videoPath?: string) => + requireNativeBridgeData({ + domain: "cursor", + action: "getTelemetry", + payload: videoPath ? { videoPath } : {}, + }), + }, +}; diff --git a/src/native/contracts.ts b/src/native/contracts.ts new file mode 100644 index 0000000..73d53db --- /dev/null +++ b/src/native/contracts.ts @@ -0,0 +1,209 @@ +export const NATIVE_BRIDGE_CHANNEL = "native-bridge:invoke"; +export const NATIVE_BRIDGE_VERSION = 1; + +export type NativePlatform = "darwin" | "win32" | "linux"; +export type CursorProviderKind = "native" | "none"; + +export interface CursorTelemetryPoint { + timeMs: number; + cx: number; + cy: number; +} + +export interface CursorRecordingSample extends CursorTelemetryPoint { + assetId?: string | null; + visible?: boolean; +} + +export interface NativeCursorAsset { + id: string; + platform: NativePlatform; + imageDataUrl: string; + width: number; + height: number; + hotspotX: number; + hotspotY: number; + scaleFactor?: number; +} + +export interface CursorRecordingData { + version: number; + provider: CursorProviderKind; + samples: CursorRecordingSample[]; + assets: NativeCursorAsset[]; +} + +export interface CursorCapabilities { + telemetry: boolean; + systemAssets: boolean; + provider: CursorProviderKind; +} + +export interface SystemCapabilities { + bridgeVersion: typeof NATIVE_BRIDGE_VERSION; + platform: NativePlatform; + cursor: CursorCapabilities; + project: { + currentContext: boolean; + }; +} + +export interface ProjectContext { + currentProjectPath: string | null; + currentVideoPath: string | null; +} + +export interface ProjectPathResult { + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + error?: string; +} + +export interface ProjectFileResult { + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; +} + +export type NativeBridgeErrorCode = + | "INVALID_REQUEST" + | "UNSUPPORTED_ACTION" + | "NOT_FOUND" + | "UNAVAILABLE" + | "INTERNAL_ERROR"; + +export interface NativeBridgeError { + code: NativeBridgeErrorCode; + message: string; + retryable: boolean; +} + +export interface NativeBridgeMeta { + version: typeof NATIVE_BRIDGE_VERSION; + requestId: string; + timestampMs: number; +} + +export interface NativeBridgeSuccess { + ok: true; + data: TData; + meta: NativeBridgeMeta; +} + +export interface NativeBridgeFailure { + ok: false; + error: NativeBridgeError; + meta: NativeBridgeMeta; +} + +export type NativeBridgeResponse = + | NativeBridgeSuccess + | NativeBridgeFailure; + +type EmptyPayload = Record; + +export type NativeBridgeRequest = + | { + domain: "system"; + action: "getPlatform"; + payload?: EmptyPayload; + requestId?: string; + } + | { + domain: "system"; + action: "getAssetBasePath"; + payload?: EmptyPayload; + requestId?: string; + } + | { + domain: "system"; + action: "getCapabilities"; + payload?: EmptyPayload; + requestId?: string; + } + | { + domain: "project"; + action: "getCurrentContext"; + payload?: EmptyPayload; + requestId?: string; + } + | { + domain: "project"; + action: "saveProjectFile"; + payload: { + projectData: unknown; + suggestedName?: string; + existingProjectPath?: string; + }; + requestId?: string; + } + | { + domain: "project"; + action: "loadProjectFile"; + payload?: EmptyPayload; + requestId?: string; + } + | { + domain: "project"; + action: "loadCurrentProjectFile"; + payload?: EmptyPayload; + requestId?: string; + } + | { + domain: "project"; + action: "setCurrentVideoPath"; + payload: { + path: string; + }; + requestId?: string; + } + | { + domain: "project"; + action: "getCurrentVideoPath"; + payload?: EmptyPayload; + requestId?: string; + } + | { + domain: "project"; + action: "clearCurrentVideoPath"; + payload?: EmptyPayload; + requestId?: string; + } + | { + domain: "cursor"; + action: "getCapabilities"; + payload?: EmptyPayload; + requestId?: string; + } + | { + domain: "cursor"; + action: "getTelemetry"; + payload?: { + videoPath?: string; + }; + requestId?: string; + } + | { + domain: "cursor"; + action: "getRecordingData"; + payload?: { + videoPath?: string; + }; + requestId?: string; + }; + +export type NativeBridgeEventName = + | "project.contextChanged" + | "cursor.providerChanged" + | "cursor.telemetryLoaded"; + +export interface NativeBridgeEvent { + name: NativeBridgeEventName; + payload: TPayload; + meta: NativeBridgeMeta; +} diff --git a/src/native/hooks/useCursorRecordingData.ts b/src/native/hooks/useCursorRecordingData.ts new file mode 100644 index 0000000..6b3451a --- /dev/null +++ b/src/native/hooks/useCursorRecordingData.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react"; +import type { CursorRecordingData } from "@/native/contracts"; +import { nativeBridgeClient } from "../client"; + +interface UseCursorRecordingDataResult { + data: CursorRecordingData | null; + loading: boolean; + error: string | null; +} + +export function useCursorRecordingData(videoPath: string | null): UseCursorRecordingDataResult { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function loadCursorRecordingData() { + if (!videoPath) { + setData(null); + setLoading(false); + setError(null); + return; + } + + setLoading(true); + setError(null); + + try { + const nextData = await nativeBridgeClient.cursor.getRecordingData(videoPath); + if (!cancelled) { + setData(nextData); + } + } catch (nextError) { + if (!cancelled) { + setData(null); + setError( + nextError instanceof Error ? nextError.message : "Failed to load cursor recording data", + ); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + loadCursorRecordingData(); + + return () => { + cancelled = true; + }; + }, [videoPath]); + + return { + data, + loading, + error, + }; +} diff --git a/src/native/hooks/useCursorTelemetry.ts b/src/native/hooks/useCursorTelemetry.ts new file mode 100644 index 0000000..1617680 --- /dev/null +++ b/src/native/hooks/useCursorTelemetry.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react"; +import type { CursorTelemetryPoint } from "@/components/video-editor/types"; +import { nativeBridgeClient } from "../client"; + +interface UseCursorTelemetryResult { + samples: CursorTelemetryPoint[]; + loading: boolean; + error: string | null; +} + +export function useCursorTelemetry(videoPath: string | null): UseCursorTelemetryResult { + const [samples, setSamples] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function loadCursorTelemetry() { + if (!videoPath) { + setSamples([]); + setLoading(false); + setError(null); + return; + } + + setLoading(true); + setError(null); + + try { + const nextSamples = await nativeBridgeClient.cursor.getTelemetry(videoPath); + if (!cancelled) { + setSamples(nextSamples); + } + } catch (nextError) { + if (!cancelled) { + setSamples([]); + setError( + nextError instanceof Error ? nextError.message : "Failed to load cursor telemetry", + ); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + loadCursorTelemetry(); + + return () => { + cancelled = true; + }; + }, [videoPath]); + + return { + samples, + loading, + error, + }; +} diff --git a/src/native/index.ts b/src/native/index.ts new file mode 100644 index 0000000..817d1cf --- /dev/null +++ b/src/native/index.ts @@ -0,0 +1,4 @@ +export * from "./client"; +export * from "./contracts"; +export * from "./hooks/useCursorRecordingData"; +export * from "./hooks/useCursorTelemetry";