Skip to content

Commit ceaaa65

Browse files
committed
feat: add unified native bridge foundation
1 parent 02d258e commit ceaaa65

19 files changed

Lines changed: 1177 additions & 123 deletions

docs/architecture/native-bridge.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Native Bridge Architecture
2+
3+
## Goal
4+
5+
Provide a single, resilient source of truth for platform-native capabilities while keeping Electron transport thin and renderer APIs unified.
6+
7+
## Layers
8+
9+
1. Native adapters
10+
Platform-specific providers implement stable domain interfaces such as cursor telemetry or system asset discovery.
11+
12+
2. Main-process services
13+
Services orchestrate adapters, own runtime state, and expose domain-level operations.
14+
15+
3. Unified IPC transport
16+
Renderer code talks to a single `native-bridge:invoke` channel using versioned contracts.
17+
18+
4. Renderer client
19+
React code should consume `src/native/client.ts` rather than binding directly to ad hoc Electron APIs.
20+
21+
## Principles
22+
23+
- Single source of truth: runtime-native state lives in the Electron main process.
24+
- Capability-first: renderer can query support before attempting native behavior.
25+
- Versioned contracts: requests and responses are explicit and evolve predictably.
26+
- Resilience: every response uses a consistent result envelope with stable error codes.
27+
28+
## Current rollout
29+
30+
This repository now contains the initial scaffold:
31+
32+
- shared contracts in `src/native/contracts.ts`
33+
- renderer SDK in `src/native/client.ts`
34+
- main-process state store in `electron/native-bridge/store.ts`
35+
- cursor telemetry adapter in `electron/native-bridge/cursor/telemetryCursorAdapter.ts`
36+
- domain services in `electron/native-bridge/services/*`
37+
- unified handler registration in `electron/ipc/nativeBridge.ts`
38+
39+
The legacy `window.electronAPI` surface still exists for backward compatibility. New native-facing features should prefer the unified bridge client.

electron/electron-env.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ declare namespace NodeJS {
2424
// Used in Renderer process, expose in `preload.ts`
2525
interface Window {
2626
electronAPI: {
27+
invokeNativeBridge: <TData = unknown>(
28+
request: import("../src/native/contracts").NativeBridgeRequest,
29+
) => Promise<import("../src/native/contracts").NativeBridgeResponse<TData>>;
2730
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
2831
switchToEditor: () => Promise<void>;
2932
openSourceSelector: () => Promise<void>;

electron/ipc/nativeBridge.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { ipcMain } from "electron";
2+
import {
3+
NATIVE_BRIDGE_CHANNEL,
4+
NATIVE_BRIDGE_VERSION,
5+
type NativeBridgeErrorCode,
6+
type NativeBridgeRequest,
7+
type NativeBridgeResponse,
8+
type NativePlatform,
9+
type ProjectFileResult,
10+
type ProjectPathResult,
11+
} from "../../src/native/contracts";
12+
import type { CursorTelemetryLoadResult } from "../native-bridge/cursor/adapter";
13+
import { TelemetryCursorAdapter } from "../native-bridge/cursor/telemetryCursorAdapter";
14+
import { CursorService } from "../native-bridge/services/cursorService";
15+
import { ProjectService } from "../native-bridge/services/projectService";
16+
import { SystemService } from "../native-bridge/services/systemService";
17+
import { NativeBridgeStateStore } from "../native-bridge/store";
18+
19+
export interface NativeBridgeContext {
20+
getPlatform: () => NodeJS.Platform;
21+
getCurrentProjectPath: () => string | null;
22+
getCurrentVideoPath: () => string | null;
23+
saveProjectFile: (
24+
projectData: unknown,
25+
suggestedName?: string,
26+
existingProjectPath?: string,
27+
) => Promise<ProjectFileResult>;
28+
loadProjectFile: () => Promise<ProjectFileResult>;
29+
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
30+
setCurrentVideoPath: (path: string) => ProjectPathResult;
31+
getCurrentVideoPathResult: () => ProjectPathResult;
32+
clearCurrentVideoPath: () => ProjectPathResult;
33+
resolveAssetBasePath: () => string | null;
34+
resolveVideoPath: (videoPath?: string | null) => string | null;
35+
loadCursorRecordingData: (
36+
videoPath: string,
37+
) => Promise<import("../../src/native/contracts").CursorRecordingData>;
38+
loadCursorTelemetry: (videoPath: string) => Promise<CursorTelemetryLoadResult>;
39+
}
40+
41+
function normalizePlatform(platform: NodeJS.Platform): NativePlatform {
42+
if (platform === "darwin" || platform === "win32") {
43+
return platform;
44+
}
45+
46+
return "linux";
47+
}
48+
49+
function createMeta(requestId?: string) {
50+
return {
51+
version: NATIVE_BRIDGE_VERSION,
52+
requestId: requestId || `native-${Date.now()}`,
53+
timestampMs: Date.now(),
54+
} as const;
55+
}
56+
57+
function createSuccessResponse<TData>(requestId: string | undefined, data: TData) {
58+
return {
59+
ok: true,
60+
data,
61+
meta: createMeta(requestId),
62+
} satisfies NativeBridgeResponse<TData>;
63+
}
64+
65+
function createErrorResponse(
66+
requestId: string | undefined,
67+
code: NativeBridgeErrorCode,
68+
message: string,
69+
retryable = false,
70+
) {
71+
return {
72+
ok: false,
73+
error: {
74+
code,
75+
message,
76+
retryable,
77+
},
78+
meta: createMeta(requestId),
79+
} satisfies NativeBridgeResponse;
80+
}
81+
82+
function isBridgeRequest(value: unknown): value is NativeBridgeRequest {
83+
if (!value || typeof value !== "object") {
84+
return false;
85+
}
86+
87+
const candidate = value as Partial<NativeBridgeRequest>;
88+
return typeof candidate.domain === "string" && typeof candidate.action === "string";
89+
}
90+
91+
export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
92+
ipcMain.removeHandler(NATIVE_BRIDGE_CHANNEL);
93+
94+
const platform = normalizePlatform(context.getPlatform());
95+
const store = new NativeBridgeStateStore(platform);
96+
const projectService = new ProjectService({
97+
store,
98+
getCurrentProjectPath: context.getCurrentProjectPath,
99+
getCurrentVideoPath: context.getCurrentVideoPath,
100+
saveProjectFile: context.saveProjectFile,
101+
loadProjectFile: context.loadProjectFile,
102+
loadCurrentProjectFile: context.loadCurrentProjectFile,
103+
setCurrentVideoPath: context.setCurrentVideoPath,
104+
getCurrentVideoPathResult: context.getCurrentVideoPathResult,
105+
clearCurrentVideoPath: context.clearCurrentVideoPath,
106+
});
107+
const cursorService = new CursorService({
108+
store,
109+
adapter: new TelemetryCursorAdapter({
110+
loadRecordingData: context.loadCursorRecordingData,
111+
resolveVideoPath: context.resolveVideoPath,
112+
loadTelemetry: context.loadCursorTelemetry,
113+
}),
114+
});
115+
const systemService = new SystemService({
116+
store,
117+
getPlatform: () => platform,
118+
getAssetBasePath: context.resolveAssetBasePath,
119+
getCursorCapabilities: () => cursorService.getCapabilities(),
120+
});
121+
122+
ipcMain.handle(NATIVE_BRIDGE_CHANNEL, async (_, request: unknown) => {
123+
if (!isBridgeRequest(request)) {
124+
return createErrorResponse(undefined, "INVALID_REQUEST", "Invalid native bridge request.");
125+
}
126+
127+
const requestId = request.requestId;
128+
const domain = request.domain as string;
129+
130+
try {
131+
switch (request.domain) {
132+
case "system": {
133+
const action = request.action as string;
134+
switch (request.action) {
135+
case "getPlatform":
136+
return createSuccessResponse(requestId, systemService.getPlatform());
137+
case "getAssetBasePath":
138+
return createSuccessResponse(requestId, systemService.getAssetBasePath());
139+
case "getCapabilities":
140+
return createSuccessResponse(requestId, await systemService.getCapabilities());
141+
default:
142+
return createErrorResponse(
143+
requestId,
144+
"UNSUPPORTED_ACTION",
145+
`Unsupported system action: ${action}`,
146+
);
147+
}
148+
}
149+
150+
case "project": {
151+
const action = request.action as string;
152+
switch (request.action) {
153+
case "getCurrentContext":
154+
return createSuccessResponse(requestId, projectService.getCurrentContext());
155+
case "saveProjectFile":
156+
return createSuccessResponse(
157+
requestId,
158+
await projectService.saveProjectFile(
159+
request.payload.projectData,
160+
request.payload.suggestedName,
161+
request.payload.existingProjectPath,
162+
),
163+
);
164+
case "loadProjectFile":
165+
return createSuccessResponse(requestId, await projectService.loadProjectFile());
166+
case "loadCurrentProjectFile":
167+
return createSuccessResponse(
168+
requestId,
169+
await projectService.loadCurrentProjectFile(),
170+
);
171+
case "setCurrentVideoPath":
172+
return createSuccessResponse(
173+
requestId,
174+
projectService.setCurrentVideoPath(request.payload.path),
175+
);
176+
case "getCurrentVideoPath":
177+
return createSuccessResponse(requestId, projectService.getCurrentVideoPath());
178+
case "clearCurrentVideoPath":
179+
return createSuccessResponse(requestId, projectService.clearCurrentVideoPath());
180+
default:
181+
return createErrorResponse(
182+
requestId,
183+
"UNSUPPORTED_ACTION",
184+
`Unsupported project action: ${action}`,
185+
);
186+
}
187+
}
188+
189+
case "cursor": {
190+
const action = request.action as string;
191+
switch (request.action) {
192+
case "getCapabilities":
193+
return createSuccessResponse(requestId, await cursorService.getCapabilities());
194+
case "getTelemetry":
195+
return createSuccessResponse(
196+
requestId,
197+
await cursorService.getTelemetry(request.payload?.videoPath),
198+
);
199+
case "getRecordingData":
200+
return createSuccessResponse(
201+
requestId,
202+
await cursorService.getRecordingData(request.payload?.videoPath),
203+
);
204+
default:
205+
return createErrorResponse(
206+
requestId,
207+
"UNSUPPORTED_ACTION",
208+
`Unsupported cursor action: ${action}`,
209+
);
210+
}
211+
}
212+
213+
default:
214+
return createErrorResponse(
215+
requestId,
216+
"UNSUPPORTED_ACTION",
217+
`Unsupported bridge domain: ${domain}`,
218+
);
219+
}
220+
} catch (error) {
221+
return createErrorResponse(
222+
requestId,
223+
"INTERNAL_ERROR",
224+
error instanceof Error ? error.message : "Unknown native bridge error.",
225+
true,
226+
);
227+
}
228+
});
229+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type {
2+
CursorCapabilities,
3+
CursorProviderKind,
4+
CursorRecordingData,
5+
CursorTelemetryPoint,
6+
} from "../../../src/native/contracts";
7+
8+
export interface CursorTelemetryLoadResult {
9+
success: boolean;
10+
samples: CursorTelemetryPoint[];
11+
message?: string;
12+
error?: string;
13+
}
14+
15+
export interface CursorNativeAdapter {
16+
readonly kind: CursorProviderKind;
17+
getCapabilities(): Promise<CursorCapabilities>;
18+
getRecordingData(videoPath?: string | null): Promise<CursorRecordingData>;
19+
getTelemetry(videoPath?: string | null): Promise<CursorTelemetryLoadResult>;
20+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { CursorCapabilities, CursorRecordingData } from "../../../src/native/contracts";
2+
import type { CursorNativeAdapter, CursorTelemetryLoadResult } from "./adapter";
3+
4+
interface TelemetryCursorAdapterOptions {
5+
loadRecordingData: (videoPath: string) => Promise<CursorRecordingData>;
6+
resolveVideoPath: (videoPath?: string | null) => string | null;
7+
loadTelemetry: (videoPath: string) => Promise<CursorTelemetryLoadResult>;
8+
}
9+
10+
export class TelemetryCursorAdapter implements CursorNativeAdapter {
11+
readonly kind = "none" as const;
12+
13+
constructor(private readonly options: TelemetryCursorAdapterOptions) {}
14+
15+
async getCapabilities(): Promise<CursorCapabilities> {
16+
return {
17+
telemetry: true,
18+
systemAssets: false,
19+
provider: this.kind,
20+
};
21+
}
22+
23+
async getRecordingData(videoPath?: string | null): Promise<CursorRecordingData> {
24+
const resolvedVideoPath = this.options.resolveVideoPath(videoPath);
25+
if (!resolvedVideoPath) {
26+
return {
27+
version: 2,
28+
provider: this.kind,
29+
samples: [],
30+
assets: [],
31+
};
32+
}
33+
34+
return this.options.loadRecordingData(resolvedVideoPath);
35+
}
36+
37+
async getTelemetry(videoPath?: string | null) {
38+
const resolvedVideoPath = this.options.resolveVideoPath(videoPath);
39+
if (!resolvedVideoPath) {
40+
return {
41+
success: true,
42+
samples: [],
43+
} satisfies CursorTelemetryLoadResult;
44+
}
45+
46+
return this.options.loadTelemetry(resolvedVideoPath);
47+
}
48+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type {
2+
CursorCapabilities,
3+
CursorRecordingData,
4+
CursorTelemetryPoint,
5+
} from "../../../src/native/contracts";
6+
import type { CursorNativeAdapter } from "../cursor/adapter";
7+
import type { NativeBridgeStateStore } from "../store";
8+
9+
interface CursorServiceOptions {
10+
store: NativeBridgeStateStore;
11+
adapter: CursorNativeAdapter;
12+
}
13+
14+
export class CursorService {
15+
constructor(private readonly options: CursorServiceOptions) {}
16+
17+
async getCapabilities(): Promise<CursorCapabilities> {
18+
const capabilities = await this.options.adapter.getCapabilities();
19+
this.options.store.setCursorCapabilities(capabilities);
20+
return capabilities;
21+
}
22+
23+
async getTelemetry(videoPath?: string | null): Promise<CursorTelemetryPoint[]> {
24+
const result = await this.options.adapter.getTelemetry(videoPath);
25+
if (!result.success) {
26+
throw new Error(result.message || result.error || "Failed to load cursor telemetry");
27+
}
28+
29+
const resolvedVideoPath = videoPath ?? this.options.store.getState().project.currentVideoPath;
30+
if (resolvedVideoPath) {
31+
this.options.store.markCursorTelemetryLoaded(resolvedVideoPath, result.samples.length);
32+
}
33+
34+
return result.samples;
35+
}
36+
37+
async getRecordingData(videoPath?: string | null): Promise<CursorRecordingData> {
38+
const data = await this.options.adapter.getRecordingData(videoPath);
39+
const resolvedVideoPath = videoPath ?? this.options.store.getState().project.currentVideoPath;
40+
if (resolvedVideoPath) {
41+
this.options.store.markCursorTelemetryLoaded(resolvedVideoPath, data.samples.length);
42+
}
43+
44+
return data;
45+
}
46+
}

0 commit comments

Comments
 (0)