feat: add unified native bridge foundation
This commit is contained in:
committed by
EtienneLescot
parent
6f099b3483
commit
44f59bfa89
@@ -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.
|
||||||
Vendored
+3
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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