import { ChildProcess, execFile, spawn } from 'child_process'; import { randomUUID } from 'crypto'; import { WebContents } from 'electron'; import { promisify } from 'util'; const execFileAsync = promisify(execFile); const SCREEN_SHARE_SINK_NAME = 'metoyou_screenshare_sink'; const SCREEN_SHARE_MONITOR_SOURCE_NAME = `${SCREEN_SHARE_SINK_NAME}.monitor`; const VOICE_SINK_NAME = 'metoyou_voice_sink'; const REROUTE_INTERVAL_MS = 750; const MONITOR_CAPTURE_SAMPLE_RATE = 48_000; const MONITOR_CAPTURE_CHANNEL_COUNT = 2; const MONITOR_CAPTURE_BITS_PER_SAMPLE = 16; const MONITOR_CAPTURE_STOP_TIMEOUT_MS = 1_000; const MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk'; const MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended'; interface ShortSinkEntry { index: string; name: string; } interface ShortSinkInputEntry { index: string; sinkIndex: string; } interface SinkInputDetails extends ShortSinkInputEntry { properties: Record; } interface DescendantProcessInfo { ids: ReadonlySet; binaryNames: ReadonlySet; } interface PactlJsonSinkInputEntry { index?: number | string; properties?: Record; sink?: number | string; } interface LinuxScreenShareAudioRoutingState { active: boolean; restoreSinkName: string | null; screenShareLoopbackModuleId: string | null; voiceLoopbackModuleId: string | null; rerouteIntervalId: ReturnType | null; subscribeProcess: ChildProcess | null; } interface LinuxScreenShareMonitorCaptureState { captureId: string | null; process: ChildProcess | null; stderr: string; stopRequested: boolean; targetWebContents: WebContents | null; } export interface LinuxScreenShareAudioRoutingInfo { available: boolean; active: boolean; monitorCaptureSupported: boolean; screenShareSinkName: string; screenShareMonitorSourceName: string; voiceSinkName: string; reason?: string; } export interface LinuxScreenShareMonitorCaptureInfo { bitsPerSample: number; captureId: string; channelCount: number; sampleRate: number; sourceName: string; } const routingState: LinuxScreenShareAudioRoutingState = { active: false, restoreSinkName: null, screenShareLoopbackModuleId: null, voiceLoopbackModuleId: null, rerouteIntervalId: null, subscribeProcess: null }; const monitorCaptureState: LinuxScreenShareMonitorCaptureState = { captureId: null, process: null, stderr: '', stopRequested: false, targetWebContents: null }; let pactlAvailableCache: boolean | null = null; export async function prepareLinuxScreenShareAudioRouting(): Promise { if (process.platform !== 'linux') { return buildRoutingInfo(false, false, 'Linux-only audio routing is unavailable on this platform.'); } if (!await isPactlAvailable()) { return buildRoutingInfo(false, false, 'pactl is unavailable; falling back to standard desktop audio capture.'); } await ensureNullSink(SCREEN_SHARE_SINK_NAME); await ensureNullSink(VOICE_SINK_NAME); return buildRoutingInfo(true, routingState.active); } export async function activateLinuxScreenShareAudioRouting(): Promise { const prepared = await prepareLinuxScreenShareAudioRouting(); if (!prepared.available) { return prepared; } if (routingState.active) { return buildRoutingInfo(true, true); } const restoreSinkName = await getPreferredRestoreSinkName(); if (!restoreSinkName) { return buildRoutingInfo(false, false, 'Unable to determine a playback sink for Linux screen-share audio routing.'); } try { routingState.restoreSinkName = restoreSinkName; routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName); routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName); // Set the default sink to the voice sink so that new app audio // streams (received WebRTC voice) never land on the screenshare // capture sink. This prevents the feedback loop where remote // voice audio was picked up by parec before the reroute interval // could move the stream away. await setDefaultSink(VOICE_SINK_NAME); routingState.active = true; // Let the combined reroute decide placement for every existing // stream. This avoids briefly shoving the app's own playback to the // screenshare sink before ownership detection can move it back. await rerouteSinkInputs(); startSinkInputRerouteLoop(); startSubscribeWatcher(); return buildRoutingInfo(true, true); } catch (error) { await deactivateLinuxScreenShareAudioRouting(); return buildRoutingInfo( false, false, error instanceof Error ? error.message : 'Failed to activate Linux screen-share audio routing.' ); } } export async function deactivateLinuxScreenShareAudioRouting(): Promise { const restoreSinkName = routingState.restoreSinkName; stopSubscribeWatcher(); stopSinkInputRerouteLoop(); await stopLinuxScreenShareMonitorCapture(); try { if (restoreSinkName) { await setDefaultSink(restoreSinkName); await moveSinkInputs(restoreSinkName, (sinkName) => sinkName === SCREEN_SHARE_SINK_NAME || sinkName === VOICE_SINK_NAME); } } catch { // Best-effort cleanup only. } await Promise.all([unloadModuleIfLoaded(routingState.screenShareLoopbackModuleId), unloadModuleIfLoaded(routingState.voiceLoopbackModuleId)]); routingState.active = false; routingState.restoreSinkName = null; routingState.screenShareLoopbackModuleId = null; routingState.voiceLoopbackModuleId = null; routingState.subscribeProcess = null; return true; } export async function cleanupLinuxScreenShareAudioRouting(): Promise { await deactivateLinuxScreenShareAudioRouting(); } export async function startLinuxScreenShareMonitorCapture( targetWebContents: WebContents ): Promise { if (process.platform !== 'linux') { throw new Error('Linux screen-share monitor capture is unavailable on this platform.'); } if (!routingState.active) { throw new Error('Linux screen-share audio routing must be active before monitor capture starts.'); } await stopLinuxScreenShareMonitorCapture(); const captureId = randomUUID(); const captureProcess = spawn('parec', [ '--device', SCREEN_SHARE_MONITOR_SOURCE_NAME, '--raw', '--format=s16le', '--rate', `${MONITOR_CAPTURE_SAMPLE_RATE}`, '--channels', `${MONITOR_CAPTURE_CHANNEL_COUNT}` ], { env: process.env, stdio: [ 'ignore', 'pipe', 'pipe' ] }); monitorCaptureState.captureId = captureId; monitorCaptureState.process = captureProcess; monitorCaptureState.stderr = ''; monitorCaptureState.stopRequested = false; monitorCaptureState.targetWebContents = targetWebContents; let started = false; const startPromise = new Promise((resolve, reject) => { const onError = (error: Error): void => { if (!started) { cleanupMonitorCaptureState(captureId, error.message); reject(error); return; } cleanupMonitorCaptureState(captureId, error.message); }; captureProcess.on('error', onError); captureProcess.once('spawn', () => { started = true; resolve(); }); }); captureProcess.stdout.on('data', (chunk: Buffer) => { if (monitorCaptureState.captureId !== captureId) { return; } const target = monitorCaptureState.targetWebContents; if (!target || target.isDestroyed()) { return; } target.send(MONITOR_AUDIO_CHUNK_CHANNEL, { captureId, chunk: Uint8Array.from(chunk) }); }); captureProcess.stderr.on('data', (chunk: Buffer) => { if (monitorCaptureState.captureId !== captureId) { return; } const nextStderr = `${monitorCaptureState.stderr}${chunk.toString()}`; monitorCaptureState.stderr = nextStderr.slice(-4_096); }); captureProcess.once('close', (code, signal) => { const reason = buildMonitorCaptureCloseReason(captureId, code, signal); cleanupMonitorCaptureState(captureId, reason); }); await startPromise; return { bitsPerSample: MONITOR_CAPTURE_BITS_PER_SAMPLE, captureId, channelCount: MONITOR_CAPTURE_CHANNEL_COUNT, sampleRate: MONITOR_CAPTURE_SAMPLE_RATE, sourceName: SCREEN_SHARE_MONITOR_SOURCE_NAME }; } export async function stopLinuxScreenShareMonitorCapture(captureId?: string): Promise { if (!monitorCaptureState.captureId || !monitorCaptureState.process) { return true; } if (captureId && captureId !== monitorCaptureState.captureId) { return false; } const currentCaptureId = monitorCaptureState.captureId; const captureProcess = monitorCaptureState.process; monitorCaptureState.stopRequested = true; await new Promise((resolve) => { const forceKillTimeout = setTimeout(() => { if (!captureProcess.killed) { captureProcess.kill('SIGKILL'); } }, MONITOR_CAPTURE_STOP_TIMEOUT_MS); captureProcess.once('close', () => { clearTimeout(forceKillTimeout); resolve(); }); if (!captureProcess.killed) { captureProcess.kill('SIGTERM'); return; } clearTimeout(forceKillTimeout); resolve(); }); return monitorCaptureState.captureId !== currentCaptureId; } function buildRoutingInfo( available: boolean, active: boolean, reason?: string ): LinuxScreenShareAudioRoutingInfo { return { available, active, monitorCaptureSupported: true, screenShareSinkName: SCREEN_SHARE_SINK_NAME, screenShareMonitorSourceName: SCREEN_SHARE_MONITOR_SOURCE_NAME, voiceSinkName: VOICE_SINK_NAME, ...(reason ? { reason } : {}) }; } async function isPactlAvailable(): Promise { if (pactlAvailableCache !== null) { return pactlAvailableCache; } try { await runPactl('info'); pactlAvailableCache = true; } catch { pactlAvailableCache = false; } return pactlAvailableCache; } async function runPactl(...args: string[]): Promise { const { stdout } = await execFileAsync('pactl', args, { env: process.env }); return stdout.trim(); } async function ensureNullSink(sinkName: string): Promise { const sinks = await listSinks(); if (sinks.some((sink) => sink.name === sinkName)) { return; } await runPactl( 'load-module', 'module-null-sink', `sink_name=${sinkName}`, `sink_properties=device.description=${sinkName}` ); } async function loadLoopbackModule(sourceName: string, sinkName: string): Promise { const moduleId = await runPactl( 'load-module', 'module-loopback', `source=${sourceName}`, `sink=${sinkName}`, 'latency_msec=10', 'source_dont_move=true', 'sink_dont_move=true' ); return moduleId.split(/\s+/)[0] || moduleId; } async function unloadModuleIfLoaded(moduleId: string | null): Promise { if (!moduleId) { return; } try { await runPactl('unload-module', moduleId); } catch { // Module may have already been unloaded externally. } } async function getPreferredRestoreSinkName(): Promise { const defaultSinkName = await getDefaultSinkName(); if (defaultSinkName && defaultSinkName !== SCREEN_SHARE_SINK_NAME && defaultSinkName !== VOICE_SINK_NAME) { return defaultSinkName; } const sinks = await listSinks(); return sinks.find((sink) => sink.name !== SCREEN_SHARE_SINK_NAME && sink.name !== VOICE_SINK_NAME)?.name ?? null; } async function getDefaultSinkName(): Promise { const info = await runPactl('info'); const defaultSinkLine = info .split(/\r?\n/) .find((line) => line.startsWith('Default Sink:')); if (!defaultSinkLine) { return null; } const sinkName = defaultSinkLine.replace('Default Sink:', '').trim(); return sinkName || null; } async function setDefaultSink(sinkName: string): Promise { await runPactl('set-default-sink', sinkName); } /** * Combined reroute that enforces sink placement in both directions: * - App-owned sink inputs that are NOT on the voice sink are moved there. * - Non-app sink inputs that ARE on the voice sink are moved to the * screenshare sink so they are captured by parec. * * This two-way approach, combined with the voice sink being the PulseAudio * default, ensures that received WebRTC voice audio can never leak into the * screenshare monitor source. */ async function rerouteSinkInputs(): Promise { const [ sinks, sinkInputs, descendantProcessInfo ] = await Promise.all([ listSinks(), listSinkInputDetails(), collectDescendantProcessInfo(process.pid) ]); const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name])); await Promise.all( sinkInputs.map(async (sinkInput) => { const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null; const appOwned = isAppOwnedSinkInput(sinkInput, descendantProcessInfo); // App-owned streams must stay on the voice sink. if (appOwned && sinkName !== VOICE_SINK_NAME) { try { await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME); } catch { // Streams can disappear or be recreated while rerouting. } return; } // Non-app streams sitting on the voice sink should be moved to the // screenshare sink for desktop-audio capture. if (!appOwned && sinkName === VOICE_SINK_NAME) { try { await runPactl('move-sink-input', sinkInput.index, SCREEN_SHARE_SINK_NAME); } catch { // Streams can disappear or be recreated while rerouting. } } }) ); } function cleanupMonitorCaptureState(captureId: string, reason?: string): void { if (monitorCaptureState.captureId !== captureId) { return; } const target = monitorCaptureState.targetWebContents; monitorCaptureState.captureId = null; monitorCaptureState.process = null; monitorCaptureState.stderr = ''; monitorCaptureState.stopRequested = false; monitorCaptureState.targetWebContents = null; if (!target || target.isDestroyed()) { return; } target.send(MONITOR_AUDIO_ENDED_CHANNEL, { captureId, ...(reason ? { reason } : {}) }); } function buildMonitorCaptureCloseReason( captureId: string, code: number | null, signal: NodeJS.Signals | null ): string | undefined { if (monitorCaptureState.captureId !== captureId) { return undefined; } if (monitorCaptureState.stopRequested) { return undefined; } if (monitorCaptureState.stderr.trim()) { return monitorCaptureState.stderr.trim(); } if (signal) { return `Linux screen-share monitor capture stopped with signal ${signal}.`; } if (typeof code === 'number' && code !== 0) { return `Linux screen-share monitor capture exited with code ${code}.`; } return undefined; } function startSinkInputRerouteLoop(): void { if (routingState.rerouteIntervalId) { return; } routingState.rerouteIntervalId = setInterval(() => { void rerouteSinkInputs(); }, REROUTE_INTERVAL_MS); } function stopSinkInputRerouteLoop(): void { if (!routingState.rerouteIntervalId) { return; } clearInterval(routingState.rerouteIntervalId); routingState.rerouteIntervalId = null; } /** * Spawns `pactl subscribe` to receive PulseAudio events in real time. * When a new or changed sink-input is detected, a reroute is triggered * immediately instead of waiting for the next interval tick. This * drastically reduces the time non-app desktop audio spends on the * voice sink before being moved to the screenshare sink. */ function startSubscribeWatcher(): void { if (routingState.subscribeProcess) { return; } let proc: ChildProcess; try { proc = spawn('pactl', ['subscribe'], { env: process.env, stdio: [ 'ignore', 'pipe', 'ignore' ] }); } catch { // If pactl subscribe fails to spawn, the interval loop still covers us. return; } routingState.subscribeProcess = proc; let pending = false; proc.stdout?.on('data', (chunk: Buffer) => { if (!routingState.active) { return; } const text = chunk.toString(); if (/Event '(?:new|change)' on sink-input/.test(text)) { if (!pending) { pending = true; // Batch rapid-fire events with a short delay. setTimeout(() => { pending = false; void rerouteSinkInputs(); }, 50); } } }); proc.on('close', () => { if (routingState.subscribeProcess === proc) { routingState.subscribeProcess = null; } }); proc.on('error', () => { if (routingState.subscribeProcess === proc) { routingState.subscribeProcess = null; } }); } function stopSubscribeWatcher(): void { const proc = routingState.subscribeProcess; if (!proc) { return; } routingState.subscribeProcess = null; if (!proc.killed) { proc.kill('SIGTERM'); } } function isAppOwnedSinkInput( sinkInput: SinkInputDetails, descendantProcessInfo: DescendantProcessInfo ): boolean { const processId = sinkInput.properties['application.process.id']; if (typeof processId === 'string' && descendantProcessInfo.ids.has(processId)) { return true; } const processBinary = normalizeProcessBinary(sinkInput.properties['application.process.binary']); if (processBinary && descendantProcessInfo.binaryNames.has(processBinary)) { return true; } const applicationName = normalizeProcessBinary(sinkInput.properties['application.name']); if (applicationName && descendantProcessInfo.binaryNames.has(applicationName)) { return true; } return false; } async function moveSinkInputs( targetSinkName: string, shouldMove: (sinkName: string | null) => boolean ): Promise { const [sinks, sinkInputs] = await Promise.all([listSinks(), listSinkInputs()]); const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name])); await Promise.all( sinkInputs.map(async (sinkInput) => { const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null; if (!shouldMove(sinkName)) { return; } try { await runPactl('move-sink-input', sinkInput.index, targetSinkName); } catch { // Streams can disappear while iterating. } }) ); } async function listSinks(): Promise { const output = await runPactl('list', 'short', 'sinks'); return output .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => line.split(/\s+/)) .filter((columns) => columns.length >= 2) .map((columns) => ({ index: columns[0], name: columns[1] })); } async function listSinkInputs(): Promise { const output = await runPactl('list', 'short', 'sink-inputs'); return output .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => line.split(/\s+/)) .filter((columns) => columns.length >= 2) .map((columns) => ({ index: columns[0], sinkIndex: columns[1] })); } async function listSinkInputDetails(): Promise { try { const output = await runPactl('--format=json', 'list', 'sink-inputs'); const entries = JSON.parse(output) as PactlJsonSinkInputEntry[]; if (!Array.isArray(entries)) { return []; } return entries .map((entry) => { const index = typeof entry.index === 'number' || typeof entry.index === 'string' ? `${entry.index}` : ''; const sinkIndex = typeof entry.sink === 'number' || typeof entry.sink === 'string' ? `${entry.sink}` : ''; const properties = Object.fromEntries( Object.entries(entry.properties || {}).map(([key, value]) => [key, typeof value === 'string' ? value : `${value ?? ''}`]) ); return { index, sinkIndex, properties } satisfies SinkInputDetails; }) .filter((entry) => !!entry.index && !!entry.sinkIndex); } catch { // Fall back to the legacy text format parser below. } const output = await runPactl('list', 'sink-inputs'); const entries: SinkInputDetails[] = []; let currentEntry: SinkInputDetails | null = null; let parsingProperties = false; const pushCurrentEntry = (): void => { if (currentEntry) { entries.push(currentEntry); } }; for (const rawLine of output.split(/\r?\n/)) { const sinkInputMatch = rawLine.match(/^Sink Input #(\d+)/); if (sinkInputMatch) { pushCurrentEntry(); currentEntry = { index: sinkInputMatch[1], sinkIndex: '', properties: {} }; parsingProperties = false; continue; } if (!currentEntry) { continue; } const sinkMatch = rawLine.match(/^\s*Sink:\s*(\d+)/); if (sinkMatch) { currentEntry.sinkIndex = sinkMatch[1]; continue; } if (/^\s*Properties:\s*$/.test(rawLine)) { parsingProperties = true; continue; } if (!parsingProperties) { continue; } if (rawLine.trim().length === 0) { parsingProperties = false; continue; } const propertyLine = rawLine.trim(); const separatorIndex = propertyLine.indexOf(' = '); if (separatorIndex === -1) { if (/^\S/.test(rawLine) || /^\s+\S[^=]*:\s*$/.test(rawLine)) { parsingProperties = false; } continue; } const key = propertyLine.slice(0, separatorIndex).trim(); const rawValue = propertyLine.slice(separatorIndex + 3).trim(); currentEntry.properties[key] = stripSurroundingQuotes(rawValue); } pushCurrentEntry(); return entries.filter((entry) => !!entry.sinkIndex); } async function collectDescendantProcessInfo(rootProcessId: number): Promise { const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,comm='], { env: process.env }); const childrenByParentId = new Map(); const binaryNameByProcessId = new Map(); stdout .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .forEach((line) => { const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/); if (!match) { return; } const [ , pid, ppid, command ] = match; const siblings = childrenByParentId.get(ppid) ?? []; siblings.push(pid); childrenByParentId.set(ppid, siblings); const normalizedBinaryName = normalizeProcessBinary(command); if (normalizedBinaryName) { binaryNameByProcessId.set(pid, normalizedBinaryName); } }); const rootId = `${rootProcessId}`; const descendantIds = new Set([rootId]); const descendantBinaryNames = new Set(); const queue = [rootId]; while (queue.length > 0) { const currentId = queue.shift(); if (!currentId) { continue; } const binaryName = binaryNameByProcessId.get(currentId); if (binaryName) { descendantBinaryNames.add(binaryName); } for (const childId of childrenByParentId.get(currentId) ?? []) { if (descendantIds.has(childId)) { continue; } descendantIds.add(childId); queue.push(childId); } } return { ids: descendantIds, binaryNames: descendantBinaryNames }; } function normalizeProcessBinary(value: string | undefined): string | null { if (!value) { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } const basename = trimmed .split(/[\\/]/) .pop() ?.trim() .toLowerCase() ?? ''; return basename || null; } function stripSurroundingQuotes(value: string): string { if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) { return value.slice(1, -1); } return value; }