// SPDX-License-Identifier: AGPL-3.0-or-later const {EventEmitter} = require('node:events'); const {existsSync} = require('node:fs'); const {join, sep} = require('node:path'); const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs'); const MODULE_NAME = '@fluxer/win-game-capture'; const WINDOWS_GAME_CAPTURE_MODULE_ENV = 'FLUXER_WINDOWS_GAME_CAPTURE_MODULE_ENABLED'; 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(arch) { switch (arch) { case 'x64': return 'win-game-capture.win32-x64-msvc.node'; case 'arm64': return 'win-game-capture.win32-arm64-msvc.node'; default: return null; } } let binding = null; let loadError = null; const nativeRoot = resolveNativeRoot(); if (process.platform === 'win32' && process.env[WINDOWS_GAME_CAPTURE_MODULE_ENV] !== 'true') { loadError = createNativeLoadError({ moduleName: MODULE_NAME, nativeRoot, packageDir: __dirname, reason: 'Windows game capture is disabled in this build', }); } else if (process.platform === 'win32') { const fileName = nativeFileName(process.arch); if (!fileName) { loadError = createNativeLoadError({ moduleName: MODULE_NAME, nativeRoot, packageDir: __dirname, reason: `unsupported Windows architecture: ${process.arch}`, }); } else { const nativePath = join(nativeRoot, fileName); const loaded = loadNativeBinding({ moduleName: MODULE_NAME, nativePath, nativeRoot, packageDir: __dirname, probe: false, }); binding = loaded.binding; loadError = loaded.loadError; } } else { loadError = createNativeLoadError({ moduleName: MODULE_NAME, nativeRoot, packageDir: __dirname, reason: `not supported on platform ${process.platform}`, }); } function gameHookFileName(arch) { switch (arch) { case 'x64': return 'fluxer-game-hook.win32-x64-msvc.dll'; case 'ia32': return 'fluxer-game-hook.win32-ia32-msvc.dll'; case 'arm64': return 'fluxer-game-hook.win32-arm64-msvc.dll'; default: return null; } } function resolveGameHookPathForArch(arch, root = nativeRoot) { if (process.platform !== 'win32') return null; const fileName = gameHookFileName(arch); if (!fileName) return null; const hookPath = join(root, fileName); return existsSync(hookPath) ? hookPath : null; } function resolveGameHookPath(root = nativeRoot) { return resolveGameHookPathForArch(process.arch, root); } function resolveGameHookPathX86(root = nativeRoot) { return resolveGameHookPathForArch('ia32', root); } function vulkanLayerManifestFileName(arch) { switch (arch) { case 'x64': return 'fluxer-vulkan-layer.win32-x64-msvc.json'; case 'ia32': return 'fluxer-vulkan-layer.win32-ia32-msvc.json'; case 'arm64': return 'fluxer-vulkan-layer.win32-arm64-msvc.json'; default: return null; } } function resolveVulkanLayerManifestPath(root = nativeRoot) { if (process.platform !== 'win32') return null; const fileName = vulkanLayerManifestFileName(process.arch); if (!fileName) return null; const manifestPath = join(root, fileName); return existsSync(manifestPath) ? manifestPath : null; } function registerVulkanLayerManifest(root = nativeRoot) { if (!binding?.registerVulkanLayerManifest) return false; const manifestPath = resolveVulkanLayerManifestPath(root); if (!manifestPath) return false; binding.registerVulkanLayerManifest(manifestPath); return true; } function unregisterVulkanLayerManifest(root = nativeRoot) { if (!binding?.unregisterVulkanLayerManifest) return false; const manifestPath = resolveVulkanLayerManifestPath(root); if (!manifestPath) return false; try { binding.unregisterVulkanLayerManifest(manifestPath); return true; } catch (error) { console.warn('[win-game-capture] unregisterVulkanLayerManifest failed:', error?.message || error); return false; } } function getVulkanLayerRegistrationState(root = nativeRoot) { const manifestPath = resolveVulkanLayerManifestPath(root); if (!binding?.getVulkanLayerRegistrationState) { return {registered: false, manifestExists: Boolean(manifestPath), dllExists: false, manifestPath}; } try { return binding.getVulkanLayerRegistrationState(manifestPath ?? ''); } catch (error) { console.warn('[win-game-capture] getVulkanLayerRegistrationState failed:', error?.message || error); return {registered: false, manifestExists: Boolean(manifestPath), dllExists: false, manifestPath}; } } if (process.platform === 'win32' && binding) { try { registerVulkanLayerManifest(); } catch (error) { console.warn('[win-game-capture] registerVulkanLayerManifest failed:', error?.message || error); } } class ScreenCapture extends EventEmitter { constructor(options = {}) { super(); if (!binding) { throw loadError || new Error(`${MODULE_NAME} binding unavailable`); } this.sourceId = options.sourceId; this.sourceKind = options.sourceKind ?? 'window'; this.width = options.width ?? 0; this.height = options.height ?? 0; this.frameRate = options.frameRate ?? 30; this.hookDllPath = options.hookDllPath ?? resolveGameHookPath(); this.hookDllPathX86 = options.hookDllPathX86 ?? resolveGameHookPathX86(); this.injectionMethod = options.injectionMethod ?? undefined; 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((...lifecycleArgs) => { const [type, message] = lifecycleArgs.length === 1 && Array.isArray(lifecycleArgs[0]) ? lifecycleArgs[0] : lifecycleArgs; if (type === 'stalled') { if (this.stopped) return; this.emit('stalled', message); return; } if (type === 'diagnostic') { if (this.stopped) return; this.emit('diagnostic', message); return; } if (type === 'error') { this.emit('error', new Error(message || 'DXGI capture error')); return; } if (type === 'closed') { if (this.stopped) { this._emitClosedOnce(); return; } this.stopped = true; try { this.native.stop(); } catch {} this._emitClosedOnce(); } }); } async start() { if (this.started || this.stopped) return undefined; if (this.sourceKind === 'game' && !this.hookDllPath) { throw new Error(`Game capture hook unavailable for ${process.platform}-${process.arch}`); } this.started = true; try { if (this.frameSinkHandle != null) { if (typeof this.native.setFrameSinkHandle !== 'function') { throw new Error(`${MODULE_NAME} native binding does not support native frame sink handles`); } this.native.setFrameSinkHandle(this.frameSinkHandle); } else if (this.nativeFrameSinkRequired) { throw new Error('Native frame sink handle is required for Windows screen capture'); } const result = this.native.start( this.sourceId, this.sourceKind, this.width || undefined, this.height || undefined, this.frameRate || undefined, this.sourceKind === 'game' ? this.hookDllPath : undefined, this.sourceKind === 'game' ? (this.hookDllPathX86 ?? undefined) : undefined, this.sourceKind === 'game' ? (this.injectionMethod ?? undefined) : undefined, this.captureId, { colorRange: this.colorRange, colorSpace: this.colorSpace, showCursorClicks: this.showCursorClicks, captureRect: this.captureRect, }, ); return result ? { width: result.width, height: result.height, frameRate: result.frameRate, pixelFormat: result.pixelFormat, } : undefined; } 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 { this.native.stop(); } finally { this._emitClosedOnce(); } } getDiagnostics() { if (!this.native || typeof this.native.getDiagnostics !== 'function') return null; try { return this.native.getDiagnostics() ?? null; } catch (error) { console.warn('[win-game-capture] getDiagnostics failed:', error?.message || error); return null; } } getSharedTextureHandle() { if (!this.native || typeof this.native.getSharedTextureHandle !== 'function') return null; try { return this.native.getSharedTextureHandle() ?? null; } catch (error) { console.warn('[win-game-capture] getSharedTextureHandle failed:', error?.message || error); return null; } } attachEncoder(width, height) { if (!this.native || typeof this.native.attachEncoder !== 'function') { throw new Error(`${MODULE_NAME} native binding does not support encoder attachment`); } this.native.attachEncoder(width, height); } detachEncoder() { if (!this.native || typeof this.native.detachEncoder !== 'function') return; this.native.detachEncoder(); } isEncoderAttached() { if (!this.native || typeof this.native.isEncoderAttached !== 'function') return false; return Boolean(this.native.isEncoderAttached()); } encoderRingFullCount() { if (!this.native || typeof this.native.encoderRingFullCount !== 'function') return 0; const count = this.native.encoderRingFullCount(); return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0; } getEncoderAttachDiagnostics() { if (!this.native || typeof this.native.getEncoderAttachDiagnostics !== 'function') return null; try { return this.native.getEncoderAttachDiagnostics() ?? null; } catch (error) { console.warn('[win-game-capture] getEncoderAttachDiagnostics failed:', error?.message || error); return null; } } getFrameSinkDiagnostics() { if (!this.native || typeof this.native.getFrameSinkDiagnostics !== 'function') { return { accepted: 0, coalesced: 0, rejected: 0, mediaFramesDroppedWithoutSink: 0, cpuFallbackFramesDropped: 0, }; } try { return this.native.getFrameSinkDiagnostics(); } catch (error) { console.warn('[win-game-capture] getFrameSinkDiagnostics failed:', error?.message || error); return { accepted: 0, coalesced: 0, rejected: 0, mediaFramesDroppedWithoutSink: 0, cpuFallbackFramesDropped: 0, }; } } _emitClosedOnce() { if (this.closedEmitted) return; this.closedEmitted = true; queueMicrotask(() => this.emit('closed')); } } const FALLBACK_STRATEGY_NAMES = new Set(['game-hook', 'wgc', 'dxgi-duplication', 'window-gdi', 'none']); function parseFallbackRecommendation(message) { if (typeof message !== 'string') return null; const match = message.match(/\[next-strategy=([a-z-]+)\]/); if (!match) return null; const name = match[1]; return FALLBACK_STRATEGY_NAMES.has(name) ? name : null; } async function listSources() { if (!binding || typeof binding.listSources !== 'function') return []; try { const sources = await binding.listSources(); if (!Array.isArray(sources)) return []; return sources .filter((source) => { return ( source && (source.kind === 'screen' || source.kind === 'window' || source.kind === 'game') && typeof source.id === 'string' && source.id.length > 0 ); }) .map((source) => ({ kind: source.kind, id: source.id, name: typeof source.name === 'string' && source.name.length > 0 ? source.name : source.id, width: Number.isFinite(source.width) ? Math.max(0, Math.floor(source.width)) : 0, height: Number.isFinite(source.height) ? Math.max(0, Math.floor(source.height)) : 0, targetPid: Number.isFinite(source.targetPid) && source.targetPid > 0 ? Math.floor(source.targetPid) : undefined, })); } catch (error) { console.warn('[win-game-capture] listSources failed:', error?.message || error); return []; } } function isSupported() { if (!binding) return false; return binding.isSupported(); } function getAvailability() { if (!binding) { return {available: false, backend: 'windows-game-capture', reason: 'load-failed'}; } return binding.getAvailability(); } function normalizeProcessId(processId) { if (processId === undefined || processId === null) return undefined; if (Number.isInteger(processId) && processId > 0) return processId; throw new TypeError(`Invalid process id: ${processId}`); } function normalizePriorityClass(priorityClass) { if (priorityClass === undefined || priorityClass === null) return undefined; const normalized = String(priorityClass).trim().toLowerCase(); if (normalized === 'real-time') return 'realtime'; if (normalized === 'high' || normalized === 'realtime') return normalized; throw new TypeError(`Invalid GPU scheduling priority class: ${priorityClass}`); } function elevateGpuSchedulingPriority(processId, priorityClass) { if (!binding) return false; try { binding.elevateGpuSchedulingPriority(normalizeProcessId(processId), normalizePriorityClass(priorityClass)); return true; } catch (error) { console.warn('[win-game-capture] elevateGpuSchedulingPriority failed:', error?.message || error); return false; } } function restoreGpuSchedulingPriority(processId) { if (!binding) return false; try { binding.restoreGpuSchedulingPriority(normalizeProcessId(processId)); return true; } catch (error) { console.warn('[win-game-capture] restoreGpuSchedulingPriority failed:', error?.message || error); return false; } } function __setBindingForTests(nextBinding) { binding = nextBinding; loadError = null; } module.exports = { isSupported, getAvailability, resolveGameHookPath, resolveGameHookPathX86, resolveVulkanLayerManifestPath, registerVulkanLayerManifest, unregisterVulkanLayerManifest, getVulkanLayerRegistrationState, listSources, ScreenCapture, parseFallbackRecommendation, elevateGpuSchedulingPriority, restoreGpuSchedulingPriority, __setBindingForTests, get loadError() { return loadError; }, };