mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
752 lines
26 KiB
TypeScript
752 lines
26 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
|
|
import {isLimitToggleEnabled} from '@app/features/app/utils/LimitUtils';
|
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
|
import {getElectronAPI, supportsDesktopScreenShareAudioCapture} from '@app/features/ui/utils/NativeUtils';
|
|
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
|
|
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
|
import ScreenShareCodecNegotiation from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
|
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
|
import {clearDesktopSourceIntent, setDesktopSourceIntent} from '@app/features/voice/state/DesktopSourceIntent';
|
|
import LocalVoiceState from '@app/features/voice/state/LocalVoiceState';
|
|
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
|
import {
|
|
resolveScreenShareContentHintForContext,
|
|
type ScreenShareContentSource,
|
|
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
|
import {
|
|
LINUX_AUDIO_TARGET_OBJECTS_PATTERN_KEY,
|
|
toNativeLinuxAudioPatterns,
|
|
} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
|
import {disarmVirtmic} from '@app/features/voice/utils/LinuxScreenShareAudio';
|
|
import {
|
|
armNativeAudioForLinuxRouting,
|
|
armNativeAudioForNextCapture,
|
|
armNativeSystemAudioForNextCapture,
|
|
disarmNativeAudio,
|
|
disarmPendingNativeAudio,
|
|
getLastNativeAudioArmFailure,
|
|
} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
|
import {
|
|
type ScreenShareAudioCaptureDebugInfo,
|
|
ScreenShareAudioCaptureError,
|
|
} from '@app/features/voice/utils/ScreenShareAudioCaptureError';
|
|
import {
|
|
type DisplayShareEnvironment,
|
|
getDisplayShareEnvironment,
|
|
usesNativeDisplayShareAudioSelection,
|
|
} from '@app/features/voice/utils/ScreenShareEnvironment';
|
|
import {
|
|
buildScreenShareOptions,
|
|
normaliseResolutionForContext,
|
|
normaliseStreamingModeForContext,
|
|
resolveStreamingModeSettings,
|
|
type ScreenShareContext,
|
|
} from '@app/features/voice/utils/ScreenShareOptions';
|
|
import {
|
|
isScreenSharePortalUnavailableError,
|
|
ScreenSharePortalUnavailableError,
|
|
} from '@app/features/voice/utils/ScreenSharePortalUnavailableError';
|
|
import {executeScreenShareOperation} from '@app/features/voice/utils/ScreenShareUtils';
|
|
import type {NativeAudioStartOptions, VirtmicNode} from '@app/types/electron.d';
|
|
import type {ScreenShareCaptureOptions, VideoCodec} from 'livekit-client';
|
|
|
|
const logger = new Logger('ScreenShareStartFlow');
|
|
|
|
type LinuxNativeAudioRule = NonNullable<NativeAudioStartOptions['linuxRule']>;
|
|
|
|
interface LinuxAudioLinkOptions {
|
|
workaround: boolean;
|
|
ignoreInputMedia: boolean;
|
|
ignoreVirtual: boolean;
|
|
ignoreDevices: boolean;
|
|
}
|
|
|
|
function getLinkOptions(): LinuxAudioLinkOptions {
|
|
return {
|
|
workaround: VoiceSettings.getLinuxAudioCaptureWorkaround(),
|
|
ignoreInputMedia: VoiceSettings.getLinuxAudioCaptureIgnoreInputMedia(),
|
|
ignoreVirtual: VoiceSettings.getLinuxAudioCaptureIgnoreVirtual(),
|
|
ignoreDevices: VoiceSettings.getLinuxAudioCaptureIgnoreDevices(),
|
|
};
|
|
}
|
|
|
|
function getSystemOptions() {
|
|
return {
|
|
...getLinkOptions(),
|
|
onlySpeakers: VoiceSettings.getLinuxAudioCaptureOnlySpeakers(),
|
|
onlyDefaultSpeakers: VoiceSettings.getLinuxAudioCaptureOnlyDefaultSpeakers(),
|
|
};
|
|
}
|
|
|
|
function withNativeAudioExcludes(exclude: Array<VirtmicNode>, options: LinuxAudioLinkOptions): Array<VirtmicNode> {
|
|
const next = toNativeLinuxAudioPatterns(exclude);
|
|
if (options.ignoreVirtual) {
|
|
next.push({'node.virtual': 'true'});
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function buildLinuxNativeAudioRule(
|
|
sourceMode: 'system' | 'specific',
|
|
userIncludeSources: Array<VirtmicNode>,
|
|
userExcludeSources: Array<VirtmicNode>,
|
|
): LinuxNativeAudioRule {
|
|
const linkOptions = getLinkOptions();
|
|
const nativeIncludeSources = toNativeLinuxAudioPatterns(userIncludeSources);
|
|
if (sourceMode === 'specific' && nativeIncludeSources.length > 0) {
|
|
const includesDeviceTarget = nativeIncludeSources.some(
|
|
(source) => LINUX_AUDIO_TARGET_OBJECTS_PATTERN_KEY in source,
|
|
);
|
|
return {
|
|
include: nativeIncludeSources,
|
|
exclude: withNativeAudioExcludes([], linkOptions),
|
|
ignoreDevices: includesDeviceTarget ? false : linkOptions.ignoreDevices,
|
|
};
|
|
}
|
|
const systemOptions = getSystemOptions();
|
|
return {
|
|
include: [],
|
|
exclude: withNativeAudioExcludes(userExcludeSources, linkOptions),
|
|
ignoreDevices: systemOptions.ignoreDevices,
|
|
onlySpeakers: systemOptions.onlySpeakers,
|
|
onlyDefaultSpeakers: systemOptions.onlyDefaultSpeakers,
|
|
};
|
|
}
|
|
|
|
export async function reconfigureActiveLinuxScreenShareAudioLink(): Promise<boolean> {
|
|
const electronApi = getElectronAPI();
|
|
const virtmicApi = electronApi?.virtmic;
|
|
if (!electronApi || electronApi.platform !== 'linux') {
|
|
return false;
|
|
}
|
|
const sourceMode = VoiceSettings.getScreenShareAudioSourceMode();
|
|
const userIncludeSources = VoiceSettings.getScreenShareAudioIncludeSources().map((entry) => ({...entry}));
|
|
const userExcludeSources = VoiceSettings.getScreenShareAudioExcludeSources().map((entry) => ({...entry}));
|
|
if (sourceMode === 'none') {
|
|
disarmVirtmic();
|
|
disarmNativeAudio();
|
|
await virtmicApi?.stop();
|
|
return true;
|
|
}
|
|
const nativeRule = buildLinuxNativeAudioRule(sourceMode, userIncludeSources, userExcludeSources);
|
|
if (await MediaEngine.ensureLinuxScreenShareAudioPublication(nativeRule).catch(() => false)) {
|
|
disarmVirtmic();
|
|
await virtmicApi?.stop();
|
|
return true;
|
|
}
|
|
disarmNativeAudio();
|
|
return false;
|
|
}
|
|
|
|
export async function stopActiveLinuxScreenShareAudioLink(): Promise<boolean> {
|
|
const electronApi = getElectronAPI();
|
|
const virtmicApi = electronApi?.virtmic;
|
|
if (!electronApi || electronApi.platform !== 'linux') {
|
|
return false;
|
|
}
|
|
disarmVirtmic();
|
|
disarmNativeAudio();
|
|
await virtmicApi?.stop();
|
|
return true;
|
|
}
|
|
|
|
function hasHigherVideoQuality(): boolean {
|
|
return isLimitToggleEnabled(
|
|
{
|
|
feature_higher_video_quality: LimitResolver.resolve({
|
|
key: 'feature_higher_video_quality',
|
|
fallback: 0,
|
|
}),
|
|
},
|
|
'feature_higher_video_quality',
|
|
);
|
|
}
|
|
|
|
function didScreenShareStart(): boolean {
|
|
return Boolean(MediaEngine.room?.localParticipant?.isScreenShareEnabled || LocalVoiceState.getSelfStream());
|
|
}
|
|
|
|
function getScreenShareContentSource(
|
|
shareContext: ScreenShareContext,
|
|
preferredDisplaySurface?: 'window' | 'monitor',
|
|
): ScreenShareContentSource {
|
|
if (shareContext === 'device') return 'device';
|
|
if (preferredDisplaySurface === 'window') return 'app';
|
|
return 'display';
|
|
}
|
|
|
|
export function normaliseDeviceScreenShareSettings(): void {
|
|
const nextStreamingMode = normaliseStreamingModeForContext(VoiceSettings.getStreamingMode(), 'device');
|
|
const nextResolution = normaliseResolutionForContext(
|
|
VoiceSettings.getScreenshareResolution(),
|
|
'device',
|
|
hasHigherVideoQuality(),
|
|
);
|
|
if (
|
|
nextStreamingMode === VoiceSettings.getStreamingMode() &&
|
|
nextResolution === VoiceSettings.getScreenshareResolution()
|
|
) {
|
|
return;
|
|
}
|
|
VoiceSettingsCommands.update({
|
|
streamingMode: nextStreamingMode,
|
|
screenshareResolution: nextResolution,
|
|
});
|
|
}
|
|
|
|
function shouldIncludeAudioForShare(
|
|
shareContext: ScreenShareContext,
|
|
displayShareEnvironment: DisplayShareEnvironment,
|
|
sourceId?: string | null,
|
|
preferredDisplaySurface?: 'window' | 'monitor',
|
|
): boolean {
|
|
if (shareContext === 'device') {
|
|
return VoiceSettings.getShareDeviceAudio();
|
|
}
|
|
if (displayShareEnvironment === 'web') {
|
|
return supportsDesktopScreenShareAudioCapture();
|
|
}
|
|
if (sourceId?.startsWith('window:')) {
|
|
return supportsDesktopScreenShareAudioCapture() && VoiceSettings.getShareAppAudio();
|
|
}
|
|
if (sourceId?.startsWith('screen:')) {
|
|
return supportsDesktopScreenShareAudioCapture() && VoiceSettings.getShareDesktopAudio();
|
|
}
|
|
if (preferredDisplaySurface === 'window') {
|
|
return supportsDesktopScreenShareAudioCapture() && VoiceSettings.getShareAppAudio();
|
|
}
|
|
if (shareContext === 'display' && usesNativeDisplayShareAudioSelection(displayShareEnvironment)) {
|
|
return supportsDesktopScreenShareAudioCapture() && VoiceSettings.getShareDesktopAudio();
|
|
}
|
|
return supportsDesktopScreenShareAudioCapture() && VoiceSettings.getShareDesktopAudio();
|
|
}
|
|
|
|
function removeAudioFromCaptureOptions(captureOptions: ScreenShareCaptureOptions): void {
|
|
captureOptions.audio = false;
|
|
captureOptions.systemAudio = 'exclude';
|
|
captureOptions.windowAudio = 'exclude';
|
|
}
|
|
|
|
function buildAudioCaptureFailureDebug(
|
|
overrides: {
|
|
platform?: string | null;
|
|
sourceId?: string | null;
|
|
sourceMode?: string | null;
|
|
reason?: string | null;
|
|
detail?: string | null;
|
|
} = {},
|
|
): ScreenShareAudioCaptureDebugInfo {
|
|
return {
|
|
platform: overrides.platform ?? getElectronAPI()?.platform ?? null,
|
|
...getLastNativeAudioArmFailure(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function degradeAudioToVideoOnly(
|
|
captureOptions: ScreenShareCaptureOptions,
|
|
debugInfo: ScreenShareAudioCaptureDebugInfo,
|
|
): void {
|
|
logger.warn('Screen share audio capture unavailable; proceeding with video only', debugInfo);
|
|
removeAudioFromCaptureOptions(captureOptions);
|
|
}
|
|
|
|
function failRequestedAudioCapture(debugInfo: ScreenShareAudioCaptureDebugInfo): never {
|
|
logger.warn('Screen share audio capture was requested but could not start', debugInfo);
|
|
throw new ScreenShareAudioCaptureError(debugInfo);
|
|
}
|
|
|
|
function cleanupNativeAudioAfterCaptureDidNotStart(mode: 'start' | 'switch'): void {
|
|
if (mode === 'switch') {
|
|
disarmPendingNativeAudio();
|
|
return;
|
|
}
|
|
disarmNativeAudio();
|
|
}
|
|
|
|
function getConfiguredScreenShareOptions(
|
|
shareContext: ScreenShareContext,
|
|
displayShareEnvironment: DisplayShareEnvironment,
|
|
sourceDimensions?: {
|
|
width: number;
|
|
height: number;
|
|
},
|
|
sourceId?: string | null,
|
|
preferredDisplaySurface?: 'window' | 'monitor',
|
|
videoCodec?: VideoCodec,
|
|
includeAudioOverride?: boolean,
|
|
) {
|
|
const currentResolution = VoiceSettings.getScreenshareResolution();
|
|
const currentStreamingMode = VoiceSettings.getStreamingMode();
|
|
const higherQuality = hasHigherVideoQuality();
|
|
const normalisedStreamingMode = normaliseStreamingModeForContext(currentStreamingMode, shareContext);
|
|
const normalisedResolution = normaliseResolutionForContext(currentResolution, shareContext, higherQuality);
|
|
const codecPreference = VoiceSettings.getPreferredScreenShareCodec();
|
|
const preferredVideoCodec = videoCodec ?? ScreenShareCodecNegotiation.selectScreenShareCodec(codecPreference);
|
|
const contentHint = resolveScreenShareContentHintForContext(
|
|
VoiceSettings.getScreenShareContentHintOverride(),
|
|
preferredVideoCodec,
|
|
getScreenShareContentSource(shareContext, preferredDisplaySurface),
|
|
normalisedStreamingMode,
|
|
);
|
|
const {resolution, frameRate} = resolveStreamingModeSettings(
|
|
normalisedStreamingMode,
|
|
normalisedResolution,
|
|
VoiceSettings.getVideoFrameRate(),
|
|
higherQuality,
|
|
);
|
|
const includeAudio =
|
|
includeAudioOverride ??
|
|
shouldIncludeAudioForShare(shareContext, displayShareEnvironment, sourceId, preferredDisplaySurface);
|
|
if (!includeAudio && shareContext !== 'device' && supportsDesktopScreenShareAudioCapture()) {
|
|
logger.info('Screen share audio not requested for this surface', {
|
|
sourceId,
|
|
preferredDisplaySurface,
|
|
shareAppAudio: VoiceSettings.getShareAppAudio(),
|
|
shareDesktopAudio: VoiceSettings.getShareDesktopAudio(),
|
|
});
|
|
}
|
|
const {captureOptions, publishOptions} = buildScreenShareOptions({
|
|
resolution,
|
|
frameRate,
|
|
includeAudio,
|
|
streamingMode: normalisedStreamingMode,
|
|
contentHint,
|
|
sourceDimensions,
|
|
preferredDisplaySurface,
|
|
useBrowserAudioPicker: displayShareEnvironment === 'web',
|
|
});
|
|
publishOptions.videoCodec = preferredVideoCodec;
|
|
return {
|
|
captureOptions,
|
|
publishOptions,
|
|
includeAudio,
|
|
audioDeviceId: includeAudio ? VoiceSettings.getEffectiveScreenShareAudioDeviceId() || undefined : undefined,
|
|
};
|
|
}
|
|
|
|
export interface ConfiguredDisplayScreenShareOptions {
|
|
sourceDimensions?: {
|
|
width: number;
|
|
height: number;
|
|
};
|
|
preferredDisplaySurface?: 'window' | 'monitor';
|
|
isOwnWindow?: boolean;
|
|
includeAudio?: boolean;
|
|
}
|
|
|
|
async function runConfiguredDisplayScreenShare(
|
|
sourceId?: string | null,
|
|
options?: ConfiguredDisplayScreenShareOptions,
|
|
mode: 'start' | 'switch' = 'start',
|
|
): Promise<boolean> {
|
|
const electronApi = getElectronAPI();
|
|
const displayShareEnvironment = await getDisplayShareEnvironment();
|
|
const useWaylandPortal = displayShareEnvironment === 'desktop-wayland';
|
|
const {
|
|
captureOptions,
|
|
publishOptions,
|
|
includeAudio: requestedAudio,
|
|
} = getConfiguredScreenShareOptions(
|
|
'display',
|
|
displayShareEnvironment,
|
|
options?.sourceDimensions,
|
|
sourceId,
|
|
options?.preferredDisplaySurface,
|
|
undefined,
|
|
options?.includeAudio,
|
|
);
|
|
if (electronApi) {
|
|
const restartWaylandPortalForSwitch = useWaylandPortal && mode === 'switch';
|
|
if (!useWaylandPortal && !sourceId) {
|
|
logger.warn('No desktop source selected for display share');
|
|
return false;
|
|
}
|
|
if (restartWaylandPortalForSwitch) {
|
|
if (!didScreenShareStart()) {
|
|
logger.warn('No active screen share to restart for Wayland portal source switch');
|
|
return false;
|
|
}
|
|
await MediaEngine.setScreenShareEnabled(false, {
|
|
sendUpdate: false,
|
|
playSound: false,
|
|
preserveStreamAudioPreferences: true,
|
|
});
|
|
}
|
|
let nativeAudioArmed = false;
|
|
const isOwnWindowShare = options?.isOwnWindow === true && sourceId?.startsWith('window:');
|
|
if (isOwnWindowShare && requestedAudio) {
|
|
logger.warn('Fluxer-owned window audio is excluded from screen share capture; continuing video-only', {
|
|
sourceId,
|
|
platform: electronApi.platform,
|
|
});
|
|
removeAudioFromCaptureOptions(captureOptions);
|
|
}
|
|
const requestedAppAudioOnLinux =
|
|
requestedAudio && !isOwnWindowShare && electronApi.platform === 'linux' && sourceId?.startsWith('window:');
|
|
const requestedDesktopAudio =
|
|
requestedAudio && (sourceId?.startsWith('screen:') || (useWaylandPortal && VoiceSettings.getShareDesktopAudio()));
|
|
const requestedNativeDesktopAudio =
|
|
requestedDesktopAudio &&
|
|
(electronApi.platform === 'darwin' || electronApi.platform === 'win32') &&
|
|
sourceId?.startsWith('screen:');
|
|
const requestedNativePickerAudioOnLinux = requestedAudio && electronApi.platform === 'linux' && useWaylandPortal;
|
|
const linuxDesktopAudioSourceMode =
|
|
electronApi.platform === 'linux' && (requestedDesktopAudio || requestedNativePickerAudioOnLinux)
|
|
? VoiceSettings.getScreenShareAudioSourceMode()
|
|
: null;
|
|
if (requestedAppAudioOnLinux) {
|
|
try {
|
|
nativeAudioArmed = await armNativeAudioForNextCapture(sourceId ?? '');
|
|
} catch (error) {
|
|
logger.warn('Failed to arm Linux native per-window audio capture', {
|
|
sourceId,
|
|
error,
|
|
});
|
|
}
|
|
if (!nativeAudioArmed) {
|
|
failRequestedAudioCapture(
|
|
buildAudioCaptureFailureDebug({
|
|
sourceId,
|
|
reason: getLastNativeAudioArmFailure()?.reason ?? 'linux-window-audio-route-unavailable',
|
|
}),
|
|
);
|
|
}
|
|
} else if (requestedNativeDesktopAudio) {
|
|
try {
|
|
nativeAudioArmed = await armNativeSystemAudioForNextCapture();
|
|
} catch (error) {
|
|
logger.warn('Failed to arm native desktop audio capture', {
|
|
sourceId,
|
|
platform: electronApi.platform,
|
|
error,
|
|
});
|
|
}
|
|
if (!nativeAudioArmed) {
|
|
const debugInfo = buildAudioCaptureFailureDebug({
|
|
sourceId,
|
|
sourceMode: 'system',
|
|
platform: electronApi.platform,
|
|
reason: getLastNativeAudioArmFailure()?.reason ?? 'system-audio-route-unavailable',
|
|
});
|
|
logger.warn('Desktop audio unavailable; aborting screen share because audio was requested', debugInfo);
|
|
failRequestedAudioCapture(debugInfo);
|
|
}
|
|
} else if (linuxDesktopAudioSourceMode === 'none') {
|
|
removeAudioFromCaptureOptions(captureOptions);
|
|
} else if ((requestedDesktopAudio || requestedNativePickerAudioOnLinux) && electronApi.platform === 'linux') {
|
|
const sourceMode = linuxDesktopAudioSourceMode ?? 'system';
|
|
const userIncludeSources = VoiceSettings.getScreenShareAudioIncludeSources().map((entry) => ({...entry}));
|
|
const userExcludeSources = VoiceSettings.getScreenShareAudioExcludeSources().map((entry) => ({...entry}));
|
|
try {
|
|
nativeAudioArmed = await armNativeAudioForLinuxRouting(
|
|
buildLinuxNativeAudioRule(sourceMode, userIncludeSources, userExcludeSources),
|
|
);
|
|
} catch (error) {
|
|
logger.warn('Failed to arm Linux native audio-capture link', {
|
|
sourceMode,
|
|
error,
|
|
});
|
|
}
|
|
if (!nativeAudioArmed) {
|
|
failRequestedAudioCapture(
|
|
buildAudioCaptureFailureDebug({
|
|
sourceMode,
|
|
reason: getLastNativeAudioArmFailure()?.reason ?? 'linux-system-audio-route-unavailable',
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
requestedAudio &&
|
|
!isOwnWindowShare &&
|
|
sourceId?.startsWith('window:') &&
|
|
(electronApi.platform === 'darwin' || electronApi.platform === 'win32')
|
|
) {
|
|
try {
|
|
nativeAudioArmed = await armNativeAudioForNextCapture(sourceId);
|
|
} catch (error) {
|
|
logger.warn('Failed to arm native per-window audio capture', {
|
|
sourceId,
|
|
error,
|
|
});
|
|
}
|
|
if (!nativeAudioArmed) {
|
|
const debugInfo = buildAudioCaptureFailureDebug({
|
|
sourceId,
|
|
reason: getLastNativeAudioArmFailure()?.reason ?? 'native-window-audio-route-unavailable',
|
|
});
|
|
logger.warn('Per-window audio unavailable; aborting screen share because audio was requested', {
|
|
sourceId,
|
|
platform: electronApi.platform,
|
|
reason: debugInfo.reason,
|
|
});
|
|
failRequestedAudioCapture(debugInfo);
|
|
}
|
|
}
|
|
if (
|
|
requestedAudio &&
|
|
sourceId?.startsWith('window:') &&
|
|
!isOwnWindowShare &&
|
|
!nativeAudioArmed &&
|
|
electronApi.platform !== 'darwin' &&
|
|
electronApi.platform !== 'win32' &&
|
|
electronApi.platform !== 'linux'
|
|
) {
|
|
failRequestedAudioCapture(
|
|
buildAudioCaptureFailureDebug({
|
|
sourceId,
|
|
platform: electronApi.platform,
|
|
reason: getLastNativeAudioArmFailure()?.reason ?? 'window-audio-route-unavailable',
|
|
}),
|
|
);
|
|
}
|
|
if (requestedAudio && (nativeAudioArmed || electronApi.platform === 'linux')) {
|
|
removeAudioFromCaptureOptions(captureOptions);
|
|
}
|
|
try {
|
|
if (useWaylandPortal && options?.preferredDisplaySurface) {
|
|
await electronApi.setDisplayMediaPortalPreference?.(options.preferredDisplaySurface);
|
|
}
|
|
if (!useWaylandPortal && sourceId) {
|
|
setDesktopSourceIntent({sourceId, includeAudio: false});
|
|
}
|
|
let operationSucceeded = false;
|
|
if (mode === 'switch' && !restartWaylandPortalForSwitch) {
|
|
operationSucceeded = await MediaEngine.replaceActiveDisplayScreenShare(captureOptions, publishOptions, {
|
|
sourceId: sourceId ?? null,
|
|
displayShareEnvironment,
|
|
requireAudio: requestedAudio && nativeAudioArmed,
|
|
});
|
|
} else {
|
|
await MediaEngine.setScreenShareEnabled(
|
|
true,
|
|
restartWaylandPortalForSwitch ? {...captureOptions, playSound: false} : captureOptions,
|
|
publishOptions,
|
|
);
|
|
operationSucceeded = didScreenShareStart();
|
|
}
|
|
const captured = mode === 'switch' ? operationSucceeded : didScreenShareStart();
|
|
if (nativeAudioArmed && !captured) {
|
|
cleanupNativeAudioAfterCaptureDidNotStart(mode);
|
|
}
|
|
if (captured && !useWaylandPortal && sourceId) {
|
|
ActiveScreenShareSource.setPublishedSource(sourceId.startsWith('window:') ? 'app' : 'display', sourceId, {
|
|
isOwnWindow: isOwnWindowShare,
|
|
});
|
|
}
|
|
if (captured && useWaylandPortal) {
|
|
ActiveScreenShareSource.setPublishedSource('wayland', null);
|
|
}
|
|
if (!captured && mode === 'start' && !didScreenShareStart()) {
|
|
ActiveScreenShareSource.clear();
|
|
}
|
|
if (!captured && useWaylandPortal && mode !== 'switch') {
|
|
logger.warn('Wayland screen share portal did not yield a capturable source', {sourceId});
|
|
throw new ScreenSharePortalUnavailableError('empty');
|
|
}
|
|
if (
|
|
captured &&
|
|
requestedAudio &&
|
|
electronApi.platform === 'linux' &&
|
|
linuxDesktopAudioSourceMode !== null &&
|
|
linuxDesktopAudioSourceMode !== 'none'
|
|
) {
|
|
const audioRelinked = await reconfigureActiveLinuxScreenShareAudioLink().catch((error) => {
|
|
logger.warn('Failed to link Linux screen-share audio after capture start', {mode, error});
|
|
return false;
|
|
});
|
|
if (!audioRelinked) {
|
|
const debugInfo = buildAudioCaptureFailureDebug({
|
|
sourceMode: linuxDesktopAudioSourceMode,
|
|
platform: electronApi.platform,
|
|
reason: getLastNativeAudioArmFailure()?.reason ?? 'linux-system-audio-route-unavailable',
|
|
});
|
|
logger.warn('Linux screen-share capture succeeded, but audio link did not complete', {
|
|
...debugInfo,
|
|
mode,
|
|
});
|
|
degradeAudioToVideoOnly(captureOptions, debugInfo);
|
|
}
|
|
}
|
|
return captured;
|
|
} catch (error) {
|
|
if (isScreenSharePortalUnavailableError(error)) {
|
|
throw error;
|
|
}
|
|
const capturedAfterError = didScreenShareStart();
|
|
if (nativeAudioArmed && !capturedAfterError) {
|
|
cleanupNativeAudioAfterCaptureDidNotStart(mode);
|
|
}
|
|
if (!capturedAfterError && mode === 'start' && !didScreenShareStart()) {
|
|
ActiveScreenShareSource.clear();
|
|
}
|
|
if (useWaylandPortal && !capturedAfterError && mode !== 'switch') {
|
|
logger.warn('Wayland screen share portal capture failed to start', {
|
|
sourceId,
|
|
error,
|
|
});
|
|
throw new ScreenSharePortalUnavailableError('error', error instanceof Error ? error.message : undefined);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (!useWaylandPortal) {
|
|
clearDesktopSourceIntent();
|
|
}
|
|
}
|
|
}
|
|
let operationSucceeded = false;
|
|
if (mode === 'switch') {
|
|
operationSucceeded = await MediaEngine.replaceActiveDisplayScreenShare(captureOptions, publishOptions, {
|
|
sourceId: null,
|
|
displayShareEnvironment,
|
|
requireAudio: false,
|
|
});
|
|
} else {
|
|
await MediaEngine.setScreenShareEnabled(true, captureOptions, publishOptions);
|
|
operationSucceeded = didScreenShareStart();
|
|
}
|
|
const captured = mode === 'switch' ? operationSucceeded : didScreenShareStart();
|
|
if (captured) {
|
|
ActiveScreenShareSource.setPublishedSource('web', null);
|
|
}
|
|
return captured;
|
|
}
|
|
|
|
interface ConfiguredScreenShareMutationRequest {
|
|
execute: () => Promise<boolean>;
|
|
promise: Promise<boolean>;
|
|
resolve: (result: boolean) => void;
|
|
reject: (error: unknown) => void;
|
|
}
|
|
|
|
let configuredScreenShareMutationActive = false;
|
|
let pendingConfiguredScreenShareMutation: ConfiguredScreenShareMutationRequest | null = null;
|
|
|
|
function createConfiguredScreenShareMutationRequest(
|
|
execute: () => Promise<boolean>,
|
|
): ConfiguredScreenShareMutationRequest {
|
|
let resolveRequest: ((result: boolean) => void) | undefined;
|
|
let rejectRequest: ((error: unknown) => void) | undefined;
|
|
const promise = new Promise<boolean>((resolve, reject) => {
|
|
resolveRequest = resolve;
|
|
rejectRequest = reject;
|
|
});
|
|
if (!resolveRequest || !rejectRequest) {
|
|
throw new Error('Configured screen share mutation deferred was not initialized');
|
|
}
|
|
return {execute, promise, resolve: resolveRequest, reject: rejectRequest};
|
|
}
|
|
|
|
async function drainConfiguredScreenShareMutations(
|
|
initialRequest: ConfiguredScreenShareMutationRequest,
|
|
): Promise<void> {
|
|
let request: ConfiguredScreenShareMutationRequest | null = initialRequest;
|
|
while (request) {
|
|
try {
|
|
request.resolve(await request.execute());
|
|
} catch (error) {
|
|
request.reject(error);
|
|
}
|
|
request = pendingConfiguredScreenShareMutation;
|
|
pendingConfiguredScreenShareMutation = null;
|
|
}
|
|
configuredScreenShareMutationActive = false;
|
|
}
|
|
|
|
function scheduleConfiguredScreenShareMutation(execute: () => Promise<boolean>): Promise<boolean> {
|
|
const request = createConfiguredScreenShareMutationRequest(execute);
|
|
if (!configuredScreenShareMutationActive) {
|
|
configuredScreenShareMutationActive = true;
|
|
void drainConfiguredScreenShareMutations(request);
|
|
return request.promise;
|
|
}
|
|
pendingConfiguredScreenShareMutation?.resolve(false);
|
|
pendingConfiguredScreenShareMutation = request;
|
|
return request.promise;
|
|
}
|
|
|
|
export async function startConfiguredDisplayScreenShare(
|
|
sourceId?: string | null,
|
|
options?: ConfiguredDisplayScreenShareOptions,
|
|
): Promise<boolean> {
|
|
return scheduleConfiguredScreenShareMutation(async () => {
|
|
let didStart = false;
|
|
await executeScreenShareOperation(async () => {
|
|
didStart = await runConfiguredDisplayScreenShare(sourceId, options, 'start');
|
|
});
|
|
return didStart;
|
|
});
|
|
}
|
|
|
|
export async function switchConfiguredDisplayScreenShare(
|
|
sourceId?: string | null,
|
|
options?: ConfiguredDisplayScreenShareOptions,
|
|
): Promise<boolean> {
|
|
return scheduleConfiguredScreenShareMutation(async () => {
|
|
let didSwitch = false;
|
|
await executeScreenShareOperation(async () => {
|
|
didSwitch = await runConfiguredDisplayScreenShare(sourceId, options, 'switch');
|
|
});
|
|
return didSwitch;
|
|
});
|
|
}
|
|
|
|
export async function startConfiguredDeviceScreenShare(videoDeviceId: string): Promise<boolean> {
|
|
return scheduleConfiguredScreenShareMutation(async () => {
|
|
normaliseDeviceScreenShareSettings();
|
|
const {captureOptions, publishOptions, includeAudio, audioDeviceId} = getConfiguredScreenShareOptions(
|
|
'device',
|
|
'desktop-custom',
|
|
);
|
|
try {
|
|
await MediaEngine.startDeviceScreenShare(
|
|
{
|
|
videoDeviceId,
|
|
audioDeviceId: includeAudio ? audioDeviceId : undefined,
|
|
resolution: captureOptions.resolution,
|
|
},
|
|
publishOptions,
|
|
);
|
|
} catch (error) {
|
|
logger.error('Failed to start device screen share', {
|
|
error,
|
|
videoDeviceId,
|
|
});
|
|
}
|
|
const didStart = didScreenShareStart();
|
|
if (didStart) ActiveScreenShareSource.setPublishedSource('device', null);
|
|
return didStart;
|
|
});
|
|
}
|
|
|
|
export async function switchConfiguredDeviceScreenShare(videoDeviceId: string): Promise<boolean> {
|
|
return scheduleConfiguredScreenShareMutation(async () => {
|
|
normaliseDeviceScreenShareSettings();
|
|
const {captureOptions, publishOptions, includeAudio, audioDeviceId} = getConfiguredScreenShareOptions(
|
|
'device',
|
|
'desktop-custom',
|
|
);
|
|
try {
|
|
const didSwitch = await MediaEngine.replaceActiveDeviceScreenShare(
|
|
{
|
|
videoDeviceId,
|
|
audioDeviceId: includeAudio ? audioDeviceId : undefined,
|
|
resolution: captureOptions.resolution,
|
|
},
|
|
publishOptions,
|
|
);
|
|
if (didSwitch) ActiveScreenShareSource.setPublishedSource('device', null);
|
|
return didSwitch;
|
|
} catch (error) {
|
|
logger.error('Failed to switch device screen share source', {
|
|
error,
|
|
videoDeviceId,
|
|
});
|
|
return false;
|
|
}
|
|
});
|
|
}
|