@covas-labs/electron-vr 0.0.0-bootstrap.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,73 @@
1
1
  # @covas-labs/electron-vr
2
2
 
3
- Bootstrap placeholder package for npm trusted publishing setup.
3
+ Electron-facing VR overlay bridge package for OpenXR or OpenVR overlays, with native mock preview fallback when no real XR runtime is usable.
4
+
5
+ Published Windows and Linux packages bundle the OpenVR runtime library they need, so consumers do not need to configure `OPENVR_SDK_DIR` for normal usage.
6
+
7
+ ## Install
8
+
9
+ This package is published on GitHub Packages.
10
+
11
+ ```ini
12
+ @covas-labs:registry=https://npm.pkg.github.com
13
+ //npm.pkg.github.com/:_authToken=${GITHUB_PACKAGES_TOKEN}
14
+ ```
15
+
16
+ ```bash
17
+ npm install @covas-labs/electron-vr
18
+ ```
19
+
20
+ ## Example
21
+
22
+ ```ts
23
+ import { app, BrowserWindow } from "electron";
24
+ import { VROverlay } from "@covas-labs/electron-vr";
25
+
26
+ let overlay: VROverlay | null = null;
27
+
28
+ app.on("ready", async () => {
29
+ const runtimeInfo = VROverlay.getRuntimeInfo();
30
+ if (!VROverlay.isAvailable(runtimeInfo)) {
31
+ return;
32
+ }
33
+
34
+ const window = new BrowserWindow({
35
+ width: 1280,
36
+ height: 720,
37
+ show: false,
38
+ frame: false,
39
+ transparent: true,
40
+ backgroundColor: "#00000000",
41
+ webPreferences: {
42
+ offscreen: {
43
+ useSharedTexture: true
44
+ },
45
+ contextIsolation: true,
46
+ nodeIntegration: false,
47
+ backgroundThrottling: false
48
+ }
49
+ });
50
+
51
+ await window.loadURL("file:///absolute/path/to/overlay.html");
52
+
53
+ overlay = await VROverlay.openWindow(window, {
54
+ name: "Status_HUD",
55
+ sizeMeters: 1,
56
+ placement: {
57
+ mode: "head",
58
+ position: { x: 0, y: 0, z: -1.2 },
59
+ rotation: { x: 0, y: 0, z: 0, w: 1 }
60
+ }
61
+ });
62
+ });
63
+ ```
64
+
65
+ You can also reposition the overlay later with `overlay.setPlacement(...)`, toggle it with `overlay.setVisible(...)`, and resize it in meters with `overlay.setSizeMeters(...)`.
66
+
67
+ `sizeMeters` must be greater than zero, and placement values should be finite numbers.
68
+
69
+ On Linux, runtime selection prefers `openxr`, then falls back to `openvr`, then to `mock`. Linux OpenVR is treated as a best-effort alternate backend when a compatible OpenVR runtime is installed but the OpenXR overlay path is unavailable or disabled. It is not currently validated end to end on the main development machine or in CI.
70
+
71
+ On Windows, runtime probing reports OpenXR overlay and D3D11 graphics-binding capability, but the default backend remains `openvr` during rollout. Set `ELECTRON_VR_ENABLE_OPENXR=1` to opt into the Windows OpenXR path when a compatible runtime exposes `XR_EXTX_overlay` and `XR_KHR_D3D11_enable`. This path is not currently validated end to end on the main development machine or in CI.
72
+
73
+ `getRuntimeInfo()` also includes `openvrRuntimeInstalled`, `openvrRuntimePath`, and platform-specific OpenXR capability details such as `openxrWindowsD3D11BindingAvailable`, so runtime diagnostics do not need to initialize OpenVR just to check availability. It now also surfaces live session/app details when the runtime exposes them, including `openxrSessionState`, `openxrSessionRunning`, and the current OpenVR scene app fields (`openvrSceneApplicationState`, `openvrSceneProcessId`, `openvrSceneApplicationKey`, `openvrSceneApplicationName`, `openvrSceneApplicationBinaryPath`). OpenXR does not expose a standard cross-runtime "current app" query, so those app-level fields are currently OpenVR-specific. `probeMode` includes the backend-selection decision as well, which makes fallback behavior easier to diagnose even when Linux OpenVR or Windows OpenXR cannot be validated on the current host.
@@ -0,0 +1,60 @@
1
+ import { BrowserWindow } from "electron";
2
+ import type { BrowserWindowConstructorOptions } from "electron";
3
+ import { type BackendKind, type RuntimeInfo, type Quat, type Vec3 } from "./bridge.js";
4
+ type OverlayWindowOptions = Omit<BrowserWindowConstructorOptions, "width" | "height" | "webPreferences"> & {
5
+ webPreferences?: BrowserWindowConstructorOptions["webPreferences"];
6
+ };
7
+ export interface VROverlayOptions {
8
+ name?: string;
9
+ width?: number;
10
+ height?: number;
11
+ url?: string;
12
+ frameRate?: number;
13
+ windowOptions?: OverlayWindowOptions;
14
+ sizeMeters?: number;
15
+ visible?: boolean;
16
+ placement?: {
17
+ mode: "head" | "world";
18
+ position?: Vec3;
19
+ rotation?: Quat;
20
+ };
21
+ }
22
+ export interface ExistingWindowVROverlayOptions {
23
+ name?: string;
24
+ frameRate?: number;
25
+ sizeMeters?: number;
26
+ visible?: boolean;
27
+ placement?: VROverlayOptions["placement"];
28
+ }
29
+ export declare class VROverlay {
30
+ readonly width: number;
31
+ readonly height: number;
32
+ readonly url: string;
33
+ readonly overlayName: string;
34
+ readonly frameRate: number;
35
+ readonly windowOptions?: VROverlayOptions["windowOptions"];
36
+ private sizeMeters;
37
+ private visible;
38
+ private placement;
39
+ private readonly vrBridge;
40
+ private window;
41
+ private ownsWindow;
42
+ private initialized;
43
+ constructor(options?: VROverlayOptions);
44
+ static getRuntimeInfo(): RuntimeInfo;
45
+ static isAvailable(runtimeInfo?: RuntimeInfo): boolean;
46
+ static hasRealVRRuntime(runtimeInfo?: RuntimeInfo): boolean;
47
+ static openWindow(window: BrowserWindow, options?: ExistingWindowVROverlayOptions): Promise<VROverlay | null>;
48
+ getRuntimeInfo(): RuntimeInfo;
49
+ getSelectedBackend(): BackendKind;
50
+ isInitialized(): boolean;
51
+ init(): Promise<boolean>;
52
+ initWithWindow(window: BrowserWindow): Promise<boolean>;
53
+ destroy(): void;
54
+ setPlacement(placement: VROverlayOptions["placement"]): boolean;
55
+ setVisible(visible: boolean): boolean;
56
+ setSizeMeters(sizeMeters: number): boolean;
57
+ private initializeBridgeForWindow;
58
+ private readonly onWindowClosed;
59
+ }
60
+ export {};
@@ -0,0 +1,199 @@
1
+ import { app, BrowserWindow } from "electron";
2
+ import { createVrBridge } from "./bridge.js";
3
+ import { assertSizeMeters, normalizePlacement } from "./overlayOptions.js";
4
+ function mergeWindowOptions(width, height, windowOptions) {
5
+ const requestedOffscreen = windowOptions?.webPreferences?.offscreen;
6
+ const normalizedOffscreen = typeof requestedOffscreen === "object"
7
+ ? { useSharedTexture: true, ...requestedOffscreen }
8
+ : { useSharedTexture: true };
9
+ return {
10
+ width,
11
+ height,
12
+ show: false,
13
+ frame: false,
14
+ transparent: true,
15
+ backgroundColor: "#00000000",
16
+ ...windowOptions,
17
+ webPreferences: {
18
+ offscreen: normalizedOffscreen,
19
+ contextIsolation: true,
20
+ nodeIntegration: false,
21
+ backgroundThrottling: false,
22
+ ...windowOptions?.webPreferences
23
+ }
24
+ };
25
+ }
26
+ function isOffscreenWindow(window) {
27
+ const webContents = window.webContents;
28
+ return typeof webContents.isOffscreen === "function" ? webContents.isOffscreen() : false;
29
+ }
30
+ export class VROverlay {
31
+ width;
32
+ height;
33
+ url;
34
+ overlayName;
35
+ frameRate;
36
+ windowOptions;
37
+ sizeMeters;
38
+ visible;
39
+ placement;
40
+ vrBridge = createVrBridge();
41
+ window = null;
42
+ ownsWindow = false;
43
+ initialized = false;
44
+ constructor(options = {}) {
45
+ this.width = options.width ?? 1024;
46
+ this.height = options.height ?? 1024;
47
+ this.url = options.url ?? "about:blank";
48
+ this.overlayName = options.name ?? "Electron_VR_Overlay";
49
+ this.frameRate = options.frameRate ?? 60;
50
+ this.windowOptions = options.windowOptions;
51
+ this.sizeMeters = options.sizeMeters ?? 1.0;
52
+ assertSizeMeters(this.sizeMeters);
53
+ this.visible = options.visible ?? true;
54
+ this.placement = normalizePlacement(options.placement);
55
+ }
56
+ static getRuntimeInfo() {
57
+ return createVrBridge().getRuntimeInfo();
58
+ }
59
+ static isAvailable(runtimeInfo = VROverlay.getRuntimeInfo()) {
60
+ return runtimeInfo.selectedBackend !== "none";
61
+ }
62
+ static hasRealVRRuntime(runtimeInfo = VROverlay.getRuntimeInfo()) {
63
+ return runtimeInfo.selectedBackend === "openxr" || runtimeInfo.selectedBackend === "openvr";
64
+ }
65
+ static async openWindow(window, options = {}) {
66
+ const [width, height] = window.getContentSize();
67
+ const overlay = new VROverlay({
68
+ name: options.name,
69
+ width,
70
+ height,
71
+ frameRate: options.frameRate,
72
+ sizeMeters: options.sizeMeters,
73
+ visible: options.visible,
74
+ placement: options.placement
75
+ });
76
+ return await overlay.initWithWindow(window) ? overlay : null;
77
+ }
78
+ getRuntimeInfo() {
79
+ return this.vrBridge.getRuntimeInfo();
80
+ }
81
+ getSelectedBackend() {
82
+ return this.vrBridge.getSelectedBackend();
83
+ }
84
+ isInitialized() {
85
+ return this.initialized && this.vrBridge.isInitialized();
86
+ }
87
+ async init() {
88
+ await app.whenReady();
89
+ if (this.initialized) {
90
+ return true;
91
+ }
92
+ const window = new BrowserWindow(mergeWindowOptions(this.width, this.height, this.windowOptions));
93
+ this.ownsWindow = true;
94
+ this.window = window;
95
+ window.once("closed", this.onWindowClosed);
96
+ const initialized = this.initializeBridgeForWindow(window, this.width, this.height);
97
+ if (!initialized) {
98
+ window.removeListener("closed", this.onWindowClosed);
99
+ this.window = null;
100
+ this.ownsWindow = false;
101
+ window.close();
102
+ return false;
103
+ }
104
+ await window.loadURL(this.url);
105
+ this.initialized = true;
106
+ return true;
107
+ }
108
+ async initWithWindow(window) {
109
+ await app.whenReady();
110
+ if (this.initialized) {
111
+ return true;
112
+ }
113
+ if (!isOffscreenWindow(window)) {
114
+ console.error("Existing BrowserWindow is not offscreen-enabled. Create it with offscreen rendering enabled before moving it into VR.");
115
+ return false;
116
+ }
117
+ const [width, height] = window.getContentSize();
118
+ this.window = window;
119
+ this.ownsWindow = false;
120
+ window.once("closed", this.onWindowClosed);
121
+ const initialized = this.initializeBridgeForWindow(window, width, height);
122
+ if (!initialized) {
123
+ window.removeListener("closed", this.onWindowClosed);
124
+ this.window = null;
125
+ return false;
126
+ }
127
+ this.initialized = true;
128
+ return true;
129
+ }
130
+ destroy() {
131
+ this.vrBridge.shutdown();
132
+ if (this.window) {
133
+ this.window.removeListener("closed", this.onWindowClosed);
134
+ if (this.ownsWindow && !this.window.isDestroyed()) {
135
+ this.window.close();
136
+ }
137
+ this.window = null;
138
+ }
139
+ this.ownsWindow = false;
140
+ this.initialized = false;
141
+ }
142
+ setPlacement(placement) {
143
+ const normalizedPlacement = normalizePlacement(placement);
144
+ this.placement = normalizedPlacement;
145
+ if (!this.isInitialized()) {
146
+ return true;
147
+ }
148
+ const success = this.vrBridge.setOverlayPlacement(normalizedPlacement);
149
+ if (!success) {
150
+ console.error("Failed to update VR overlay placement:", this.vrBridge.getLastError());
151
+ }
152
+ return success;
153
+ }
154
+ setVisible(visible) {
155
+ this.visible = visible;
156
+ if (!this.isInitialized()) {
157
+ return true;
158
+ }
159
+ const success = this.vrBridge.setOverlayVisible(visible);
160
+ if (!success) {
161
+ console.error("Failed to update VR overlay visibility:", this.vrBridge.getLastError());
162
+ }
163
+ return success;
164
+ }
165
+ setSizeMeters(sizeMeters) {
166
+ assertSizeMeters(sizeMeters);
167
+ this.sizeMeters = sizeMeters;
168
+ if (!this.isInitialized()) {
169
+ return true;
170
+ }
171
+ const success = this.vrBridge.setOverlaySizeMeters(sizeMeters);
172
+ if (!success) {
173
+ console.error("Failed to update VR overlay size:", this.vrBridge.getLastError());
174
+ }
175
+ return success;
176
+ }
177
+ initializeBridgeForWindow(window, width, height) {
178
+ const initialized = this.vrBridge.initialize({
179
+ name: this.overlayName,
180
+ width,
181
+ height,
182
+ sizeMeters: this.sizeMeters,
183
+ visible: this.visible,
184
+ placement: this.placement
185
+ });
186
+ if (!initialized) {
187
+ console.error("Failed to initialize VR bridge:", this.vrBridge.getLastError());
188
+ return false;
189
+ }
190
+ this.vrBridge.attachWindow(window, { frameRate: this.frameRate });
191
+ return true;
192
+ }
193
+ onWindowClosed = () => {
194
+ this.vrBridge.shutdown();
195
+ this.window = null;
196
+ this.ownsWindow = false;
197
+ this.initialized = false;
198
+ };
199
+ }
@@ -0,0 +1,116 @@
1
+ import type { BrowserWindow } from "electron";
2
+ export type BackendKind = "none" | "openxr" | "openvr" | "mock";
3
+ export interface RuntimeInfo {
4
+ platform: string;
5
+ probeMode: string;
6
+ openxrAvailable: boolean;
7
+ openxrOverlayExtensionAvailable: boolean;
8
+ openxrLinuxEglBindingAvailable: boolean;
9
+ openxrWindowsD3D11BindingAvailable: boolean;
10
+ openxrRuntimeName: string;
11
+ openxrRuntimeManifestPath: string;
12
+ openxrRuntimeLibraryPath: string;
13
+ openxrLoaderPath: string;
14
+ openxrSessionState: string;
15
+ openxrSessionRunning: boolean;
16
+ openvrAvailable: boolean;
17
+ openvrRuntimeInstalled: boolean;
18
+ openvrRuntimePath: string;
19
+ openvrSceneApplicationState: string;
20
+ openvrSceneProcessId: number;
21
+ openvrSceneApplicationKey: string;
22
+ openvrSceneApplicationName: string;
23
+ openvrSceneApplicationBinaryPath: string;
24
+ selectedBackend: BackendKind;
25
+ }
26
+ export interface Vec3 {
27
+ x: number;
28
+ y: number;
29
+ z: number;
30
+ }
31
+ export interface Quat {
32
+ x: number;
33
+ y: number;
34
+ z: number;
35
+ w: number;
36
+ }
37
+ export type PlacementMode = "head" | "world";
38
+ export interface OverlayPlacement {
39
+ mode: PlacementMode;
40
+ position: Vec3;
41
+ rotation: Quat;
42
+ }
43
+ export interface InitializeVROptions {
44
+ name: string;
45
+ width: number;
46
+ height: number;
47
+ sizeMeters: number;
48
+ visible: boolean;
49
+ placement: OverlayPlacement;
50
+ }
51
+ export interface LinuxTexturePlane {
52
+ fd: number;
53
+ stride: number;
54
+ offset: number;
55
+ size: number;
56
+ }
57
+ export interface LinuxTextureInfo {
58
+ codedSize?: {
59
+ width: number;
60
+ height: number;
61
+ };
62
+ pixelFormat?: "rgba" | "bgra";
63
+ modifier?: string;
64
+ planes?: LinuxTexturePlane[];
65
+ }
66
+ export interface SoftwareFrameInfo {
67
+ width: number;
68
+ height: number;
69
+ rgbaPixels: Buffer;
70
+ }
71
+ export interface SharedTextureInfo extends LinuxTextureInfo {
72
+ sharedTextureHandle?: Buffer;
73
+ }
74
+ export interface SharedTexturePayload {
75
+ textureInfo?: SharedTextureInfo;
76
+ release(): void;
77
+ }
78
+ export interface NativeImageLike {
79
+ getSize(): {
80
+ width: number;
81
+ height: number;
82
+ };
83
+ toBitmap(): Buffer;
84
+ }
85
+ export interface AttachWindowOptions {
86
+ frameRate?: number;
87
+ }
88
+ export declare class VrBridge {
89
+ private readonly addon;
90
+ private attachedWindow;
91
+ private warnedAboutMissingSharedTexture;
92
+ private warnedAboutSoftwareFallback;
93
+ private warnedAboutWindowsSoftwareFallback;
94
+ private loggedFirstPaint;
95
+ private loggedFirstWindowsHandle;
96
+ private loggedFirstWindowsSubmit;
97
+ private loggedFirstWindowsSoftwareSubmit;
98
+ private windowsReadbackInFlight;
99
+ private windowsReadbackPending;
100
+ attachWindow(window: BrowserWindow, options?: AttachWindowOptions): void;
101
+ detachWindow(): void;
102
+ getRuntimeInfo(): RuntimeInfo;
103
+ getSelectedBackend(): BackendKind;
104
+ initialize(options: InitializeVROptions): boolean;
105
+ setOverlayPlacement(placement: OverlayPlacement): boolean;
106
+ setOverlayVisible(visible: boolean): boolean;
107
+ setOverlaySizeMeters(sizeMeters: number): boolean;
108
+ shutdown(): void;
109
+ isInitialized(): boolean;
110
+ getLastError(): string | null;
111
+ private submitWindowsSoftwareFrame;
112
+ private useWindowsSoftwareFallback;
113
+ private scheduleWindowsCaptureFallback;
114
+ private readonly onPaint;
115
+ }
116
+ export declare function createVrBridge(): VrBridge;
package/dist/bridge.js ADDED
@@ -0,0 +1,370 @@
1
+ import { createRequire } from "node:module";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ let cachedAddon = null;
6
+ const PREBUILT_ADDON_PACKAGES = {
7
+ linux: {
8
+ x64: "@covas-labs/electron-vr-prebuilt-linux-x64"
9
+ },
10
+ win32: {
11
+ x64: "@covas-labs/electron-vr-prebuilt-win32-x64"
12
+ }
13
+ };
14
+ function resolvePrebuiltPackageName() {
15
+ const packagesForPlatform = PREBUILT_ADDON_PACKAGES[process.platform];
16
+ if (!packagesForPlatform) {
17
+ return null;
18
+ }
19
+ return packagesForPlatform[process.arch] ?? null;
20
+ }
21
+ function prependLibrarySearchPath(directory) {
22
+ if (!existsSync(directory)) {
23
+ return;
24
+ }
25
+ if (process.platform === "win32") {
26
+ const currentPath = process.env.PATH ?? "";
27
+ const pathEntries = currentPath.split(";").filter(Boolean);
28
+ if (!pathEntries.includes(directory)) {
29
+ process.env.PATH = currentPath ? `${directory};${currentPath}` : directory;
30
+ }
31
+ return;
32
+ }
33
+ if (process.platform === "linux") {
34
+ const currentLdLibraryPath = process.env.LD_LIBRARY_PATH ?? "";
35
+ const pathEntries = currentLdLibraryPath.split(":").filter(Boolean);
36
+ if (!pathEntries.includes(directory)) {
37
+ process.env.LD_LIBRARY_PATH = currentLdLibraryPath ? `${directory}:${currentLdLibraryPath}` : directory;
38
+ }
39
+ return;
40
+ }
41
+ }
42
+ function ensureOpenVRLibraryPath(currentDir) {
43
+ const localAddonDir = resolve(currentDir, "..", "..", "native-addon", "build", "Release");
44
+ prependLibrarySearchPath(localAddonDir);
45
+ const prebuiltPackageName = resolvePrebuiltPackageName();
46
+ if (prebuiltPackageName) {
47
+ const require = createRequire(import.meta.url);
48
+ try {
49
+ const prebuiltEntry = require.resolve(prebuiltPackageName);
50
+ prependLibrarySearchPath(dirname(prebuiltEntry));
51
+ }
52
+ catch {
53
+ // Ignore and fall back to application resolution or SDK lookup.
54
+ }
55
+ try {
56
+ const applicationRequire = createRequire(resolve(process.cwd(), "package.json"));
57
+ const prebuiltEntry = applicationRequire.resolve(prebuiltPackageName);
58
+ prependLibrarySearchPath(dirname(prebuiltEntry));
59
+ }
60
+ catch {
61
+ // Ignore and fall back to SDK lookup.
62
+ }
63
+ }
64
+ const sdkDir = process.env.OPENVR_SDK_DIR;
65
+ if (!sdkDir) {
66
+ return;
67
+ }
68
+ if (process.platform === "win32") {
69
+ prependLibrarySearchPath(resolve(sdkDir, "bin", "win64"));
70
+ }
71
+ else if (process.platform === "linux") {
72
+ prependLibrarySearchPath(resolve(sdkDir, "lib", "linux64"));
73
+ }
74
+ }
75
+ function loadVrBridgeAddon() {
76
+ if (cachedAddon) {
77
+ return cachedAddon;
78
+ }
79
+ const require = createRequire(import.meta.url);
80
+ const applicationRequire = createRequire(resolve(process.cwd(), "package.json"));
81
+ const currentDir = dirname(fileURLToPath(import.meta.url));
82
+ const localAddonPath = resolve(currentDir, "..", "..", "native-addon", "build", "Release", "vr_bridge.node");
83
+ ensureOpenVRLibraryPath(currentDir);
84
+ try {
85
+ cachedAddon = require(localAddonPath);
86
+ return cachedAddon;
87
+ }
88
+ catch (localError) {
89
+ const prebuiltPackageName = resolvePrebuiltPackageName();
90
+ if (!prebuiltPackageName) {
91
+ throw new Error(`No supported prebuilt Electron addon is available for ${process.platform}-${process.arch}. Local addon load failed: ${String(localError)}`);
92
+ }
93
+ try {
94
+ cachedAddon = require(prebuiltPackageName);
95
+ return cachedAddon;
96
+ }
97
+ catch (packageRequireError) {
98
+ try {
99
+ cachedAddon = applicationRequire(prebuiltPackageName);
100
+ return cachedAddon;
101
+ }
102
+ catch (applicationRequireError) {
103
+ throw new Error(`Failed to load the Electron VR addon from ${localAddonPath} or ${prebuiltPackageName}. Local error: ${String(localError)}. Package-local prebuilt error: ${String(packageRequireError)}. Application-level prebuilt error: ${String(applicationRequireError)}`);
104
+ }
105
+ }
106
+ }
107
+ }
108
+ function sanitizeRuntimeInfo(runtimeInfo) {
109
+ return {
110
+ ...runtimeInfo,
111
+ openxrSessionState: runtimeInfo.openxrSessionState ?? "unknown",
112
+ openxrSessionRunning: runtimeInfo.openxrSessionRunning ?? false,
113
+ openvrSceneApplicationState: runtimeInfo.openvrSceneApplicationState ?? "",
114
+ openvrSceneProcessId: runtimeInfo.openvrSceneProcessId ?? 0,
115
+ openvrSceneApplicationKey: runtimeInfo.openvrSceneApplicationKey ?? "",
116
+ openvrSceneApplicationName: runtimeInfo.openvrSceneApplicationName ?? "",
117
+ openvrSceneApplicationBinaryPath: runtimeInfo.openvrSceneApplicationBinaryPath ?? ""
118
+ };
119
+ }
120
+ function isSharedTexturePayload(value) {
121
+ if (!value || typeof value !== "object") {
122
+ return false;
123
+ }
124
+ const candidate = value;
125
+ return typeof candidate.release === "function" || typeof candidate.textureInfo === "object";
126
+ }
127
+ function isNativeImageLike(value) {
128
+ if (!value || typeof value !== "object") {
129
+ return false;
130
+ }
131
+ const candidate = value;
132
+ return typeof candidate.getSize === "function" && typeof candidate.toBitmap === "function";
133
+ }
134
+ function bgraToRgba(source) {
135
+ const result = Buffer.allocUnsafe(source.length);
136
+ for (let index = 0; index < source.length; index += 4) {
137
+ result[index] = source[index + 2];
138
+ result[index + 1] = source[index + 1];
139
+ result[index + 2] = source[index];
140
+ result[index + 3] = source[index + 3];
141
+ }
142
+ return result;
143
+ }
144
+ function releaseTexture(texture) {
145
+ if (texture && typeof texture.release === "function") {
146
+ texture.release();
147
+ }
148
+ }
149
+ function cloneLinuxTextureInfo(textureInfo) {
150
+ return {
151
+ codedSize: textureInfo.codedSize
152
+ ? {
153
+ width: textureInfo.codedSize.width,
154
+ height: textureInfo.codedSize.height
155
+ }
156
+ : undefined,
157
+ pixelFormat: textureInfo.pixelFormat,
158
+ modifier: textureInfo.modifier,
159
+ planes: textureInfo.planes?.map((plane) => ({
160
+ fd: plane.fd,
161
+ stride: plane.stride,
162
+ offset: plane.offset,
163
+ size: plane.size
164
+ }))
165
+ };
166
+ }
167
+ export class VrBridge {
168
+ addon = loadVrBridgeAddon();
169
+ attachedWindow = null;
170
+ warnedAboutMissingSharedTexture = false;
171
+ warnedAboutSoftwareFallback = false;
172
+ warnedAboutWindowsSoftwareFallback = false;
173
+ loggedFirstPaint = false;
174
+ loggedFirstWindowsHandle = false;
175
+ loggedFirstWindowsSubmit = false;
176
+ loggedFirstWindowsSoftwareSubmit = false;
177
+ windowsReadbackInFlight = false;
178
+ windowsReadbackPending = false;
179
+ attachWindow(window, options = {}) {
180
+ this.detachWindow();
181
+ const offscreenContents = window.webContents;
182
+ offscreenContents.setFrameRate(options.frameRate ?? 60);
183
+ offscreenContents.on("paint", this.onPaint);
184
+ this.attachedWindow = window;
185
+ }
186
+ detachWindow() {
187
+ if (!this.attachedWindow) {
188
+ return;
189
+ }
190
+ const offscreenContents = this.attachedWindow.webContents;
191
+ offscreenContents.removeListener("paint", this.onPaint);
192
+ this.attachedWindow = null;
193
+ }
194
+ getRuntimeInfo() {
195
+ return sanitizeRuntimeInfo(this.addon.getRuntimeInfo());
196
+ }
197
+ getSelectedBackend() {
198
+ return this.getRuntimeInfo().selectedBackend;
199
+ }
200
+ initialize(options) {
201
+ return this.addon.initializeVR(options);
202
+ }
203
+ setOverlayPlacement(placement) {
204
+ return this.addon.setOverlayPlacement(placement);
205
+ }
206
+ setOverlayVisible(visible) {
207
+ return this.addon.setOverlayVisible(visible);
208
+ }
209
+ setOverlaySizeMeters(sizeMeters) {
210
+ return this.addon.setOverlaySizeMeters(sizeMeters);
211
+ }
212
+ shutdown() {
213
+ this.detachWindow();
214
+ this.addon.shutdownVR();
215
+ }
216
+ isInitialized() {
217
+ return this.addon.isInitialized();
218
+ }
219
+ getLastError() {
220
+ return this.addon.getLastError();
221
+ }
222
+ submitWindowsSoftwareFrame(nativeImage) {
223
+ const { width, height } = nativeImage.getSize();
224
+ const bitmap = nativeImage.toBitmap();
225
+ if (width === 0 || height === 0 || bitmap.length === 0) {
226
+ console.warn(`Windows software frame readback was empty (width=${width}, height=${height}, bytes=${bitmap.length}).`);
227
+ return false;
228
+ }
229
+ const submitted = this.addon.submitSoftwareFrame({
230
+ width,
231
+ height,
232
+ rgbaPixels: bgraToRgba(bitmap)
233
+ });
234
+ if (!submitted) {
235
+ console.error("Failed to submit Windows software frame to VR bridge:", this.addon.getLastError());
236
+ return false;
237
+ }
238
+ if (!this.loggedFirstWindowsSoftwareSubmit) {
239
+ this.loggedFirstWindowsSoftwareSubmit = true;
240
+ console.log("VR overlay submitted first Windows software frame to the native bridge.");
241
+ }
242
+ return true;
243
+ }
244
+ useWindowsSoftwareFallback(nativeImage, warning) {
245
+ if (!this.warnedAboutWindowsSoftwareFallback) {
246
+ this.warnedAboutWindowsSoftwareFallback = true;
247
+ console.warn(warning);
248
+ }
249
+ if (nativeImage && this.submitWindowsSoftwareFrame(nativeImage)) {
250
+ return;
251
+ }
252
+ this.scheduleWindowsCaptureFallback();
253
+ }
254
+ scheduleWindowsCaptureFallback() {
255
+ if (!this.attachedWindow) {
256
+ return;
257
+ }
258
+ if (this.windowsReadbackInFlight) {
259
+ this.windowsReadbackPending = true;
260
+ return;
261
+ }
262
+ this.windowsReadbackInFlight = true;
263
+ const window = this.attachedWindow;
264
+ const offscreenContents = window.webContents;
265
+ const [width, height] = window.getContentSize();
266
+ void offscreenContents.capturePage({ x: 0, y: 0, width, height })
267
+ .then((image) => {
268
+ this.submitWindowsSoftwareFrame(image);
269
+ })
270
+ .catch((error) => {
271
+ console.error("Failed to capture Windows software fallback frame:", error);
272
+ })
273
+ .finally(() => {
274
+ this.windowsReadbackInFlight = false;
275
+ if (this.windowsReadbackPending) {
276
+ this.windowsReadbackPending = false;
277
+ this.scheduleWindowsCaptureFallback();
278
+ }
279
+ });
280
+ }
281
+ onPaint = (event, _dirty, paintResult) => {
282
+ const nativeImage = isNativeImageLike(paintResult) ? paintResult : null;
283
+ const texture = isSharedTexturePayload(event.texture)
284
+ ? event.texture
285
+ : isSharedTexturePayload(paintResult)
286
+ ? paintResult
287
+ : null;
288
+ if (!this.loggedFirstPaint) {
289
+ this.loggedFirstPaint = true;
290
+ console.log(`VR overlay received first paint event (platform=${process.platform}, backend=${this.getSelectedBackend()}, hasTexture=${texture ? "yes" : "no"}, hasBitmap=${nativeImage ? "yes" : "no"}).`);
291
+ }
292
+ if (!texture) {
293
+ if (process.platform === "win32" && this.getSelectedBackend() === "openvr") {
294
+ this.useWindowsSoftwareFallback(nativeImage, "Windows shared texture payload was unavailable; falling back to software RGBA upload.");
295
+ return;
296
+ }
297
+ if (this.getSelectedBackend() === "mock" && nativeImage) {
298
+ const { width, height } = nativeImage.getSize();
299
+ this.addon.submitSoftwareFrame({
300
+ width,
301
+ height,
302
+ rgbaPixels: bgraToRgba(nativeImage.toBitmap())
303
+ });
304
+ if (!this.warnedAboutSoftwareFallback) {
305
+ this.warnedAboutSoftwareFallback = true;
306
+ console.warn("Shared texture payload was unavailable; using software bitmap upload for mock preview.");
307
+ }
308
+ return;
309
+ }
310
+ if (!this.warnedAboutMissingSharedTexture) {
311
+ this.warnedAboutMissingSharedTexture = true;
312
+ console.warn("Shared texture payload was unavailable on paint; Electron provided a bitmap instead.");
313
+ }
314
+ return;
315
+ }
316
+ try {
317
+ const textureInfo = texture.textureInfo;
318
+ const handle = textureInfo?.sharedTextureHandle;
319
+ if (process.platform === "win32") {
320
+ if (Buffer.isBuffer(handle)) {
321
+ if (!this.loggedFirstWindowsHandle) {
322
+ this.loggedFirstWindowsHandle = true;
323
+ console.log(`VR overlay received Windows shared texture handle (${handle.byteLength} bytes).`);
324
+ }
325
+ const submitted = this.addon.submitSharedTexture(handle);
326
+ if (!submitted) {
327
+ console.error("Failed to submit Windows frame to VR bridge:", this.addon.getLastError());
328
+ if (this.getSelectedBackend() === "openvr") {
329
+ this.useWindowsSoftwareFallback(nativeImage, "Windows shared texture submission failed; falling back to software RGBA upload.");
330
+ }
331
+ }
332
+ else if (!this.loggedFirstWindowsSubmit) {
333
+ this.loggedFirstWindowsSubmit = true;
334
+ console.log("VR overlay submitted first Windows frame to the native bridge.");
335
+ }
336
+ }
337
+ else if (this.getSelectedBackend() === "openvr") {
338
+ this.useWindowsSoftwareFallback(nativeImage, "Windows shared texture handle was unavailable; falling back to software RGBA upload.");
339
+ }
340
+ else if (!this.warnedAboutMissingSharedTexture) {
341
+ this.warnedAboutMissingSharedTexture = true;
342
+ console.warn("Shared texture handle was unavailable on Windows; VR submission was skipped.");
343
+ }
344
+ return;
345
+ }
346
+ if (process.platform === "linux") {
347
+ if (textureInfo?.planes?.length) {
348
+ const textureInfoSnapshot = cloneLinuxTextureInfo(textureInfo);
349
+ const submitted = this.addon.submitSharedTexture(textureInfoSnapshot);
350
+ if (!submitted) {
351
+ console.error("Failed to submit Linux frame to VR bridge:", this.addon.getLastError());
352
+ }
353
+ }
354
+ else if (!this.warnedAboutMissingSharedTexture) {
355
+ this.warnedAboutMissingSharedTexture = true;
356
+ console.warn("Shared texture metadata was unavailable on paint; preview submission was skipped.");
357
+ }
358
+ }
359
+ }
360
+ catch (error) {
361
+ console.error("Error while forwarding frame to VR bridge:", error);
362
+ }
363
+ finally {
364
+ releaseTexture(texture);
365
+ }
366
+ };
367
+ }
368
+ export function createVrBridge() {
369
+ return new VrBridge();
370
+ }
@@ -0,0 +1,2 @@
1
+ export { VrBridge, createVrBridge, type AttachWindowOptions, type BackendKind, type InitializeVROptions, type OverlayPlacement, type Quat, type RuntimeInfo, type Vec3 } from "./bridge.js";
2
+ export { VROverlay, type ExistingWindowVROverlayOptions, type VROverlayOptions } from "./VROverlay.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { VrBridge, createVrBridge } from "./bridge.js";
2
+ export { VROverlay } from "./VROverlay.js";
@@ -0,0 +1,11 @@
1
+ import type { Quat, OverlayPlacement, Vec3 } from "./bridge.js";
2
+ export interface OverlayPlacementInput {
3
+ mode: "head" | "world";
4
+ position?: Vec3;
5
+ rotation?: Quat;
6
+ }
7
+ export declare function isFiniteNumber(value: number): boolean;
8
+ export declare function assertVec3(value: Vec3, context: string): void;
9
+ export declare function assertQuat(value: Quat, context: string): void;
10
+ export declare function assertSizeMeters(sizeMeters: number): void;
11
+ export declare function normalizePlacement(placement?: OverlayPlacementInput): OverlayPlacement;
@@ -0,0 +1,42 @@
1
+ export function isFiniteNumber(value) {
2
+ return Number.isFinite(value);
3
+ }
4
+ export function assertVec3(value, context) {
5
+ if (!isFiniteNumber(value.x) || !isFiniteNumber(value.y) || !isFiniteNumber(value.z)) {
6
+ throw new TypeError(`${context} must contain finite x, y, and z values.`);
7
+ }
8
+ }
9
+ export function assertQuat(value, context) {
10
+ if (!isFiniteNumber(value.x) || !isFiniteNumber(value.y) || !isFiniteNumber(value.z) || !isFiniteNumber(value.w)) {
11
+ throw new TypeError(`${context} must contain finite x, y, z, and w values.`);
12
+ }
13
+ }
14
+ export function assertSizeMeters(sizeMeters) {
15
+ if (!isFiniteNumber(sizeMeters) || sizeMeters <= 0) {
16
+ throw new RangeError("sizeMeters must be a finite number greater than zero.");
17
+ }
18
+ }
19
+ export function normalizePlacement(placement) {
20
+ const mode = placement?.mode ?? "head";
21
+ if (mode !== "head" && mode !== "world") {
22
+ throw new RangeError("placement.mode must be 'head' or 'world'.");
23
+ }
24
+ const position = {
25
+ x: placement?.position?.x ?? 0,
26
+ y: placement?.position?.y ?? 0,
27
+ z: placement?.position?.z ?? -1.2
28
+ };
29
+ const rotation = {
30
+ x: placement?.rotation?.x ?? 0,
31
+ y: placement?.rotation?.y ?? 0,
32
+ z: placement?.rotation?.z ?? 0,
33
+ w: placement?.rotation?.w ?? 1
34
+ };
35
+ assertVec3(position, "placement.position");
36
+ assertQuat(rotation, "placement.rotation");
37
+ return {
38
+ mode,
39
+ position,
40
+ rotation
41
+ };
42
+ }
package/package.json CHANGED
@@ -1,22 +1,29 @@
1
1
  {
2
2
  "name": "@covas-labs/electron-vr",
3
- "version": "0.0.0-bootstrap.0",
4
- "description": "Bootstrap placeholder package for npm trusted publishing setup.",
3
+ "version": "0.3.2",
4
+ "description": "Electron VR overlay bridge with OpenXR, OpenVR, and mock fallback backends.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/COVAS-Labs/electron-vr.git"
8
8
  },
9
- "main": "index.js",
9
+ "type": "module",
10
+ "main": "dist/index.js",
11
+ "types": "dist/index.d.ts",
10
12
  "exports": {
11
- ".": "./index.js"
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
12
17
  },
13
18
  "files": [
14
- "index.js",
15
- "README.md"
19
+ "dist"
16
20
  ],
21
+ "peerDependencies": {
22
+ "electron": ">=37"
23
+ },
17
24
  "optionalDependencies": {
18
- "@covas-labs/electron-vr-prebuilt-linux-x64": "0.0.0-bootstrap.0",
19
- "@covas-labs/electron-vr-prebuilt-win32-x64": "0.0.0-bootstrap.0"
25
+ "@covas-labs/electron-vr-prebuilt-linux-x64": "0.3.2",
26
+ "@covas-labs/electron-vr-prebuilt-win32-x64": "0.3.2"
20
27
  },
21
28
  "publishConfig": {
22
29
  "registry": "https://registry.npmjs.org",
package/index.js DELETED
@@ -1,4 +0,0 @@
1
- module.exports = {
2
- bootstrap: true,
3
- message: "Bootstrap placeholder package. Configure trusted publishing, then publish a real tagged release."
4
- };