// SPDX-License-Identifier: AGPL-3.0-or-later import assert from 'node:assert/strict'; import {mkdirSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {afterEach, describe, it} from 'node:test'; import linuxScreenCapture from './index.js'; const {getGameCaptureLaunchEnvironment} = linuxScreenCapture; function makeFakeBinding({ sources = [], availability = { available: true, backend: 'linux-pipewire-portal', detail: 'portal:5', capabilities: {process: true, system: true}, }, } = {}) { const calls = []; const frameSinkHandleCalls = []; const natives = []; class FakeNative { constructor() { this.lifecycleCallback = undefined; this.stopCount = 0; natives.push(this); } setLifecycleCallback(callback) { this.lifecycleCallback = callback; } setFrameSinkHandle(handle) { frameSinkHandleCalls.push(handle); } async start(sourceId, sourceKind, width, height, frameRate, captureId, captureOptions) { calls.push({sourceId, sourceKind, width, height, frameRate, captureId, captureOptions}); return {width: width || 1920, height: height || 1080, frameRate: frameRate || 30, pixelFormat: 'nv12'}; } async stop() { this.stopCount += 1; } getDiagnostics() { return { portalSessionId: 'portal-session-1', width: 1280, height: 720, }; } } return { binding: { ScreenCapture: FakeNative, listSources: async () => sources, getAvailability: async () => availability, getBackendInfo: () => ({ backend: 'linux-pipewire-portal', supported: true, portalVersion: 5, pipewireReachable: true, }), }, calls, frameSinkHandleCalls, natives, }; } afterEach(() => { linuxScreenCapture.__setBindingForTests(null); }); describe('linux-screen-capture game capture launch environment', () => { it('enables OBS Vulkan capture and names the client', () => { const result = getGameCaptureLaunchEnvironment({ env: {}, name: 'fluxer-test', mode: 'vulkan', }); assert.equal(result.env.OBS_VKCAPTURE, '1'); assert.equal(result.env.OBS_VKCAPTURE_NAME, 'fluxer-test'); assert.equal(result.env.LD_PRELOAD, undefined); assert.equal(result.diagnostics.mode, 'vulkan'); assert.match(result.diagnostics.licenseBoundary, /GPL-covered runtime tools/); }); it('prefers bundled hook assets and can force PRIME/NVIDIA launch variables', () => { const nativeRoot = mkdtempSync(join(tmpdir(), 'fluxer-linux-screen-capture-')); const bundledRoot = join(nativeRoot, 'obs-vkcapture'); const glRoot = join(bundledRoot, 'obs_glcapture'); mkdirSync(glRoot, {recursive: true}); writeFileSync(join(bundledRoot, 'obs_vkcapture_64.json'), '{}'); writeFileSync(join(bundledRoot, 'libVkLayer_obs_vkcapture.so'), ''); writeFileSync(join(glRoot, 'libobs_glcapture.so'), ''); const result = getGameCaptureLaunchEnvironment({ env: {LD_PRELOAD: '/tmp/existing.so'}, nativeRoot, preferDiscreteGpu: true, }); assert.equal(result.env.OBS_VKCAPTURE, '1'); assert.equal(result.env.__NV_PRIME_RENDER_OFFLOAD, '1'); assert.equal(result.env.__VK_LAYER_NV_optimus, 'NVIDIA_only'); assert.equal(result.env.__GLX_VENDOR_LIBRARY_NAME, 'nvidia'); assert.equal(result.env.DRI_PRIME, '1'); assert.equal(result.env.VK_ADD_LAYER_PATH, bundledRoot); assert.equal(result.env.VK_INSTANCE_LAYERS, 'VK_LAYER_OBS_vkcapture_64'); assert.equal(result.env.LD_PRELOAD, `/tmp/existing.so:${join(glRoot, 'libobs_glcapture.so')}`); assert.equal(result.diagnostics.forceNvidiaIcd, false); assert.equal(result.diagnostics.nvidiaIcdPath, null); assert.equal(result.diagnostics.bundledVulkanLayerDir, bundledRoot); assert.equal(result.diagnostics.bundledGlCaptureLib, join(glRoot, 'libobs_glcapture.so')); }); it('does not duplicate launch path entries or Vulkan layer names', () => { const nativeRoot = mkdtempSync(join(tmpdir(), 'fluxer-linux-screen-capture-')); const bundledRoot = join(nativeRoot, 'obs-vkcapture', 'vulkan'); mkdirSync(bundledRoot, {recursive: true}); writeFileSync(join(bundledRoot, 'obs_vkcapture_64.json'), '{}'); writeFileSync(join(bundledRoot, 'libVkLayer_obs_vkcapture.so'), ''); const result = getGameCaptureLaunchEnvironment({ env: { VK_ADD_LAYER_PATH: `/tmp/other:${bundledRoot}`, VK_INSTANCE_LAYERS: 'VK_LAYER_OBS_vkcapture_64:VK_LAYER_KHRONOS_validation', }, nativeRoot, mode: 'vulkan', }); assert.equal(result.env.VK_ADD_LAYER_PATH, `/tmp/other:${bundledRoot}`); assert.equal(result.env.VK_INSTANCE_LAYERS, 'VK_LAYER_OBS_vkcapture_64:VK_LAYER_KHRONOS_validation'); assert.equal(result.diagnostics.bundledVulkanLayerDir, bundledRoot); assert.equal(result.diagnostics.vulkanLayerName, 'VK_LAYER_OBS_vkcapture_64'); }); it('can force a specific NVIDIA Vulkan ICD for hybrid GPU systems', () => { const result = getGameCaptureLaunchEnvironment({ env: {}, preferDiscreteGpu: true, forceNvidiaIcd: '/tmp/nvidia_icd.json', }); assert.equal(result.env.VK_ICD_FILENAMES, '/tmp/nvidia_icd.json'); assert.equal(result.diagnostics.forceNvidiaIcd, true); assert.equal(result.diagnostics.nvidiaIcdPath, '/tmp/nvidia_icd.json'); }); it('keeps obs-vkcapture runtime assets and license notes in the package surface', () => { const packageJson = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')); assert.equal(packageJson.license, 'AGPL-3.0-or-later'); assert(packageJson.files.includes('THIRD_PARTY_OBS_VKCAPTURE.md')); assert(packageJson.files.includes('obs-vkcapture/**/*')); const notice = readFileSync(new URL('./THIRD_PARTY_OBS_VKCAPTURE.md', import.meta.url), 'utf8'); assert.match(notice, /separate third-party runtime component/); assert.match(notice, /exact upstream license text/); assert.match(notice, /corresponding source/); }); }); describe('linux-screen-capture loader wrapper', () => { it('forwards display and window sources from native binding without rewriting ids', async () => { const {binding} = makeFakeBinding({ sources: [ {kind: 'screen', id: 'pipewire:display:1', name: 'Display 1', width: 2560, height: 1440}, { kind: 'window', id: 'pipewire:window:4242', name: 'Fluxer', width: 1280, height: 720, appName: 'Fluxer', targetPid: 1234, }, ], }); linuxScreenCapture.__setBindingForTests(binding); assert.deepEqual(await linuxScreenCapture.listSources(), [ {kind: 'screen', id: 'pipewire:display:1', name: 'Display 1', width: 2560, height: 1440}, { kind: 'window', id: 'pipewire:window:4242', name: 'Fluxer', width: 1280, height: 720, appName: 'Fluxer', targetPid: 1234, }, ]); }); it('forwards source id, kind, dimensions, and diagnostics to native binding', async () => { const {binding, calls} = makeFakeBinding(); linuxScreenCapture.__setBindingForTests(binding); const displayCapture = new linuxScreenCapture.ScreenCapture({ sourceId: 'pipewire:display:1', sourceKind: 'screen', width: 2560, height: 1440, frameRate: 60, captureId: 'capture-1', colorRange: 'full', colorSpace: 'rec709', showCursorClicks: true, captureRect: {x: 10, y: 20, width: 300, height: 200}, }); const windowCapture = new linuxScreenCapture.ScreenCapture({ sourceId: 'pipewire:window:4242', sourceKind: 'window', width: 1280, height: 720, frameRate: 30, }); await displayCapture.start(); await windowCapture.start(); assert.deepEqual(calls, [ { sourceId: 'pipewire:display:1', sourceKind: 'screen', width: 2560, height: 1440, frameRate: 60, captureId: 'capture-1', captureOptions: { colorRange: 'full', colorSpace: 'rec709', showCursorClicks: true, captureRect: {x: 10, y: 20, width: 300, height: 200}, }, }, { sourceId: 'pipewire:window:4242', sourceKind: 'window', width: 1280, height: 720, frameRate: 30, captureId: undefined, captureOptions: { colorRange: undefined, colorSpace: undefined, showCursorClicks: false, captureRect: undefined, }, }, ]); assert.deepEqual(displayCapture.getDiagnostics(), { portalSessionId: 'portal-session-1', width: 1280, height: 720, sourceId: 'pipewire:display:1', sourceKind: 'screen', }); assert.deepEqual(windowCapture.getDiagnostics(), { portalSessionId: 'portal-session-1', width: 1280, height: 720, sourceId: 'pipewire:window:4242', sourceKind: 'window', }); }); it('emits closed once for native closed-clean lifecycle events', async () => { const {binding, natives} = makeFakeBinding(); linuxScreenCapture.__setBindingForTests(binding); const capture = new linuxScreenCapture.ScreenCapture({ sourceId: 'pipewire:display:1', sourceKind: 'screen', }); let closed = 0; capture.on('closed', () => { closed += 1; }); await capture.start(); natives[0].lifecycleCallback('closed-clean', 'capture stopped'); await capture.stop(); assert.equal(closed, 1); assert.equal(natives[0].stopCount, 1); }); it('reports PipeWire portal capabilities from native binding', async () => { const {binding} = makeFakeBinding({ availability: { available: true, backend: 'linux-pipewire-portal', detail: 'system capture disabled by portal', capabilities: {process: true, system: false}, }, }); linuxScreenCapture.__setBindingForTests(binding); assert.deepEqual(await linuxScreenCapture.getAvailability(), { available: true, backend: 'linux-pipewire-portal', detail: 'system capture disabled by portal', capabilities: {process: true, system: false}, }); }); it('installs a native frame sink handle once before start', async () => { const {binding, calls, frameSinkHandleCalls} = makeFakeBinding(); linuxScreenCapture.__setBindingForTests(binding); const frameSinkHandle = {native: true}; const capture = new linuxScreenCapture.ScreenCapture({ sourceId: 'pipewire:display:1', sourceKind: 'screen', frameSinkHandle, nativeFrameSinkRequired: true, }); await capture.start(); assert.deepEqual(frameSinkHandleCalls, [frameSinkHandle]); assert.deepEqual(calls, [ { sourceId: 'pipewire:display:1', sourceKind: 'screen', width: 0, height: 0, frameRate: 30, captureId: undefined, captureOptions: { colorRange: undefined, colorSpace: undefined, showCursorClicks: false, captureRect: undefined, }, }, ]); }); it('fails before native start when a native frame sink is required but missing', async () => { const {binding, calls, frameSinkHandleCalls} = makeFakeBinding(); linuxScreenCapture.__setBindingForTests(binding); const capture = new linuxScreenCapture.ScreenCapture({ sourceId: 'pipewire:display:1', sourceKind: 'screen', nativeFrameSinkRequired: true, }); await assert.rejects(() => capture.start(), /native frame sink handle is required/); assert.deepEqual(frameSinkHandleCalls, []); assert.deepEqual(calls, []); }); });