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
+3 -2
View File
@@ -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();
};
+1 -2
View File
@@ -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;
+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";