// 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-process-loopback'; 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-process-loopback.win32-x64-msvc.node'; case 'arm64': return 'win-process-loopback.win32-arm64-msvc.node'; default: return null; } } let binding = null; let loadError = null; if (process.platform === 'win32') { const nativeRoot = resolveNativeRoot(); const fileName = nativeFileName(process.arch); if (!fileName) { loadError = createNativeLoadError({ moduleName: MODULE_NAME, nativeRoot, packageDir: __dirname, reason: `unsupported Windows architecture: ${process.arch}`, }); throw loadError; } else { const nativePath = join(nativeRoot, fileName); const loaded = loadNativeBinding({ moduleName: MODULE_NAME, nativePath, nativeRoot, packageDir: __dirname, probe: false, }); binding = loaded.binding; loadError = loaded.loadError; if (loadError) throw loadError; } } else { loadError = createNativeLoadError({ moduleName: MODULE_NAME, nativeRoot: resolveNativeRoot(), packageDir: __dirname, reason: `not supported on platform ${process.platform}`, }); } function isSupported() { if (!binding) return false; try { return Boolean(binding.isSupported()); } catch { return false; } } function getBackendInfo() { if (!binding) { const reason = process.platform !== 'win32' ? `@fluxer/win-process-loopback only supports Windows, got ${process.platform}. Microsoft's documented process-loopback minimum is Windows build 20348.` : `@fluxer/win-process-loopback native binary unavailable: ${loadError?.message ?? 'unknown reason'}`; return { backend: 'win-process-loopback', supported: false, reason, processSupported: false, systemSupported: false, systemExcludesSelf: false, processIncludeSupported: false, processExcludeSupported: false, sessionMixerSupported: false, systemLoopbackMode: 'unavailable', minWindowsBuild: 20348, minWindowsVersionLabel: 'Windows build 20348 (documented)', detectedWindowsBuild: undefined, }; } try { return binding.getBackendInfo(); } catch (err) { return { backend: 'win-process-loopback', supported: false, reason: `@fluxer/win-process-loopback getBackendInfo failed: ${err?.message ?? String(err)}`, processSupported: false, systemSupported: false, systemExcludesSelf: false, processIncludeSupported: false, processExcludeSupported: false, sessionMixerSupported: false, systemLoopbackMode: 'unavailable', minWindowsBuild: 20348, minWindowsVersionLabel: 'Windows build 20348 (documented)', detectedWindowsBuild: undefined, }; } } function pidFromHwnd(hwnd) { if (!binding) return 0; try { return binding.pidFromHwnd(hwnd); } catch { return 0; } } function resolveAudioRootPid(pid) { if (!binding) return pid; try { return binding.resolveAudioRootPid(pid); } catch { return pid; } } class ProcessLoopback extends EventEmitter { constructor(pid, opts = {}) { super(); if (!binding) { throw loadError ?? new Error('@fluxer/win-process-loopback binding unavailable'); } const captureScope = opts.captureScope ?? opts.winCaptureScope ?? opts.scope ?? 'process'; const includeProcessTree = captureScope === 'system' || captureScope === 'session-mixer' ? false : opts.includeProcessTree !== false; const sampleRate = opts.sampleRate; const channels = opts.channels; this._stopped = false; this._started = false; this._startPromise = null; this._resolveStart = null; this._rejectStart = null; this._native = new binding.ProcessLoopback( pid, {includeProcessTree, sampleRate, channels, captureScope}, (frame) => this.emit('frame', frame), (err) => { if (!this._started && this._rejectStart) { this._rejectStart(err); this._clearStartWaiters(); } this.emit('error', err); }, () => { if (this._stopped) return; this._stopped = true; if (!this._started && this._rejectStart) { this._rejectStart(new Error('ProcessLoopback closed before audio capture started')); this._clearStartWaiters(); } queueMicrotask(() => { try { this._native.dispose(); } catch {} }); this.emit('closed'); }, () => { this._started = true; if (this._resolveStart) { this._resolveStart(); this._clearStartWaiters(); } this.emit('started'); }, ); } _clearStartWaiters() { this._resolveStart = null; this._rejectStart = null; } setScreenAudioSink(handle) { if (typeof this._native.setScreenAudioSink !== 'function') return false; try { return this._native.setScreenAudioSink(handle) !== false; } catch { return false; } } clearScreenAudioSink() { if (typeof this._native.clearScreenAudioSink === 'function') { this._native.clearScreenAudioSink(); } } start() { if (this._stopped) return Promise.resolve(); if (this._started) return Promise.resolve(); if (this._startPromise) return this._startPromise; this._startPromise = new Promise((resolve, reject) => { this._resolveStart = resolve; this._rejectStart = reject; try { this._native.start(); } catch (error) { this._clearStartWaiters(); reject(error); } }); return this._startPromise; } stop() { if (this._stopped) return; this._stopped = true; this.clearScreenAudioSink(); if (!this._started && this._rejectStart) { this._rejectStart(new Error('ProcessLoopback stopped before audio capture started')); this._clearStartWaiters(); } this._native.dispose(); } } function __setBindingForTests(nextBinding, nextLoadError = null) { binding = nextBinding; loadError = nextLoadError; } module.exports = { isSupported, getBackendInfo, pidFromHwnd, resolveAudioRootPid, ProcessLoopback, get loadError() { return loadError; }, __setBindingForTests, };