feat: add unified native bridge foundation

This commit is contained in:
Etienne Lescot
2026-03-16 10:41:35 +01:00
committed by EtienneLescot
parent 6f099b3483
commit 44f59bfa89
17 changed files with 1072 additions and 5 deletions
+39
View File
@@ -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.
+3
View File
@@ -24,6 +24,9 @@ declare namespace NodeJS {
// Used in Renderer process, expose in `preload.ts` // Used in Renderer process, expose in `preload.ts`
interface Window { interface Window {
electronAPI: { electronAPI: {
invokeNativeBridge: <TData = unknown>(
request: import("../src/native/contracts").NativeBridgeRequest,
) => Promise<import("../src/native/contracts").NativeBridgeResponse<TData>>;
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>; getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>; switchToEditor: () => Promise<void>;
switchToHud: () => Promise<void>; switchToHud: () => Promise<void>;
+229
View File
@@ -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<ProjectFileResult>;
loadProjectFile: () => Promise<ProjectFileResult>;
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
setCurrentVideoPath: (path: string) => ProjectPathResult;
getCurrentVideoPathResult: () => ProjectPathResult;
clearCurrentVideoPath: () => ProjectPathResult;
resolveAssetBasePath: () => string | null;
resolveVideoPath: (videoPath?: string | null) => string | null;
loadCursorRecordingData: (
videoPath: string,
) => Promise<import("../../src/native/contracts").CursorRecordingData>;
loadCursorTelemetry: (videoPath: string) => Promise<CursorTelemetryLoadResult>;
}
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<TData>(requestId: string | undefined, data: TData) {
return {
ok: true,
data,
meta: createMeta(requestId),
} satisfies NativeBridgeResponse<TData>;
}
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<NativeBridgeRequest>;
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,
);
}
});
}
+20
View File
@@ -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<CursorCapabilities>;
getRecordingData(videoPath?: string | null): Promise<CursorRecordingData>;
getTelemetry(videoPath?: string | null): Promise<CursorTelemetryLoadResult>;
}
@@ -0,0 +1,48 @@
import type { CursorCapabilities, CursorRecordingData } from "../../../src/native/contracts";
import type { CursorNativeAdapter, CursorTelemetryLoadResult } from "./adapter";
interface TelemetryCursorAdapterOptions {
loadRecordingData: (videoPath: string) => Promise<CursorRecordingData>;
resolveVideoPath: (videoPath?: string | null) => string | null;
loadTelemetry: (videoPath: string) => Promise<CursorTelemetryLoadResult>;
}
export class TelemetryCursorAdapter implements CursorNativeAdapter {
readonly kind = "none" as const;
constructor(private readonly options: TelemetryCursorAdapterOptions) {}
async getCapabilities(): Promise<CursorCapabilities> {
return {
telemetry: true,
systemAssets: false,
provider: this.kind,
};
}
async getRecordingData(videoPath?: string | null): Promise<CursorRecordingData> {
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);
}
}
@@ -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<CursorCapabilities> {
const capabilities = await this.options.adapter.getCapabilities();
this.options.store.setCursorCapabilities(capabilities);
return capabilities;
}
async getTelemetry(videoPath?: string | null): Promise<CursorTelemetryPoint[]> {
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<CursorRecordingData> {
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;
}
}
@@ -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<ProjectFileResult>;
loadProjectFile: () => Promise<ProjectFileResult>;
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
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;
}
}
@@ -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<CursorCapabilities>;
}
export class SystemService {
constructor(private readonly options: SystemServiceOptions) {}
getPlatform() {
return this.options.getPlatform();
}
getAssetBasePath() {
return this.options.getAssetBasePath();
}
async getCapabilities(): Promise<SystemCapabilities> {
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;
}
}
+88
View File
@@ -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(),
},
},
};
}
}
+4 -1
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from "electron"; 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 // 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 // (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", { contextBridge.exposeInMainWorld("electronAPI", {
assetBaseUrl, assetBaseUrl,
invokeNativeBridge: <TData>(request: NativeBridgeRequest) => {
return ipcRenderer.invoke(NATIVE_BRIDGE_CHANNEL, request) as Promise<TData>;
},
hudOverlayHide: () => { hudOverlayHide: () => {
ipcRenderer.send("hud-overlay-hide"); ipcRenderer.send("hud-overlay-hide");
}, },
+3 -2
View File
@@ -20,6 +20,7 @@ import {
import { RxDragHandleDots2 } from "react-icons/rx"; import { RxDragHandleDots2 } from "react-icons/rx";
import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
import { nativeBridgeClient } from "@/native";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useCameraDevices } from "../../hooks/useCameraDevices"; import { useCameraDevices } from "../../hooks/useCameraDevices";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
@@ -293,13 +294,13 @@ export function LaunchWindow() {
} }
if (result.success && result.path) { if (result.success && result.path) {
await window.electronAPI.setCurrentVideoPath(result.path); await nativeBridgeClient.project.setCurrentVideoPath(result.path);
await window.electronAPI.switchToEditor(); await window.electronAPI.switchToEditor();
} }
}; };
const openProjectFile = async () => { const openProjectFile = async () => {
const result = await window.electronAPI.loadProjectFile(); const result = await nativeBridgeClient.project.loadProjectFile();
if (result.canceled || !result.success) return; if (result.canceled || !result.success) return;
await window.electronAPI.switchToEditor(); await window.electronAPI.switchToEditor();
}; };
+1 -2
View File
@@ -1,8 +1,7 @@
import { fixWebmDuration } from "@fix-webm-duration/fix"; import { fixWebmDuration } from "@fix-webm-duration/fix";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useScopedT } from "@/contexts/I18nContext"; import { nativeBridgeClient } from "@/native";
import { requestCameraAccess } from "@/lib/requestCameraAccess";
const TARGET_FRAME_RATE = 60; const TARGET_FRAME_RATE = 60;
const MIN_FRAME_RATE = 30; const MIN_FRAME_RATE = 30;
+133
View File
@@ -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<TData = unknown>(
request: NativeBridgeRequest,
): Promise<NativeBridgeResponse<TData>> {
const invoke = getElectronBridge();
return invoke({
...request,
requestId: request.requestId ?? createRequestId(),
});
}
export async function requireNativeBridgeData<TData>(request: NativeBridgeRequest): Promise<TData> {
const response = await invokeNativeBridge<TData>(request);
if (!response.ok) {
throw new Error(response.error.message);
}
return response.data;
}
export const nativeBridgeClient = {
rawInvoke: invokeNativeBridge,
system: {
getPlatform: () =>
requireNativeBridgeData<NativePlatform>({
domain: "system",
action: "getPlatform",
}),
getAssetBasePath: () =>
requireNativeBridgeData<string | null>({
domain: "system",
action: "getAssetBasePath",
}),
getCapabilities: () =>
requireNativeBridgeData<SystemCapabilities>({
domain: "system",
action: "getCapabilities",
}),
},
project: {
getCurrentContext: () =>
requireNativeBridgeData<ProjectContext>({
domain: "project",
action: "getCurrentContext",
}),
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) =>
requireNativeBridgeData<ProjectFileResult>({
domain: "project",
action: "saveProjectFile",
payload: {
projectData,
suggestedName,
existingProjectPath,
},
}),
loadProjectFile: () =>
requireNativeBridgeData<ProjectFileResult>({
domain: "project",
action: "loadProjectFile",
}),
loadCurrentProjectFile: () =>
requireNativeBridgeData<ProjectFileResult>({
domain: "project",
action: "loadCurrentProjectFile",
}),
setCurrentVideoPath: (path: string) =>
requireNativeBridgeData<ProjectPathResult>({
domain: "project",
action: "setCurrentVideoPath",
payload: { path },
}),
getCurrentVideoPath: () =>
requireNativeBridgeData<ProjectPathResult>({
domain: "project",
action: "getCurrentVideoPath",
}),
clearCurrentVideoPath: () =>
requireNativeBridgeData<ProjectPathResult>({
domain: "project",
action: "clearCurrentVideoPath",
}),
},
cursor: {
getCapabilities: () =>
requireNativeBridgeData<CursorCapabilities>({
domain: "cursor",
action: "getCapabilities",
}),
getRecordingData: (videoPath?: string) =>
requireNativeBridgeData<CursorRecordingData>({
domain: "cursor",
action: "getRecordingData",
payload: videoPath ? { videoPath } : {},
}),
getTelemetry: (videoPath?: string) =>
requireNativeBridgeData<CursorTelemetryPoint[]>({
domain: "cursor",
action: "getTelemetry",
payload: videoPath ? { videoPath } : {},
}),
},
};
+209
View File
@@ -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<TData> {
ok: true;
data: TData;
meta: NativeBridgeMeta;
}
export interface NativeBridgeFailure {
ok: false;
error: NativeBridgeError;
meta: NativeBridgeMeta;
}
export type NativeBridgeResponse<TData = unknown> =
| NativeBridgeSuccess<TData>
| NativeBridgeFailure;
type EmptyPayload = Record<string, never>;
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<TPayload = unknown> {
name: NativeBridgeEventName;
payload: TPayload;
meta: NativeBridgeMeta;
}
@@ -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<CursorRecordingData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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,
};
}
+61
View File
@@ -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<CursorTelemetryPoint[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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,
};
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./client";
export * from "./contracts";
export * from "./hooks/useCursorRecordingData";
export * from "./hooks/useCursorTelemetry";