// Native WASAPI process-loopback bridge. Wraps the // @chatapp/audio-loopback-native napi-rs addon and forwards PCM chunks // to the renderer over IPC. // // Why the addon over Chromium's built-in 'loopback' source: the OS // process-loopback API supports EXCLUDE_TARGET_PROCESS_TREE, which lets // us capture every render session *except* our own PID tree. That keeps // LiveKit's call playback out of the outgoing share so peers don't hear // themselves echoed back. Chromium's 'loopback' has no such filter. // // Windows-only. The addon's start_capture call rejects on macOS/Linux // with a clear error string and the renderer falls through to the // existing Chromium getUserMedia path (lib/screenAudio.ts). import { app, BrowserWindow, ipcMain } from 'electron'; import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { CHANNELS, type AudioLoopbackChunk, type AudioLoopbackStartResult, } from '../ipc-types'; interface NativeAudioLoopback { startCapture: (callback: (samples: Float32Array) => void) => number; /** INCLUDE_TARGET_PROCESS_TREE variant — capture only `pid`'s tree. * Used for window-shares so we get just the picked app's audio. */ startCaptureForPid?: ( pid: number, callback: (samples: Float32Array) => void, ) => number; /** Look up the owning process id of a top-level window handle. */ resolveWindowPid?: (hwnd: number) => number; stopCapture: (captureId: number) => void; } // __dirname in an ESM main bundle resolves to out/main after // electron-vite builds. We need a CJS-style require to load the native // addon — `import` would trigger ESM resolution which doesn't handle // .node files cleanly across electron-vite's transform. const __filenameSafe = typeof __filename === 'string' ? __filename : fileURLToPath(import.meta.url); const __dirnameSafe = path.dirname(__filenameSafe); const requireCjs = createRequire(__filenameSafe); let cachedAddon: NativeAudioLoopback | null | undefined; function resolveAddon(): NativeAudioLoopback | null { if (cachedAddon !== undefined) return cachedAddon; // Production: electron-builder copies the .node into // resources/native/audio-loopback.node (see extraResources in // package.json). // Dev: napi build emits the binary alongside the addon's package.json // at apps/desktop/native/audio-loopback/audio-loopback..node, // and writes an index.js shim that auto-selects the right triple. // Loading the shim works in both layouts when the binary sits // adjacent to it; for the packaged single-file layout we require the // .node directly. const candidates: string[] = []; if (app.isPackaged && process.resourcesPath) { candidates.push(path.join(process.resourcesPath, 'native', 'audio-loopback.node')); } // Dev workspace layout — main bundle lives at out/main/main.js, // addon lives at native/audio-loopback/. Walk up two levels from the // bundle dir (out/main → out → apps/desktop) and into native/. candidates.push( path.join(__dirnameSafe, '..', '..', 'native', 'audio-loopback', 'index.js'), // Direct .node fallback in case the JS shim is missing (e.g. user // ran `cargo build` manually instead of `napi build`). path.join( __dirnameSafe, '..', '..', 'native', 'audio-loopback', `audio-loopback.${process.platform}-${process.arch}-msvc.node`, ), ); for (const candidate of candidates) { try { const mod = requireCjs(candidate) as NativeAudioLoopback; if ( typeof mod.startCapture === 'function' && typeof mod.stopCapture === 'function' ) { cachedAddon = mod; return mod; } } catch { /* try next candidate */ } } cachedAddon = null; return null; } /** Track active capture ids per BrowserWindow so we can tear them down * if the renderer is destroyed mid-capture (renderer crash, window * close during share). Without this the WASAPI thread would leak. */ const activeCaptures = new Map>(); function registerWindowCleanup(win: BrowserWindow, addon: NativeAudioLoopback): void { const wcId = win.webContents.id; if (activeCaptures.has(wcId)) return; activeCaptures.set(wcId, new Set()); const cleanup = (): void => { const ids = activeCaptures.get(wcId); if (!ids) return; for (const id of ids) { try { addon.stopCapture(id); } catch { /* best effort */ } } activeCaptures.delete(wcId); }; win.webContents.once('destroyed', cleanup); win.once('closed', cleanup); } export function register(mainWindow: BrowserWindow): void { ipcMain.handle( CHANNELS.AUDIO_LOOPBACK_START, async (event): Promise => { const addon = resolveAddon(); if (!addon) { throw new Error('audio-loopback native addon unavailable on this platform'); } const wc = event.sender; // captureId is assigned synchronously by addon.startCapture below, // but the chunk callback needs to reference it — we forward-declare // via a closure-shared holder. The first chunk can only fire after // startCapture returns (the worker thread spawn happens inside it). let assignedId = 0; const cb = makeChunkCallback(wc, () => assignedId); assignedId = addon.startCapture(cb); registerWindowCleanup(mainWindow, addon); const set = activeCaptures.get(mainWindow.webContents.id); if (set) set.add(assignedId); return { captureId: assignedId }; }, ); ipcMain.handle( CHANNELS.AUDIO_LOOPBACK_START_FOR_WINDOW, async ( event, args: { hwnd: number }, ): Promise => { const addon = resolveAddon(); if (!addon) { throw new Error('audio-loopback native addon unavailable on this platform'); } if (!addon.startCaptureForPid || !addon.resolveWindowPid) { throw new Error( 'audio-loopback native addon is too old: missing startCaptureForPid / resolveWindowPid (rebuild with `pnpm build:native`)', ); } if (!args || typeof args.hwnd !== 'number' || !Number.isFinite(args.hwnd)) { throw new Error('AUDIO_LOOPBACK_START_FOR_WINDOW: hwnd must be a finite number'); } const pid = addon.resolveWindowPid(args.hwnd); const wc = event.sender; let assignedId = 0; const cb = makeChunkCallback(wc, () => assignedId); assignedId = addon.startCaptureForPid(pid, cb); registerWindowCleanup(mainWindow, addon); const set = activeCaptures.get(mainWindow.webContents.id); if (set) set.add(assignedId); return { captureId: assignedId }; }, ); ipcMain.handle( CHANNELS.AUDIO_LOOPBACK_STOP, async (_event, captureId: number): Promise => { const addon = resolveAddon(); if (!addon) return; try { addon.stopCapture(captureId); } finally { const set = activeCaptures.get(mainWindow.webContents.id); if (set) set.delete(captureId); } }, ); } /** Shared chunk-forwarding callback. Both start variants produce the * same wire format (interleaved f32 stereo @ 48kHz) so the renderer * doesn't need to know which start path was used — the captureId * routes the chunks. */ function makeChunkCallback( wc: Electron.WebContents, getCaptureId: () => number, ): (samples: Float32Array) => void { return (samples: Float32Array): void => { // The addon delivers each chunk on its WASAPI capture thread — // marshal to the renderer's webContents from the main loop. If the // webContents has been destroyed (window closed during a share) // silently drop; the cleanup hook will stop the capture. if (wc.isDestroyed()) return; const captureId = getCaptureId(); const payload: AudioLoopbackChunk = { captureId, samples }; wc.send(CHANNELS.AUDIO_LOOPBACK_CHUNK, payload); }; }