// 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 {performance} = require('node:perf_hooks'); const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs'); const MODULE_NAME = '@fluxer/mac-app-audio'; 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 !== 'darwin') { throw new Error(`@fluxer/mac-app-audio is only supported on macOS, got ${process.platform}`); } switch (process.arch) { case 'x64': return 'mac-app-audio.darwin-x64.node'; case 'arm64': return 'mac-app-audio.darwin-arm64.node'; default: throw new Error(`Unsupported macOS architecture: ${process.arch}`); } } let binding = null; let loadError = null; if (process.platform === 'darwin') { try { const nativeRoot = resolveNativeRoot(); const nativePath = join(nativeRoot, nativeFileName()); const loaded = loadNativeBinding({ moduleName: MODULE_NAME, nativePath, nativeRoot, packageDir: __dirname, probe: false, }); 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, }); throw loadError; } } function pidFromWindowId(windowId) { if (!binding) return 0; return binding.pidFromWindowId(windowId); } function listAudibleApplications() { if (!binding) return Promise.resolve([]); return binding.listAudibleApplications(); } function getBackendInfo() { if (!binding) { return { backend: 'mac-app-audio', supported: false, reason: process.platform === 'darwin' ? `@fluxer/mac-app-audio native binary unavailable: ${loadError?.message ?? 'unknown reason'}` : `@fluxer/mac-app-audio is only supported on macOS, got ${process.platform}`, minMacosVersion: '12.3', minMacosVersionCoreaudio: '14.2', detectedMacosVersion: undefined, sckAvailable: false, coreaudioAvailable: false, }; } return binding.getBackendInfo(); } function getBackendAvailability() { if (!binding) { return Promise.resolve({ sck: {supported: false}, coreaudio: {supported: false}, screenPermission: 'not-determined', audioPermission: 'not-determined', }); } return binding.getBackendAvailability(); } function __setBindingForTests(nextBinding) { binding = nextBinding; loadError = null; } class ProcessLoopback extends EventEmitter { constructor(pid, options = {}) { super(); if (!binding) { throw loadError || new Error('@fluxer/mac-app-audio binding unavailable'); } this.pid = pid; void options.excludeSelf; this.excludeSelf = true; this.includeProcessTree = options.includeProcessTree ?? true; this.backend = options.backend ?? options.macBackend ?? 'auto'; this.captureScope = options.captureScope ?? options.macCaptureScope ?? options.scope ?? 'process'; this.started = false; this.stopped = false; this.closedEmitted = false; this.nextTimestampUs = Math.round(performance.now() * 1000); this.native = new binding.ProcessLoopback(); this.native.setFrameCallback((samples) => { if (this.stopped) return; const copied = new Float32Array(samples); const numFrames = copied.length / 2; const frame = { samples: copied, sampleRate: 48000, channels: 2, timestampUs: this.nextTimestampUs, }; this.nextTimestampUs += Math.round((numFrames / frame.sampleRate) * 1_000_000); this.emit('frame', frame); }); this.native.setLifecycleCallback((type, message) => { if (type === 'error') { this.emit('error', new Error(message || 'macOS app audio stream stopped')); return; } if (type === 'closed') { if (this.stopped) { this.emitClosedOnce(); return; } this.stopped = true; Promise.resolve() .then(() => this.native.stop()) .catch(() => {}); this.emitClosedOnce(); } }); } emitClosedOnce() { if (this.closedEmitted) return; this.closedEmitted = true; this.emit('closed'); } async start() { if (this.started || this.stopped) return; this.started = true; try { await this.native.start(this.pid, this.excludeSelf, this.includeProcessTree, this.backend, this.captureScope); } catch (error) { this.stopped = true; this.emit('error', error instanceof Error ? error : new Error(String(error))); throw error; } } 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(); } } async stop() { if (this.stopped) return; this.stopped = true; this.clearScreenAudioSink(); try { await this.native.stop(); } finally { this.emitClosedOnce(); } } } module.exports = { ProcessLoopback, listAudibleApplications, getBackendAvailability, getBackendInfo, pidFromWindowId, loadError, __setBindingForTests, };