fix(desktop): capture screen share audio from chosen devices (#3456)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-14 02:48:19 +02:00
1 parent 123c4d5bbf
commit 97b2ac91ca
56 files changed
+2338 -291

No files matched your search

+55 -8
View File
@@ -7,6 +7,7 @@ import type {
NativeAudioApplication,
NativeAudioAvailability,
NativeAudioEndReason,
NativeAudioPlaybackDevice,
NativeAudioStartOptions,
NativeAudioStartResult,
VirtmicLinkOptions,
@@ -22,7 +23,9 @@ import {
isValidAudioFrame,
isValidLinuxRule,
isValidTargetPid,
isValidWinEndpoints,
normalizeTimestampUs,
resolveWindowsCaptureScope,
} from '@electron/main/NativeAudioValidation';
import {ipcMain} from 'electron';
@@ -39,6 +42,7 @@ interface NativeCaptureInstance {
start(): Promise<void> | void;
stop(): Promise<void> | void;
setRoutingRule?: (target: {linuxRule: NonNullable<NativeAudioStartOptions['linuxRule']>}) => boolean;
setEndpoints?: (endpoints: Array<string>) => boolean;
}
interface WindowsNativeAudioModule {
@@ -59,12 +63,14 @@ interface WindowsNativeAudioModule {
};
pidFromHwnd: (hwnd: bigint) => number;
resolveAudioRootPid: (pid: number) => number;
listPlaybackDevices?: () => Promise<Array<NativeAudioPlaybackDevice>>;
ProcessLoopback: new (
pid: number,
options?: {
includeProcessTree?: boolean;
captureScope?: 'process' | 'system' | 'session-mixer';
winCaptureScope?: 'process' | 'system' | 'session-mixer';
endpoints?: Array<string>;
},
) => NativeCaptureInstance;
loadError?: Error | null;
@@ -270,6 +276,7 @@ function loadNativeAudioAddon(): NativeAddonLoadResult {
processInclude: false,
processExclude: false,
sessionMixer: false,
endpointSelect: false,
systemLoopbackMode: 'unavailable',
},
},
@@ -290,6 +297,7 @@ function loadNativeAudioAddon(): NativeAddonLoadResult {
processInclude,
processExclude,
sessionMixer,
endpointSelect: sessionMixer,
systemLoopbackMode:
systemLoopbackMode === 'process-exclude' || systemLoopbackMode === 'session-mixer'
? systemLoopbackMode
@@ -510,6 +518,21 @@ async function listNativeAudioApplications(): Promise<Array<NativeAudioApplicati
}
}
async function listWindowsPlaybackDevices(): Promise<Array<NativeAudioPlaybackDevice>> {
const loadResult = loadNativeAudioAddon();
if (loadResult.platform !== 'win32' || !loadResult.availability.available || !loadResult.addon) {
return [];
}
const addon = loadResult.addon as WindowsNativeAudioModule;
if (typeof addon.listPlaybackDevices !== 'function') return [];
try {
return await addon.listPlaybackDevices();
} catch (error) {
logger.warn('Failed to list Windows playback devices', error);
return [];
}
}
async function resolveAudioRootPidForSource(sourceId: unknown): Promise<number | null> {
const windowToken = parseDesktopWindowSourceToken(sourceId);
if (!windowToken) {
@@ -646,6 +669,19 @@ function reconfigureCaptureById(
}
}
function reconfigureWindowsEndpointsById(senderId: number, captureId: string, endpoints: Array<string>): boolean {
const session = activeSessions.get(captureId);
if (!session || session.sender.id !== senderId) return false;
if (session.finalized || session.stopping) return false;
if (typeof session.capture.setEndpoints !== 'function') return false;
try {
return session.capture.setEndpoints(endpoints);
} catch (error) {
logger.warn('Failed to change Windows playback devices in place', {captureId, error});
return false;
}
}
async function makeRoomForSenderSession(senderId: number): Promise<void> {
const senderSessions = activeSessionIdsBySenderId.get(senderId);
if (!senderSessions) return;
@@ -679,6 +715,10 @@ async function startNativeAudioCapture(
: 'process';
const winSystemCapture =
loadResult.platform === 'win32' && (winCaptureScope === 'system' || winCaptureScope === 'session-mixer');
const winEndpoints = loadResult.platform === 'win32' ? options?.winEndpoints : undefined;
if (winEndpoints !== undefined && !isValidWinEndpoints(winEndpoints)) {
throw new Error('Invalid Windows playback device selection');
}
const targetPid = options?.targetPid;
if (!options || (!linuxRule && !macSystemCapture && !winSystemCapture && !isValidTargetPid(targetPid))) {
throw new Error('Invalid native audio target PID');
@@ -715,13 +755,7 @@ async function startNativeAudioCapture(
if (requestedScope === 'system' && availability.capabilities?.systemExcludesSelf !== true) {
throw new Error(`Native ${loadResult.platform} system audio capture does not guarantee Fluxer self-exclusion`);
}
const effectiveWinCaptureScope =
loadResult.platform === 'win32' &&
winCaptureScope === 'system' &&
availability.capabilities?.processExclude === false &&
availability.capabilities?.sessionMixer === true
? 'session-mixer'
: winCaptureScope;
const windowsCapture = resolveWindowsCaptureScope(winCaptureScope, winEndpoints, availability.capabilities);
if (sender.isDestroyed()) {
throw new Error('Cannot start native audio capture for destroyed renderer');
}
@@ -739,7 +773,8 @@ async function startNativeAudioCapture(
if (!isValidTargetPid(targetPidForCapture)) throw new Error('Invalid native audio target PID');
capture = new (loadResult.addon as WindowsNativeAudioModule).ProcessLoopback(targetPidForCapture, {
includeProcessTree: winSystemCapture ? false : (options.includeProcessTree ?? true),
captureScope: effectiveWinCaptureScope,
captureScope: windowsCapture.captureScope,
...(windowsCapture.endpoints ? {endpoints: windowsCapture.endpoints} : {}),
});
} else if (loadResult.platform === 'darwin') {
if (!isValidTargetPid(targetPidForCapture)) throw new Error('Invalid native audio target PID');
@@ -883,6 +918,16 @@ export function registerNativeAudioHandlers(): void {
return reconfigureCaptureById(event.sender.id, captureId, linuxRule);
},
);
ipcMain.handle('native-audio:set-win-endpoints', (event, captureId: unknown, endpoints: unknown): boolean => {
if (typeof captureId !== 'string' || !isValidWinEndpoints(endpoints)) {
return false;
}
return reconfigureWindowsEndpointsById(event.sender.id, captureId, endpoints);
});
ipcMain.handle(
'native-audio:list-playback-devices',
(): Promise<Array<NativeAudioPlaybackDevice>> => listWindowsPlaybackDevices(),
);
ipcMain.handle('native-audio:stop', async (event, captureId: string): Promise<void> => {
const session = activeSessions.get(captureId);
if (!session || session.sender.id !== event.sender.id) {
@@ -899,6 +944,8 @@ export function cleanupNativeAudio(): void {
ipcMain.removeHandler('native-audio:resolve-root-pid');
ipcMain.removeHandler('native-audio:start');
ipcMain.removeHandler('native-audio:set-rule');
ipcMain.removeHandler('native-audio:set-win-endpoints');
ipcMain.removeHandler('native-audio:list-playback-devices');
ipcMain.removeHandler('native-audio:stop');
handlersRegistered = false;
for (const session of [...activeSessions.values()]) {