// SPDX-License-Identifier: AGPL-3.0-or-later const {EventEmitter} = require('node:events'); const {existsSync} = require('node:fs'); const {delimiter, join, sep} = require('node:path'); const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs'); const MODULE_NAME = '@fluxer/linux-screen-capture'; const SKIP_NATIVE_PROBE_ENV = 'FLUXER_LINUX_SCREEN_CAPTURE_SKIP_NATIVE_PROBE'; const OBS_VKCAPTURE_LAYER_NAME = 'VK_LAYER_OBS_vkcapture_64'; const DEFAULT_NVIDIA_VULKAN_ICD = '/usr/share/vulkan/icd.d/nvidia_icd.json'; function resolveNativeRoot() { const asarSegment = `${sep}app.asar${sep}`; if (!__dirname.includes(asarSegment)) return __dirname; const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`); return existsSync(unpackedDir) ? unpackedDir : __dirname; } function nativeFileName() { if (process.platform !== 'linux') { throw new Error(`@fluxer/linux-screen-capture is only supported on Linux, got ${process.platform}`); } switch (process.arch) { case 'x64': return 'linux-screen-capture.linux-x64-gnu.node'; case 'arm64': return 'linux-screen-capture.linux-arm64-gnu.node'; default: throw new Error(`Unsupported Linux architecture: ${process.arch}`); } } let binding = null; let loadError = null; if (process.platform === 'linux') { try { const nativeRoot = resolveNativeRoot(); const nativePath = join(nativeRoot, nativeFileName()); const loaded = loadNativeBinding({ moduleName: MODULE_NAME, nativePath, nativeRoot, packageDir: __dirname, skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV, }); binding = loaded.binding; loadError = loaded.loadError; if (loadError) throw loadError; } catch (error) { loadError = createNativeLoadError({ moduleName: MODULE_NAME, nativeRoot: resolveNativeRoot(), packageDir: __dirname, reason: 'native loader threw before binding load completed', cause: error, skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV, }); throw loadError; } } function getBackendInfo() { if (!binding) { return { backend: 'linux-pipewire-portal', supported: false, reason: process.platform === 'linux' ? `@fluxer/linux-screen-capture native binary unavailable: ${loadError?.message ?? 'unknown reason'}` : `@fluxer/linux-screen-capture is only supported on Linux, got ${process.platform}`, portalVersion: undefined, pipewireReachable: false, }; } return binding.getBackendInfo(); } function getAvailability() { if (!binding) { return Promise.resolve({ available: false, backend: 'linux-pipewire-portal', reason: 'unsupported-platform', capabilities: {process: false, system: false}, }); } return binding.getAvailability(); } function listSources() { if (!binding) return Promise.resolve([]); return binding.listSources(); } function prependPathEnv(current, next) { if (!next) return current; if (!current) return next; const parts = current.split(delimiter).filter(Boolean); return parts.includes(next) ? current : `${next}${delimiter}${current}`; } function appendColonEnv(current, next) { if (!next) return current; if (!current) return next; const parts = current.split(':').filter(Boolean); return parts.includes(next) ? current : `${current}:${next}`; } function bundledObsVkcaptureRoots(nativeRoot) { return [join(nativeRoot, 'obs-vkcapture'), join(nativeRoot, 'game-capture', 'obs-vkcapture')]; } function resolveBundledVulkanLayerDir(nativeRoot) { for (const root of bundledObsVkcaptureRoots(nativeRoot)) { const jsonPath = join(root, 'obs_vkcapture_64.json'); const layerPath = join(root, 'libVkLayer_obs_vkcapture.so'); if (existsSync(jsonPath) && existsSync(layerPath)) return root; const vulkanRoot = join(root, 'vulkan'); const vulkanJsonPath = join(vulkanRoot, 'obs_vkcapture_64.json'); const vulkanLayerPath = join(vulkanRoot, 'libVkLayer_obs_vkcapture.so'); if (existsSync(vulkanJsonPath) && existsSync(vulkanLayerPath)) return vulkanRoot; } return null; } function resolveBundledGlCaptureLib(nativeRoot) { for (const root of bundledObsVkcaptureRoots(nativeRoot)) { const candidates = [ join(root, 'obs_glcapture', 'libobs_glcapture.so'), join(root, 'opengl', 'libobs_glcapture.so'), join(root, 'libobs_glcapture.so'), ]; for (const candidate of candidates) { if (existsSync(candidate)) return candidate; } } return null; } function resolveSystemVulkanLayerManifest() { const candidates = [ '/usr/share/vulkan/implicit_layer.d/obs_vkcapture_64.json', '/usr/local/share/vulkan/implicit_layer.d/obs_vkcapture_64.json', ]; return candidates.find((candidate) => existsSync(candidate)) ?? null; } function resolveSystemGlCaptureLib() { const candidates = [ '/usr/lib/obs_glcapture/libobs_glcapture.so', '/usr/lib64/obs_glcapture/libobs_glcapture.so', '/usr/local/lib/obs_glcapture/libobs_glcapture.so', '/usr/lib/x86_64-linux-gnu/obs_glcapture/libobs_glcapture.so', '/usr/lib/aarch64-linux-gnu/obs_glcapture/libobs_glcapture.so', ]; return candidates.find((candidate) => existsSync(candidate)) ?? null; } function resolveNvidiaIcdPath(forceNvidiaIcd) { if (typeof forceNvidiaIcd === 'string' && forceNvidiaIcd.length > 0) return forceNvidiaIcd; if (forceNvidiaIcd !== true) return null; return existsSync(DEFAULT_NVIDIA_VULKAN_ICD) ? DEFAULT_NVIDIA_VULKAN_ICD : null; } function addDiscreteGpuLaunchEnv(env, options = {}) { env.DRI_PRIME = env.DRI_PRIME || '1'; env.__NV_PRIME_RENDER_OFFLOAD = '1'; env.__VK_LAYER_NV_optimus = 'NVIDIA_only'; env.__GLX_VENDOR_LIBRARY_NAME = 'nvidia'; const nvidiaIcdPath = resolveNvidiaIcdPath(options.forceNvidiaIcd); if (nvidiaIcdPath) env.VK_ICD_FILENAMES = nvidiaIcdPath; return nvidiaIcdPath; } function getGameCaptureLaunchEnvironment(options = {}) { const baseEnv = options.env ?? process.env; const nativeRoot = options.nativeRoot ?? resolveNativeRoot(); const mode = options.mode === 'vulkan' || options.mode === 'opengl' ? options.mode : 'auto'; const env = {...baseEnv, OBS_VKCAPTURE: '1'}; if (options.name) env.OBS_VKCAPTURE_NAME = String(options.name); const nvidiaIcdPath = options.preferDiscreteGpu ? addDiscreteGpuLaunchEnv(env, options) : null; const bundledVulkanLayerDir = resolveBundledVulkanLayerDir(nativeRoot); const systemVulkanLayerManifest = resolveSystemVulkanLayerManifest(); if (mode !== 'opengl' && bundledVulkanLayerDir) { env.VK_ADD_LAYER_PATH = prependPathEnv(env.VK_ADD_LAYER_PATH, bundledVulkanLayerDir); env.VK_INSTANCE_LAYERS = appendColonEnv(env.VK_INSTANCE_LAYERS, OBS_VKCAPTURE_LAYER_NAME); } const bundledGlCaptureLib = resolveBundledGlCaptureLib(nativeRoot); const systemGlCaptureLib = resolveSystemGlCaptureLib(); const glCaptureLib = bundledGlCaptureLib ?? systemGlCaptureLib; if (mode !== 'vulkan' && glCaptureLib) { env.LD_PRELOAD = appendColonEnv(env.LD_PRELOAD, glCaptureLib); } return { env, diagnostics: { mode, preferDiscreteGpu: options.preferDiscreteGpu === true, forceNvidiaIcd: options.forceNvidiaIcd === true || typeof options.forceNvidiaIcd === 'string', nvidiaIcdPath, bundledVulkanLayerDir, systemVulkanLayerManifest, vulkanLayerName: bundledVulkanLayerDir ? OBS_VKCAPTURE_LAYER_NAME : null, bundledGlCaptureLib, systemGlCaptureLib, glCaptureLib, licenseBoundary: 'obs-vkcapture hook assets are separate GPL-covered runtime tools; Fluxer communicates through the OBS-compatible socket protocol.', }, }; } function __setBindingForTests(nextBinding) { binding = nextBinding; loadError = null; } class ScreenCapture extends EventEmitter { constructor(options = {}) { super(); if (!binding) { throw loadError || new Error('@fluxer/linux-screen-capture binding unavailable'); } this.sourceId = options.sourceId; this.sourceKind = options.sourceKind ?? 'screen'; this.width = options.width ?? 0; this.height = options.height ?? 0; this.frameRate = options.frameRate ?? 30; this.captureId = typeof options.captureId === 'string' ? options.captureId : undefined; this.colorRange = options.colorRange; this.colorSpace = options.colorSpace; this.showCursorClicks = options.showCursorClicks === true; this.captureRect = options.captureRect; this.frameSinkHandle = options.frameSinkHandle; this.nativeFrameSinkRequired = options.nativeFrameSinkRequired === true; this.started = false; this.stopped = false; this.closedEmitted = false; this.native = new binding.ScreenCapture(); this.native.setLifecycleCallback((type, message) => { if (type === 'error') { this.emit('error', new Error(message || 'Linux PipeWire screen capture stream stopped')); return; } if (type === 'closed' || type === 'closed-clean') { if (this.stopped) { this.emitClosedOnce(); return; } this.stopped = true; Promise.resolve() .then(() => this.native.stop()) .catch(() => {}); this.emitClosedOnce(); return; } if (type === 'stalled' || type === 'diagnostic') { this.emit(type, message); } }); } emitClosedOnce() { if (this.closedEmitted) return; this.closedEmitted = true; this.emit('closed'); } async start() { if (this.started || this.stopped) return; this.started = true; try { if (this.frameSinkHandle != null) { if (typeof this.native.setFrameSinkHandle !== 'function') { throw new Error('@fluxer/linux-screen-capture native binding does not support frame sink handles'); } this.native.setFrameSinkHandle(this.frameSinkHandle); } else if (this.nativeFrameSinkRequired) { throw new Error('@fluxer/linux-screen-capture native frame sink handle is required'); } const result = await this.native.start( String(this.sourceId ?? ''), this.sourceKind, this.width, this.height, this.frameRate, this.captureId, { colorRange: this.colorRange, colorSpace: this.colorSpace, showCursorClicks: this.showCursorClicks, captureRect: this.captureRect, }, ); if (result) { this.width = result.width ?? this.width; this.height = result.height ?? this.height; this.frameRate = result.frameRate ?? this.frameRate; this.pixelFormat = result.pixelFormat ?? 'nv12'; } return { width: this.width, height: this.height, frameRate: this.frameRate, pixelFormat: this.pixelFormat ?? 'nv12', }; } catch (error) { this.stopped = true; this.emit('error', error instanceof Error ? error : new Error(String(error))); throw error; } } async stop() { if (this.stopped) return; this.stopped = true; try { await this.native.stop(); } finally { this.emitClosedOnce(); } } getDiagnostics() { const addonDiagnostics = typeof this.native.getDiagnostics === 'function' ? this.native.getDiagnostics() : null; if (!addonDiagnostics) return null; return { ...addonDiagnostics, sourceId: String(this.sourceId ?? ''), sourceKind: this.sourceKind, width: addonDiagnostics.width ?? this.width, height: addonDiagnostics.height ?? this.height, }; } } module.exports = { ScreenCapture, getAvailability, getBackendInfo, getGameCaptureLaunchEnvironment, listSources, loadError, __setBindingForTests, };