feat: add unified native bridge foundation
This commit is contained in:
committed by
EtienneLescot
parent
6f099b3483
commit
44f59bfa89
@@ -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,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;
|
||||
|
||||
@@ -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 } : {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./client";
|
||||
export * from "./contracts";
|
||||
export * from "./hooks/useCursorRecordingData";
|
||||
export * from "./hooks/useCursorTelemetry";
|
||||
Reference in New Issue
Block a user