feat(voice): give window shares their own audio scope (#2446)

This commit is contained in:
Hampus
2026-09-04 15:48:37 +02:00
committed by GitHub
parent 0d8116d73e
commit bc7f701e87
23 changed files with 1252 additions and 611 deletions
@@ -40,7 +40,6 @@ import {
} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedDesktopControls'; } from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedDesktopControls';
import {UnreadBadgeCustomizationControl} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedExperimentalControls'; import {UnreadBadgeCustomizationControl} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedExperimentalControls';
import { import {
ManualScreenShareAudioSourcesControl,
OpenH264Control, OpenH264Control,
ScreenShareAv1OptInControl, ScreenShareAv1OptInControl,
ScreenShareCodecControl, ScreenShareCodecControl,
@@ -88,7 +87,6 @@ export const DIRECT_CONTROL_ITEM_IDS = new Set([
'voice-video-screen-share-av1-opt-in', 'voice-video-screen-share-av1-opt-in',
'voice-video-screen-share-hevc-opt-in', 'voice-video-screen-share-hevc-opt-in',
'voice-video-openh264-codec', 'voice-video-openh264-codec',
'voice-video-manual-screen-share-audio-sources',
'voice-video-screen-share-preview-behavior', 'voice-video-screen-share-preview-behavior',
'voice-video-screen-share-encoder-controls', 'voice-video-screen-share-encoder-controls',
'advanced-unread-badge-customization', 'advanced-unread-badge-customization',
@@ -125,7 +123,6 @@ export const COMPACT_SWITCH_CONTROL_ITEM_IDS = new Set([
'voice-video-new-device-alerts', 'voice-video-new-device-alerts',
'voice-video-connection-volume-controls', 'voice-video-connection-volume-controls',
'voice-video-openh264-codec', 'voice-video-openh264-codec',
'voice-video-manual-screen-share-audio-sources',
'voice-video-screen-share-av1-opt-in', 'voice-video-screen-share-av1-opt-in',
'voice-video-screen-share-hevc-opt-in', 'voice-video-screen-share-hevc-opt-in',
'advanced-unread-badge-customization', 'advanced-unread-badge-customization',
@@ -286,10 +283,6 @@ export const AdvancedSettingControl = observer(({item}: {item: SearchableSetting
return ( return (
<OpenH264Control data-flx="user.advanced-setting-direct-controls.advanced-setting-control.open-h264-control" /> <OpenH264Control data-flx="user.advanced-setting-direct-controls.advanced-setting-control.open-h264-control" />
); );
case 'voice-video-manual-screen-share-audio-sources':
return (
<ManualScreenShareAudioSourcesControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.manual-screen-share-audio-sources-control" />
);
case 'voice-video-screen-share-preview-behavior': case 'voice-video-screen-share-preview-behavior':
return ( return (
<ScreenSharePreviewBehaviorControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.screen-share-preview-behavior-control" /> <ScreenSharePreviewBehaviorControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.screen-share-preview-behavior-control" />
@@ -28,11 +28,8 @@ import {
selectAutomaticScreenShareCodec, selectAutomaticScreenShareCodec,
} from '@app/features/voice/utils/CodecCapabilityDetector'; } from '@app/features/voice/utils/CodecCapabilityDetector';
import {getGpuEncoderReportSync, loadGpuEncoderReport} from '@app/features/voice/utils/GpuEncoderCapabilities'; import {getGpuEncoderReportSync, loadGpuEncoderReport} from '@app/features/voice/utils/GpuEncoderCapabilities';
import {getNativeAudioAvailabilitySnapshot} from '@app/features/voice/utils/NativeAudioCaptureBridge';
import {setOpenH264Enabled} from '@app/features/voice/utils/OpenH264Status'; import {setOpenH264Enabled} from '@app/features/voice/utils/OpenH264Status';
import {CODEC_DISPLAY_LABEL} from '@app/features/voice/utils/ScreenShareCodecPolicy'; import {CODEC_DISPLAY_LABEL} from '@app/features/voice/utils/ScreenShareCodecPolicy';
import {reapplyActiveScreenShareAudioSources} from '@app/features/voice/utils/ScreenShareStartFlow';
import {maySupportManualScreenShareAudioSourceSelection} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro'; import {useLingui} from '@lingui/react/macro';
import {GearIcon} from '@phosphor-icons/react'; import {GearIcon} from '@phosphor-icons/react';
@@ -192,11 +189,6 @@ const HEVC_SCREEN_SHARE_OPT_IN_DESCRIPTOR = msg({
comment: comment:
'Switch label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.', 'Switch label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.',
}); });
const MANUAL_SCREEN_SHARE_AUDIO_SOURCES_DESCRIPTOR = msg({
message: 'Pick the apps to capture audio from',
comment:
'Switch label for the advanced opt-in that reveals a manual per-application audio source picker in the stream settings menu.',
});
const SCREEN_SHARE_CODEC_OPTION_ORDER = ['av1', 'h265', 'h264', 'vp9', 'vp8'] as const; const SCREEN_SHARE_CODEC_OPTION_ORDER = ['av1', 'h265', 'h264', 'vp9', 'vp8'] as const;
export const ScreenShareCodecControl = observer(() => { export const ScreenShareCodecControl = observer(() => {
@@ -269,31 +261,6 @@ export const ScreenShareHevcOptInControl = observer(() => {
); );
}); });
export const ManualScreenShareAudioSourcesControl = observer(() => {
const {i18n} = useLingui();
if (!isDesktop()) return null;
if (
!maySupportManualScreenShareAudioSourceSelection({
platform: getElectronAPI()?.platform,
nativeAudioAvailability: getNativeAudioAvailabilitySnapshot(),
})
) {
return null;
}
return (
<Switch
ariaLabel={i18n._(MANUAL_SCREEN_SHARE_AUDIO_SOURCES_DESCRIPTOR)}
value={VoiceSettings.getScreenShareManualAudioSourcesOptIn()}
onChange={(value) => {
VoiceSettingsCommands.update({screenShareManualAudioSourcesOptIn: value});
void reapplyActiveScreenShareAudioSources();
}}
compact
data-flx="user.advanced-settings-tab.switch.manual-screen-share-audio-sources"
/>
);
});
export const OpenH264Control = observer(() => { export const OpenH264Control = observer(() => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const handleChange = useCallback((value: boolean) => { const handleChange = useCallback((value: boolean) => {
@@ -3,8 +3,6 @@
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {getElectronAPI, isDesktop} from '@app/features/ui/utils/NativeUtils'; import {getElectronAPI, isDesktop} from '@app/features/ui/utils/NativeUtils';
import type {SearchableSettingDescriptor} from '@app/features/user/components/settings_utils/search_index/SearchIndexTypes'; import type {SearchableSettingDescriptor} from '@app/features/user/components/settings_utils/search_index/SearchIndexTypes';
import {getNativeAudioAvailabilitySnapshot} from '@app/features/voice/utils/NativeAudioCaptureBridge';
import {maySupportManualScreenShareAudioSourceSelection} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
const SHOW_NEW_DEVICE_ALERTS_DESCRIPTOR = msg({ const SHOW_NEW_DEVICE_ALERTS_DESCRIPTOR = msg({
@@ -124,31 +122,6 @@ const ENABLE_OPENH264_SOFTWARE_ENCODING_AND_DECODING_ON_LINUX_DESCRIPTOR = msg({
message: 'OpenH264 software codec on Linux', message: 'OpenH264 software codec on Linux',
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.', comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
}); });
const PICK_THE_APPS_TO_CAPTURE_AUDIO_FROM_DESCRIPTOR = msg({
message: 'Pick the apps to capture audio from',
comment: 'Settings search entry label. Names an advanced screen-share audio setting in the settings UI.',
});
const APPLICATION_AUDIO_DESCRIPTOR = msg({
message: 'Application audio',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const AUDIO_SOURCES_DESCRIPTOR = msg({
message: 'Audio sources',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const CAPTURE_CARD_DESCRIPTOR = msg({
message: 'Capture card',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const VIRTUAL_MICROPHONE_DESCRIPTOR = msg({
message: 'Virtual microphone',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const MANUAL_AUDIO_SOURCE_PICKER_NOTE_DESCRIPTOR = msg({
message:
'Adds a manual audio source picker to the stream settings menu. Without it, a shared window already captures that app and a shared screen captures your desktop.',
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
});
const SCREEN_SHARE_PREVIEW_BEHAVIOR_DESCRIPTOR = msg({ const SCREEN_SHARE_PREVIEW_BEHAVIOR_DESCRIPTOR = msg({
message: 'Screen share preview behavior', message: 'Screen share preview behavior',
comment: 'Settings search entry label. Names the settings search entry in the settings UI.', comment: 'Settings search entry label. Names the settings search entry in the settings UI.',
@@ -274,27 +247,6 @@ export const voiceVideoIndex: Array<SearchableSettingDescriptor> = [
tags: ['media', 'voice'], tags: ['media', 'voice'],
isVisible: () => isDesktop() && getElectronAPI()?.platform === 'linux', isVisible: () => isDesktop() && getElectronAPI()?.platform === 'linux',
}, },
{
id: 'voice-video-manual-screen-share-audio-sources',
tabType: 'voice_video',
sectionId: 'video',
label: PICK_THE_APPS_TO_CAPTURE_AUDIO_FROM_DESCRIPTOR,
keywords: [
APPLICATION_AUDIO_DESCRIPTOR,
AUDIO_SOURCES_DESCRIPTOR,
CAPTURE_CARD_DESCRIPTOR,
VIRTUAL_MICROPHONE_DESCRIPTOR,
],
description: MANUAL_AUDIO_SOURCE_PICKER_NOTE_DESCRIPTOR,
audience: 'advanced',
tags: ['media', 'voice'],
isVisible: () =>
isDesktop() &&
maySupportManualScreenShareAudioSourceSelection({
platform: getElectronAPI()?.platform,
nativeAudioAvailability: getNativeAudioAvailabilitySnapshot(),
}),
},
{ {
id: 'voice-video-screen-share-preview-behavior', id: 'voice-video-screen-share-preview-behavior',
tabType: 'voice_video', tabType: 'voice_video',
@@ -76,7 +76,6 @@ type VoiceSettingsPatch = Partial<{
linuxAudioCaptureIgnoreDevices: boolean; linuxAudioCaptureIgnoreDevices: boolean;
linuxAudioCaptureGranularSelect: boolean; linuxAudioCaptureGranularSelect: boolean;
linuxAudioCaptureDeviceSelect: boolean; linuxAudioCaptureDeviceSelect: boolean;
screenShareManualAudioSourcesOptIn: boolean;
screenShareAudioSourceMode: 'none' | 'system' | 'specific'; screenShareAudioSourceMode: 'none' | 'system' | 'specific';
screenShareAudioIncludeSources: Array<Record<string, string>>; screenShareAudioIncludeSources: Array<Record<string, string>>;
screenShareAudioExcludeSources: Array<Record<string, string>>; screenShareAudioExcludeSources: Array<Record<string, string>>;
@@ -10,46 +10,35 @@ import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands'; import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
import VoiceSettings from '@app/features/voice/state/VoiceSettings'; import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import { import {
getLinuxAudioSourceDisplayName, filterRoutableLinuxAudioSources,
type LinuxAudioSourceFilterOptions, type LinuxAudioSourceFilterOptions,
type LinuxAudioSourceItem, type LinuxAudioSourceItem,
linuxAudioSourceItemKey, linuxAudioSourceItemKey,
mapLinuxAudioNodeToItems, mapLinuxAudioNodeToItems,
uniqueLinuxAudioSourceItems, uniqueLinuxAudioSourceItems,
} from '@app/features/voice/utils/LinuxAudioSourceRules'; } from '@app/features/voice/utils/LinuxAudioSourceRules';
import type {StreamSettingsShareContext} from '@app/features/voice/utils/StreamSettingsUpdatePolicy'; import {
formatScreenShareAudioSummary,
MICROPHONE_DESCRIPTOR,
MICROPHONE_WITH_DEVICE_DESCRIPTOR,
} from '@app/features/voice/utils/ScreenShareAudioSummary';
import type {DisplayShareEnvironment} from '@app/features/voice/utils/ScreenShareEnvironment';
import {
resolveWindowShareAudioScope,
type StreamSettingsShareContext,
supportsWindowShareAudioScope,
type WindowShareAudioScope,
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
import type {VirtmicNode} from '@app/types/electron.d'; import type {VirtmicNode} from '@app/types/electron.d';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro'; import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useState} from 'react'; import {useCallback, useEffect, useState} from 'react';
const NO_AUDIO_DESCRIPTOR = msg({ const SHARED_WINDOW_AUDIO_DESCRIPTOR = msg({
message: 'No audio', message: 'Shared window audio',
comment: 'Summary label in the Linux audio source picker when no audio sources are selected.',
});
const CUSTOM_DESCRIPTOR = msg({
message: 'Custom',
comment: 'Summary label in the Linux audio source picker when a custom subset of apps is selected.',
});
const APPS_DESCRIPTOR = msg({
message: '{length} apps',
comment: comment:
'Summary label in the Linux audio source picker when N apps are included. {length} is the integer app count.', 'Capture option in the Linux audio source picker on a window share. Captures only the audio of the window being shared.',
});
const ENTIRE_SYSTEM_DESCRIPTOR = msg({
message: 'Entire system',
comment: 'Summary label in the Linux audio source picker when capturing the whole system audio mix.',
});
const MICROPHONE_DESCRIPTOR = msg({
message: 'Microphone',
comment:
'Summary label in the Linux audio source picker on a video device share, where the default is the microphone rather than the system mix.',
});
const MICROPHONE_WITH_DEVICE_DESCRIPTOR = msg({
message: 'Microphone ({deviceLabel})',
comment:
'Capture option in the Linux audio source picker on a video device share. {deviceLabel} is the selected audio input device name.',
}); });
const AUDIO_SOURCES_DESCRIPTOR = msg({ const AUDIO_SOURCES_DESCRIPTOR = msg({
message: 'Audio sources: {summaryLabel}', message: 'Audio sources: {summaryLabel}',
@@ -129,15 +118,26 @@ async function fetchAudioSources(options: LinuxAudioSourceFilterOptions): Promis
} }
interface AudioSourcePickerLinuxSubmenuProps { interface AudioSourcePickerLinuxSubmenuProps {
onSelectionChange?: () => void; onSelectionChange?: (nextWindowAudioScope?: WindowShareAudioScope) => void;
shareContext?: StreamSettingsShareContext; shareContext?: StreamSettingsShareContext;
displayShareEnvironment?: DisplayShareEnvironment;
windowAudioScope?: WindowShareAudioScope;
microphoneLabel?: string; microphoneLabel?: string;
} }
export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerLinuxSubmenuProps) => { export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerLinuxSubmenuProps) => {
const {onSelectionChange, shareContext = 'display', microphoneLabel} = props; const {
onSelectionChange,
shareContext = 'display',
displayShareEnvironment,
windowAudioScope,
microphoneLabel,
} = props;
const {i18n} = useLingui(); const {i18n} = useLingui();
const isDeviceShare = shareContext === 'device'; const isDeviceShare = shareContext === 'device';
const scopeInput = {shareContext, displayShareEnvironment, windowAudioScope};
const offersWindowScope = supportsWindowShareAudioScope(scopeInput);
const resolvedScope = resolveWindowShareAudioScope(scopeInput);
const sourceMode = VoiceSettings.getScreenShareAudioSourceMode(); const sourceMode = VoiceSettings.getScreenShareAudioSourceMode();
const includeSources = VoiceSettings.getScreenShareAudioIncludeSources(); const includeSources = VoiceSettings.getScreenShareAudioIncludeSources();
const excludeSources = VoiceSettings.getScreenShareAudioExcludeSources(); const excludeSources = VoiceSettings.getScreenShareAudioExcludeSources();
@@ -145,6 +145,8 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
const deviceSelect = VoiceSettings.getLinuxAudioCaptureDeviceSelect(); const deviceSelect = VoiceSettings.getLinuxAudioCaptureDeviceSelect();
const ignoreVirtual = VoiceSettings.getLinuxAudioCaptureIgnoreVirtual(); const ignoreVirtual = VoiceSettings.getLinuxAudioCaptureIgnoreVirtual();
const [snapshot, setSnapshot] = useState<AudioSourceSnapshot>(EMPTY_SNAPSHOT); const [snapshot, setSnapshot] = useState<AudioSourceSnapshot>(EMPTY_SNAPSHOT);
const routesSelectedSources = sourceMode === 'specific' && filterRoutableLinuxAudioSources(includeSources).length > 0;
const widenedScope = offersWindowScope ? ('system' as const) : undefined;
const refresh = useCallback(() => { const refresh = useCallback(() => {
setSnapshot((prev) => ({...prev, loading: true})); setSnapshot((prev) => ({...prev, loading: true}));
void fetchAudioSources({granular, deviceSelect, ignoreVirtual}).then((next) => { void fetchAudioSources({granular, deviceSelect, ignoreVirtual}).then((next) => {
@@ -161,13 +163,16 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, [refresh]); }, [refresh]);
const handlePickWindow = useCallback(() => {
onSelectionChange?.('window');
}, [onSelectionChange]);
const handlePickSystem = useCallback(() => { const handlePickSystem = useCallback(() => {
VoiceSettingsCommands.update({ VoiceSettingsCommands.update({
screenShareAudioSourceMode: 'system', screenShareAudioSourceMode: 'system',
screenShareAudioIncludeSources: [], screenShareAudioIncludeSources: [],
}); });
onSelectionChange?.(); onSelectionChange?.(widenedScope);
}, [onSelectionChange]); }, [onSelectionChange, widenedScope]);
const handlePickNone = useCallback(() => { const handlePickNone = useCallback(() => {
VoiceSettingsCommands.update({ VoiceSettingsCommands.update({
screenShareAudioSourceMode: 'none', screenShareAudioSourceMode: 'none',
@@ -185,9 +190,9 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
screenShareAudioSourceMode: nextSources.length > 0 ? 'specific' : 'system', screenShareAudioSourceMode: nextSources.length > 0 ? 'specific' : 'system',
screenShareAudioIncludeSources: nextSources, screenShareAudioIncludeSources: nextSources,
}); });
onSelectionChange?.(); onSelectionChange?.(widenedScope);
}, },
[includeSources, onSelectionChange], [includeSources, onSelectionChange, widenedScope],
); );
const handleToggleExcludeApp = useCallback( const handleToggleExcludeApp = useCallback(
(item: LinuxAudioSourceItem) => { (item: LinuxAudioSourceItem) => {
@@ -202,19 +207,19 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
}, },
[excludeSources, onSelectionChange], [excludeSources, onSelectionChange],
); );
const defaultSourceLabel = isDeviceShare const deviceSourceLabel = i18n._(MICROPHONE_WITH_DEVICE_DESCRIPTOR, {
? i18n._(MICROPHONE_WITH_DEVICE_DESCRIPTOR, {deviceLabel: microphoneLabel ?? i18n._(MICROPHONE_DESCRIPTOR)}) deviceLabel: microphoneLabel ?? i18n._(MICROPHONE_DESCRIPTOR),
: i18n._(ENTIRE_SYSTEM_DESCRIPTOR); });
const summaryLabel = const summaryLabel = formatScreenShareAudioSummary(i18n, {
sourceMode === 'none' sourceMode,
? i18n._(NO_AUDIO_DESCRIPTOR) includeSources,
: sourceMode === 'specific' shareContext,
? includeSources.length === 1 microphoneLabel,
? (getLinuxAudioSourceDisplayName(includeSources[0]) ?? i18n._(CUSTOM_DESCRIPTOR)) displayShareEnvironment,
: i18n._(APPS_DESCRIPTOR, {length: includeSources.length}) windowAudioScope,
: isDeviceShare });
? i18n._(MICROPHONE_DESCRIPTOR) const showsWideSourceLists = !offersWindowScope || resolvedScope === 'system';
: i18n._(ENTIRE_SYSTEM_DESCRIPTOR); const wideSourceIsSelected = isDeviceShare ? !routesSelectedSources : sourceMode === 'system';
if (!snapshot.available && !snapshot.loading) { if (!snapshot.available && !snapshot.loading) {
return null; return null;
} }
@@ -227,22 +232,33 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
<MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.capture"> <MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.capture">
{i18n._(CAPTURE_DESCRIPTOR)} {i18n._(CAPTURE_DESCRIPTOR)}
</MenuGroupLabel> </MenuGroupLabel>
{offersWindowScope && (
<MenuItemRadio
selected={resolvedScope === 'window'}
onSelect={handlePickWindow}
data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-item-radio.pick-window"
>
{i18n._(SHARED_WINDOW_AUDIO_DESCRIPTOR)}
</MenuItemRadio>
)}
<MenuItemRadio <MenuItemRadio
selected={sourceMode === 'system'} selected={showsWideSourceLists && wideSourceIsSelected}
onSelect={handlePickSystem} onSelect={handlePickSystem}
data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-item-radio.pick-system" data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-item-radio.pick-system"
> >
{isDeviceShare ? defaultSourceLabel : <Trans>Entire system audio</Trans>} {isDeviceShare ? deviceSourceLabel : <Trans>Entire system audio</Trans>}
</MenuItemRadio>
<MenuItemRadio
selected={sourceMode === 'none'}
onSelect={handlePickNone}
data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-item-radio.pick-none"
>
<Trans>None</Trans>
</MenuItemRadio> </MenuItemRadio>
{!isDeviceShare && !offersWindowScope && (
<MenuItemRadio
selected={sourceMode === 'none'}
onSelect={handlePickNone}
data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-item-radio.pick-none"
>
<Trans>None</Trans>
</MenuItemRadio>
)}
</MenuGroup> </MenuGroup>
{snapshot.items.length > 0 && ( {showsWideSourceLists && snapshot.items.length > 0 && (
<MenuGroup data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-group--2"> <MenuGroup data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-group--2">
<MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.include-apps"> <MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.include-apps">
{i18n._(INCLUDE_APPS_DESCRIPTOR)} {i18n._(INCLUDE_APPS_DESCRIPTOR)}
@@ -259,7 +275,7 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
))} ))}
</MenuGroup> </MenuGroup>
)} )}
{!isDeviceShare && sourceMode === 'system' && snapshot.items.length > 0 && ( {!isDeviceShare && showsWideSourceLists && sourceMode === 'system' && snapshot.items.length > 0 && (
<MenuGroup data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-group--3"> <MenuGroup data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-group--3">
<MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.exclude-from-system"> <MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.exclude-from-system">
{i18n._(EXCLUDE_FROM_SYSTEM_DESCRIPTOR)} {i18n._(EXCLUDE_FROM_SYSTEM_DESCRIPTOR)}
@@ -20,8 +20,10 @@ import MediaEngine, {useMediaEngineVersion} from '@app/features/voice/engine/Med
import ScreenShareCodecNegotiation from '@app/features/voice/engine/ScreenShareCodecNegotiation'; import ScreenShareCodecNegotiation from '@app/features/voice/engine/ScreenShareCodecNegotiation';
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource'; import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices'; import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices';
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
import VoiceSettings, {type ScreenshareResolution, type StreamingMode} from '@app/features/voice/state/VoiceSettings'; import VoiceSettings, {type ScreenshareResolution, type StreamingMode} from '@app/features/voice/state/VoiceSettings';
import {resolveScreenShareContentHintForContext} from '@app/features/voice/utils/CodecCapabilityDetector'; import {resolveScreenShareContentHintForContext} from '@app/features/voice/utils/CodecCapabilityDetector';
import {filterRoutableLinuxAudioSources} from '@app/features/voice/utils/LinuxAudioSourceRules';
import { import {
getNativeAudioAvailabilityCached, getNativeAudioAvailabilityCached,
getNativeAudioAvailabilitySnapshot, getNativeAudioAvailabilitySnapshot,
@@ -42,7 +44,9 @@ import {
} from '@app/features/voice/utils/ScreenShareOptions'; } from '@app/features/voice/utils/ScreenShareOptions';
import {isScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError'; import {isScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError';
import { import {
applyLiveScreenShareAudioSourceChange,
reconfigureActiveDeviceShareAudio, reconfigureActiveDeviceShareAudio,
reconfigureActiveLinuxAppShareAudio,
reconfigureActiveLinuxScreenShareAudioLink, reconfigureActiveLinuxScreenShareAudioLink,
stopActiveLinuxScreenShareAudioLink, stopActiveLinuxScreenShareAudioLink,
} from '@app/features/voice/utils/ScreenShareStartFlow'; } from '@app/features/voice/utils/ScreenShareStartFlow';
@@ -51,6 +55,7 @@ import {
isLinuxDesktopAudioShare, isLinuxDesktopAudioShare,
type StreamSettingsShareContext, type StreamSettingsShareContext,
shouldReconfigureAudioForActiveStreamSettings, shouldReconfigureAudioForActiveStreamSettings,
type WindowShareAudioScope,
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy'; } from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
import {hasHigherVideoQuality as resolveHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement'; import {hasHigherVideoQuality as resolveHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
import {formatVoiceAudioDeviceLabel} from '@app/features/voice/utils/VoiceMessageDescriptors'; import {formatVoiceAudioDeviceLabel} from '@app/features/voice/utils/VoiceMessageDescriptors';
@@ -149,6 +154,11 @@ const SHARE_STREAM_AUDIO_DESCRIPTOR = msg({
message: 'Share stream audio', message: 'Share stream audio',
comment: 'Toggle label for sharing audio with an active stream.', comment: 'Toggle label for sharing audio with an active stream.',
}); });
const CAPTURE_ENTIRE_SYSTEM_AUDIO_DESCRIPTOR = msg({
message: 'Capture entire system audio',
comment:
'Toggle label in the stream settings menu for a window share whose audio scope the user widened from the shared window to the whole system mix.',
});
const logger = new Logger('StreamSettingsMenuContent'); const logger = new Logger('StreamSettingsMenuContent');
const SCREEN_SHARE_AUDIO_SOURCE = VoiceTrackSource.ScreenShareAudio as Track.Source; const SCREEN_SHARE_AUDIO_SOURCE = VoiceTrackSource.ScreenShareAudio as Track.Source;
@@ -291,6 +301,8 @@ export async function pushActiveStreamSettings(
audioLinkUpdated = await stopActiveLinuxScreenShareAudioLink(); audioLinkUpdated = await stopActiveLinuxScreenShareAudioLink();
} else if (shareContext === 'device') { } else if (shareContext === 'device') {
audioLinkUpdated = await reconfigureActiveDeviceShareAudio(); audioLinkUpdated = await reconfigureActiveDeviceShareAudio();
} else if (shareContext === 'app') {
audioLinkUpdated = await reconfigureActiveLinuxAppShareAudio();
} else { } else {
audioLinkUpdated = await reconfigureActiveLinuxScreenShareAudioLink(); audioLinkUpdated = await reconfigureActiveLinuxScreenShareAudioLink();
} }
@@ -374,9 +386,13 @@ export const StreamSettingsMenuContent = observer(
}; };
}, []); }, []);
const platform = getElectronAPI()?.platform; const platform = getElectronAPI()?.platform;
const manualAudioSourcesOptIn = VoiceSettings.getScreenShareManualAudioSourcesOptIn();
const audioSourceMode = VoiceSettings.getScreenShareAudioSourceMode(); const audioSourceMode = VoiceSettings.getScreenShareAudioSourceMode();
const selectedAudioSourceCount = VoiceSettings.getScreenShareAudioIncludeSources().length; const selectedAudioSourceCount = filterRoutableLinuxAudioSources(
VoiceSettings.getScreenShareAudioIncludeSources(),
).length;
const windowAudioScope = applyToLiveStream
? ActiveScreenShareSource.getWindowAudioScope()
: ActiveScreenShareSource.getPendingWindowAudioScope();
const audioMenuState = useMemo( const audioMenuState = useMemo(
() => () =>
selectStreamSettingsAudioMenuState({ selectStreamSettingsAudioMenuState({
@@ -388,9 +404,9 @@ export const StreamSettingsMenuContent = observer(
hasLiveScreenShareAudioPublication, hasLiveScreenShareAudioPublication,
nativeAudioAvailability, nativeAudioAvailability,
platform, platform,
manualAudioSourcesOptIn,
audioSourceMode, audioSourceMode,
selectedAudioSourceCount, selectedAudioSourceCount,
windowAudioScope,
}), }),
[ [
applyToLiveStream, applyToLiveStream,
@@ -398,12 +414,12 @@ export const StreamSettingsMenuContent = observer(
captureAudioEnabled, captureAudioEnabled,
displayShareEnvironment, displayShareEnvironment,
hasLiveScreenShareAudioPublication, hasLiveScreenShareAudioPublication,
manualAudioSourcesOptIn,
nativeAudioAvailability, nativeAudioAvailability,
platform, platform,
selectedAudioSourceCount, selectedAudioSourceCount,
shareContext, shareContext,
supportsStreamAudio, supportsStreamAudio,
windowAudioScope,
], ],
); );
const modeOptions: Array<Option<StreamingMode>> = useMemo(() => { const modeOptions: Array<Option<StreamingMode>> = useMemo(() => {
@@ -474,14 +490,28 @@ export const StreamSettingsMenuContent = observer(
}, },
[applyToLiveStream, displayShareEnvironment, hasHigherVideoQuality, shareContext], [applyToLiveStream, displayShareEnvironment, hasHigherVideoQuality, shareContext],
); );
const applyAudioSourceChange = useCallback(() => { const applyAudioSourceChange = useCallback(
if (!applyToLiveStream) return; (nextWindowAudioScope?: WindowShareAudioScope) => {
const applied = if (!applyToLiveStream) {
shareContext === 'device' ? reconfigureActiveDeviceShareAudio() : reconfigureActiveLinuxScreenShareAudioLink(); if (nextWindowAudioScope != null) {
void applied.catch((error) => { ActiveScreenShareSource.setPendingWindowAudioScope(nextWindowAudioScope);
logger.warn('Failed to apply the screen share audio source change', error); }
}); return;
}, [applyToLiveStream, shareContext]); }
void applyLiveScreenShareAudioSourceChange(shareContext, nextWindowAudioScope)
.then((applied) => {
if (applied) return;
logger.warn('The screen share audio source change did not take; the share is running without audio', {
shareContext,
nextWindowAudioScope,
});
})
.catch((error) => {
logger.warn('Failed to apply the screen share audio source change', error);
});
},
[applyToLiveStream, shareContext],
);
const handleModeSelect = useCallback( const handleModeSelect = useCallback(
(option: Option<StreamingMode>) => { (option: Option<StreamingMode>) => {
if (option.isPremium && !hasHigherVideoQuality) { if (option.isPremium && !hasHigherVideoQuality) {
@@ -673,6 +703,8 @@ export const StreamSettingsMenuContent = observer(
<StreamSettingsAudioGroup <StreamSettingsAudioGroup
audioMenuState={audioMenuState} audioMenuState={audioMenuState}
shareContext={shareContext} shareContext={shareContext}
displayShareEnvironment={displayShareEnvironment}
windowAudioScope={windowAudioScope}
compact={true} compact={true}
audioDeviceOptions={audioDeviceOptions} audioDeviceOptions={audioDeviceOptions}
currentAudioDeviceId={currentAudioDeviceId} currentAudioDeviceId={currentAudioDeviceId}
@@ -787,6 +819,8 @@ export const StreamSettingsMenuContent = observer(
<StreamSettingsAudioGroup <StreamSettingsAudioGroup
audioMenuState={audioMenuState} audioMenuState={audioMenuState}
shareContext={shareContext} shareContext={shareContext}
displayShareEnvironment={displayShareEnvironment}
windowAudioScope={windowAudioScope}
compact={false} compact={false}
audioDeviceOptions={audioDeviceOptions} audioDeviceOptions={audioDeviceOptions}
currentAudioDeviceId={currentAudioDeviceId} currentAudioDeviceId={currentAudioDeviceId}
@@ -814,12 +848,14 @@ StreamSettingsMenuContent.displayName = 'StreamSettingsMenuContent';
interface StreamSettingsAudioGroupProps { interface StreamSettingsAudioGroupProps {
audioMenuState: StreamSettingsAudioMenuViewState; audioMenuState: StreamSettingsAudioMenuViewState;
shareContext: StreamSettingsShareContext; shareContext: StreamSettingsShareContext;
displayShareEnvironment: DisplayShareEnvironment;
windowAudioScope: WindowShareAudioScope;
compact: boolean; compact: boolean;
audioDeviceOptions: Array<MediaDeviceInfo>; audioDeviceOptions: Array<MediaDeviceInfo>;
currentAudioDeviceId: string; currentAudioDeviceId: string;
selectedAudioDeviceLabel: string; selectedAudioDeviceLabel: string;
onCaptureAudioToggle: (checked: boolean) => void; onCaptureAudioToggle: (checked: boolean) => void;
onAudioSourceChange: () => void; onAudioSourceChange: (nextWindowAudioScope?: WindowShareAudioScope) => void;
onAudioDeviceSelect: (deviceId: string) => void; onAudioDeviceSelect: (deviceId: string) => void;
} }
@@ -827,6 +863,8 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps)
const { const {
audioMenuState, audioMenuState,
shareContext, shareContext,
displayShareEnvironment,
windowAudioScope,
compact, compact,
audioDeviceOptions, audioDeviceOptions,
currentAudioDeviceId, currentAudioDeviceId,
@@ -840,6 +878,7 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps)
if (compact) return i18n._(SHARE_STREAM_AUDIO_DESCRIPTOR); if (compact) return i18n._(SHARE_STREAM_AUDIO_DESCRIPTOR);
if (audioMenuState.control.labelKey === 'captureDeviceAudio') return <Trans>Capture device audio</Trans>; if (audioMenuState.control.labelKey === 'captureDeviceAudio') return <Trans>Capture device audio</Trans>;
if (audioMenuState.control.labelKey === 'captureAppAudio') return <Trans>Capture app audio</Trans>; if (audioMenuState.control.labelKey === 'captureAppAudio') return <Trans>Capture app audio</Trans>;
if (audioMenuState.control.labelKey === 'captureSystemAudio') return i18n._(CAPTURE_ENTIRE_SYSTEM_AUDIO_DESCRIPTOR);
return <Trans>Capture desktop audio</Trans>; return <Trans>Capture desktop audio</Trans>;
}; };
return ( return (
@@ -858,6 +897,8 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps)
<AudioSourcePickerLinuxSubmenu <AudioSourcePickerLinuxSubmenu
onSelectionChange={onAudioSourceChange} onSelectionChange={onAudioSourceChange}
shareContext={shareContext} shareContext={shareContext}
displayShareEnvironment={displayShareEnvironment}
windowAudioScope={windowAudioScope}
microphoneLabel={selectedAudioDeviceLabel} microphoneLabel={selectedAudioDeviceLabel}
data-flx="voice.stream-settings-menu-content.audio-group.audio-source-picker-submenu" data-flx="voice.stream-settings-menu-content.audio-group.audio-source-picker-submenu"
/> />
@@ -133,32 +133,37 @@ describe('StreamSettingsMenuContentStateMachine', () => {
expect(selectStreamSettingsAudioControlState(signals({displayShareEnvironment: 'web'}))).toBe('toggle'); expect(selectStreamSettingsAudioControlState(signals({displayShareEnvironment: 'web'}))).toBe('toggle');
}); });
it('hides manual audio sources for every share type until the advanced opt-in is on', () => { it('offers manual audio sources on every desktop share type, window shares included', () => {
for (const shareContext of ['app', 'device', 'display'] as const) {
for (const displayShareEnvironment of ['desktop-custom', 'desktop-wayland'] as const) {
expect(
selectStreamSettingsAudioMenuState(
signals({
shareContext,
displayShareEnvironment,
platform: 'linux',
captureAudioEnabled: true,
nativeAudioAvailability: availableNativeAudio(),
}),
).showManualAudioSources,
).toBe(true);
}
}
});
it('leaves audio source selection to the browser picker on the web', () => {
for (const shareContext of ['app', 'device', 'display'] as const) { for (const shareContext of ['app', 'device', 'display'] as const) {
const displayShareEnvironment = shareContext === 'app' ? 'desktop-wayland' : 'desktop-custom';
expect( expect(
selectStreamSettingsAudioMenuState( selectStreamSettingsAudioMenuState(
signals({ signals({
shareContext, shareContext,
displayShareEnvironment, displayShareEnvironment: 'web',
platform: 'linux', platform: 'linux',
captureAudioEnabled: true, captureAudioEnabled: true,
nativeAudioAvailability: availableNativeAudio(), nativeAudioAvailability: availableNativeAudio(),
}), }),
).showManualAudioSources, ).showManualAudioSources,
).toBe(false); ).toBe(false);
expect(
selectStreamSettingsAudioMenuState(
signals({
shareContext,
displayShareEnvironment,
platform: 'linux',
captureAudioEnabled: true,
nativeAudioAvailability: availableNativeAudio(),
manualAudioSourcesOptIn: true,
}),
).showManualAudioSources,
).toBe(true);
} }
}); });
@@ -170,7 +175,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
platform, platform,
captureAudioEnabled: true, captureAudioEnabled: true,
nativeAudioAvailability: availableNativeAudio(), nativeAudioAvailability: availableNativeAudio(),
manualAudioSourcesOptIn: true,
}), }),
).showManualAudioSources, ).showManualAudioSources,
).toBe(false); ).toBe(false);
@@ -181,7 +185,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
platform: null, platform: null,
captureAudioEnabled: true, captureAudioEnabled: true,
nativeAudioAvailability: availableNativeAudio(), nativeAudioAvailability: availableNativeAudio(),
manualAudioSourcesOptIn: true,
}), }),
).showManualAudioSources, ).showManualAudioSources,
).toBe(false); ).toBe(false);
@@ -191,7 +194,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
platform: 'linux', platform: 'linux',
captureAudioEnabled: true, captureAudioEnabled: true,
nativeAudioAvailability: availableNativeAudio({available: false, reason: 'no-pipewire'}), nativeAudioAvailability: availableNativeAudio({available: false, reason: 'no-pipewire'}),
manualAudioSourcesOptIn: true,
}), }),
).showManualAudioSources, ).showManualAudioSources,
).toBe(false); ).toBe(false);
@@ -201,7 +203,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
platform: 'linux', platform: 'linux',
captureAudioEnabled: true, captureAudioEnabled: true,
nativeAudioAvailability: null, nativeAudioAvailability: null,
manualAudioSourcesOptIn: true,
}), }),
).showManualAudioSources, ).showManualAudioSources,
).toBe(false); ).toBe(false);
@@ -214,7 +215,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
platform: 'linux', platform: 'linux',
captureAudioEnabled: false, captureAudioEnabled: false,
nativeAudioAvailability: availableNativeAudio(), nativeAudioAvailability: availableNativeAudio(),
manualAudioSourcesOptIn: true,
}), }),
).showManualAudioSources, ).showManualAudioSources,
).toBe(false); ).toBe(false);
@@ -225,12 +225,76 @@ describe('StreamSettingsMenuContentStateMachine', () => {
supportsStreamAudio: false, supportsStreamAudio: false,
captureAudioEnabled: true, captureAudioEnabled: true,
nativeAudioAvailability: availableNativeAudio(), nativeAudioAvailability: availableNativeAudio(),
manualAudioSourcesOptIn: true,
}), }),
).showManualAudioSources, ).showManualAudioSources,
).toBe(false); ).toBe(false);
}); });
it('names the real scope on a window share instead of always saying app audio', () => {
const common = {
shareContext: 'app',
displayShareEnvironment: 'desktop-custom',
platform: 'linux',
captureAudioEnabled: true,
nativeAudioAvailability: availableNativeAudio(),
} as const;
expect(selectStreamSettingsAudioMenuState(signals(common)).control.labelKey).toBe('captureAppAudio');
expect(selectStreamSettingsAudioMenuState(signals({...common, windowAudioScope: 'window'})).control.labelKey).toBe(
'captureAppAudio',
);
expect(selectStreamSettingsAudioMenuState(signals({...common, windowAudioScope: 'system'})).control.labelKey).toBe(
'captureSystemAudio',
);
expect(
selectStreamSettingsAudioMenuState(
signals({
...common,
windowAudioScope: 'system',
audioSourceMode: 'specific',
selectedAudioSourceCount: 2,
}),
).control.labelKey,
).toBe('captureAppAudio');
expect(
selectStreamSettingsAudioMenuState(signals({...common, windowAudioScope: 'system', audioSourceMode: 'none'}))
.control.labelKey,
).toBe('captureAppAudio');
});
it('keeps saying app audio on a window share whose stored display selection it does not follow', () => {
const common = {
shareContext: 'app',
displayShareEnvironment: 'desktop-custom',
platform: 'linux',
captureAudioEnabled: true,
nativeAudioAvailability: availableNativeAudio(),
windowAudioScope: 'window',
} as const;
for (const audioSourceMode of ['none', 'system', 'specific'] as const) {
expect(
selectStreamSettingsAudioMenuState(signals({...common, audioSourceMode, selectedAudioSourceCount: 2})).control
.labelKey,
).toBe('captureAppAudio');
}
});
it('never widens a Wayland window share label, because its window cannot be isolated', () => {
expect(
selectStreamSettingsAudioMenuState(
signals({
shareContext: 'app',
displayShareEnvironment: 'desktop-wayland',
platform: 'linux',
captureAudioEnabled: true,
nativeAudioAvailability: availableNativeAudio(),
windowAudioScope: 'window',
}),
).control.labelKey,
).toBe('captureSystemAudio');
});
it('keeps the audio device menu on a device share whether or not sources are routed', () => { it('keeps the audio device menu on a device share whether or not sources are routed', () => {
const routed = selectStreamSettingsAudioMenuState( const routed = selectStreamSettingsAudioMenuState(
signals({ signals({
@@ -238,7 +302,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
platform: 'linux', platform: 'linux',
captureAudioEnabled: true, captureAudioEnabled: true,
nativeAudioAvailability: availableNativeAudio(), nativeAudioAvailability: availableNativeAudio(),
manualAudioSourcesOptIn: true,
audioSourceMode: 'specific', audioSourceMode: 'specific',
selectedAudioSourceCount: 2, selectedAudioSourceCount: 2,
}), }),
@@ -247,7 +310,7 @@ describe('StreamSettingsMenuContentStateMachine', () => {
expect(routed.showDeviceAudioMenu).toBe(true); expect(routed.showDeviceAudioMenu).toBe(true);
}); });
it('pins the audio menu shape for every share type on every platform, opt-in off and on', () => { it('pins the audio menu shape for every share type on every platform', () => {
const platforms = [ const platforms = [
{platform: 'linux', nativeAudioAvailability: availableNativeAudio(), manualCapable: true}, {platform: 'linux', nativeAudioAvailability: availableNativeAudio(), manualCapable: true},
{platform: 'win32', nativeAudioAvailability: availableNativeAudio(), manualCapable: false}, {platform: 'win32', nativeAudioAvailability: availableNativeAudio(), manualCapable: false},
@@ -262,57 +325,35 @@ describe('StreamSettingsMenuContentStateMachine', () => {
for (const {platform, nativeAudioAvailability, manualCapable} of platforms) { for (const {platform, nativeAudioAvailability, manualCapable} of platforms) {
for (const shareContext of ['app', 'device', 'display'] as const) { for (const shareContext of ['app', 'device', 'display'] as const) {
for (const manualAudioSourcesOptIn of [false, true]) { const state = selectStreamSettingsAudioMenuState(
const state = selectStreamSettingsAudioMenuState( signals({
signals({ shareContext,
shareContext, platform,
platform, nativeAudioAvailability,
nativeAudioAvailability, captureAudioEnabled: true,
captureAudioEnabled: true, }),
manualAudioSourcesOptIn, );
}),
);
expect(state.control).toMatchObject({ expect(state.control).toMatchObject({
value: 'toggle', value: 'toggle',
checked: true, checked: true,
labelKey: expectedLabelKey[shareContext], labelKey: expectedLabelKey[shareContext],
}); });
expect(state.showManualAudioSources).toBe(manualCapable && manualAudioSourcesOptIn && shareContext !== 'app'); expect(state.showManualAudioSources).toBe(manualCapable);
expect(state.showDeviceAudioMenu).toBe(shareContext === 'device'); expect(state.showDeviceAudioMenu).toBe(shareContext === 'device');
}
} }
} }
}); });
it('offers manual sources on a window share only where the capture rule decides its audio', () => {
const common = {
shareContext: 'app',
platform: 'linux',
nativeAudioAvailability: availableNativeAudio(),
captureAudioEnabled: true,
manualAudioSourcesOptIn: true,
} as const;
expect(
selectStreamSettingsAudioMenuState(signals({...common, displayShareEnvironment: 'desktop-custom'}))
.showManualAudioSources,
).toBe(false);
expect(
selectStreamSettingsAudioMenuState(signals({...common, displayShareEnvironment: 'desktop-wayland'}))
.showManualAudioSources,
).toBe(true);
});
it('resolves the same audio group whether the menu is pre-start or attached to a live stream', () => { it('resolves the same audio group whether the menu is pre-start or attached to a live stream', () => {
for (const shareContext of ['app', 'device', 'display'] as const) { for (const shareContext of ['app', 'device', 'display'] as const) {
for (const manualAudioSourcesOptIn of [false, true]) { for (const windowAudioScope of ['window', 'system'] as const) {
const common = { const common = {
shareContext, shareContext,
platform: 'linux', platform: 'linux',
nativeAudioAvailability: availableNativeAudio(), nativeAudioAvailability: availableNativeAudio(),
captureAudioEnabled: true, captureAudioEnabled: true,
manualAudioSourcesOptIn, windowAudioScope,
}; };
const prestart = selectStreamSettingsAudioMenuState(signals({...common, applyToLiveStream: false})); const prestart = selectStreamSettingsAudioMenuState(signals({...common, applyToLiveStream: false}));
const live = selectStreamSettingsAudioMenuState(signals({...common, applyToLiveStream: true})); const live = selectStreamSettingsAudioMenuState(signals({...common, applyToLiveStream: true}));
@@ -8,8 +8,11 @@ import {
import { import {
canSelectManualAudioSources, canSelectManualAudioSources,
manualAudioSourcesGovernShare, manualAudioSourcesGovernShare,
resolveWindowShareAudioScope,
type ScreenShareAudioSourceMode, type ScreenShareAudioSourceMode,
type StreamSettingsShareContext, type StreamSettingsShareContext,
selectAppShareAudioRoute,
type WindowShareAudioScope,
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy'; } from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
import type {NativeAudioAvailability} from '@app/types/electron.d'; import type {NativeAudioAvailability} from '@app/types/electron.d';
import {getInitialSnapshot, setup, transition} from 'xstate'; import {getInitialSnapshot, setup, transition} from 'xstate';
@@ -20,7 +23,11 @@ export type StreamSettingsAudioControlStateValue =
| 'prestartNativePickerOwned' | 'prestartNativePickerOwned'
| 'restartRequired' | 'restartRequired'
| 'toggle'; | 'toggle';
export type StreamSettingsAudioControlLabelKey = 'captureAppAudio' | 'captureDesktopAudio' | 'captureDeviceAudio'; export type StreamSettingsAudioControlLabelKey =
| 'captureAppAudio'
| 'captureDesktopAudio'
| 'captureDeviceAudio'
| 'captureSystemAudio';
type StreamSettingsNativeAudioUnsupportedScope = 'process' | 'system'; type StreamSettingsNativeAudioUnsupportedScope = 'process' | 'system';
export interface StreamSettingsNativeAudioSignals { export interface StreamSettingsNativeAudioSignals {
@@ -35,9 +42,9 @@ export interface StreamSettingsAudioControlSignals extends StreamSettingsNativeA
supportsStreamAudio: boolean; supportsStreamAudio: boolean;
captureAudioEnabled: boolean; captureAudioEnabled: boolean;
hasLiveScreenShareAudioPublication: boolean; hasLiveScreenShareAudioPublication: boolean;
manualAudioSourcesOptIn?: boolean;
audioSourceMode?: ScreenShareAudioSourceMode; audioSourceMode?: ScreenShareAudioSourceMode;
selectedAudioSourceCount?: number; selectedAudioSourceCount?: number;
windowAudioScope?: WindowShareAudioScope;
} }
export interface StreamSettingsAudioControlViewState { export interface StreamSettingsAudioControlViewState {
@@ -148,10 +155,15 @@ export function selectStreamSettingsAudioControlState(
return typeof snapshot.value === 'string' ? (snapshot.value as StreamSettingsAudioControlStateValue) : 'hidden'; return typeof snapshot.value === 'string' ? (snapshot.value as StreamSettingsAudioControlStateValue) : 'hidden';
} }
function selectAudioControlLabelKey(shareContext: StreamSettingsShareContext): StreamSettingsAudioControlLabelKey { function selectAudioControlLabelKey(signals: StreamSettingsAudioControlSignals): StreamSettingsAudioControlLabelKey {
if (shareContext === 'device') return 'captureDeviceAudio'; if (signals.shareContext === 'device') return 'captureDeviceAudio';
if (shareContext === 'app') return 'captureAppAudio'; if (signals.shareContext !== 'app') return 'captureDesktopAudio';
return 'captureDesktopAudio'; const route = selectAppShareAudioRoute({
audioSourceMode: signals.audioSourceMode,
selectedSourceCount: signals.selectedAudioSourceCount,
windowAudioScope: resolveWindowShareAudioScope(signals),
});
return route === 'system' ? 'captureSystemAudio' : 'captureAppAudio';
} }
export function selectStreamSettingsAudioMenuState( export function selectStreamSettingsAudioMenuState(
@@ -162,7 +174,7 @@ export function selectStreamSettingsAudioMenuState(
control: { control: {
value, value,
checked: signals.captureAudioEnabled, checked: signals.captureAudioEnabled,
labelKey: selectAudioControlLabelKey(signals.shareContext), labelKey: selectAudioControlLabelKey(signals),
}, },
showManualAudioSources: showManualAudioSources:
signals.supportsStreamAudio && signals.supportsStreamAudio &&
@@ -170,11 +182,7 @@ export function selectStreamSettingsAudioMenuState(
manualAudioSourcesGovernShare(signals) && manualAudioSourcesGovernShare(signals) &&
canSelectManualAudioSources({ canSelectManualAudioSources({
platform: signals.platform, platform: signals.platform,
shareContext: signals.shareContext,
nativeAudioAvailability: signals.nativeAudioAvailability, nativeAudioAvailability: signals.nativeAudioAvailability,
manualOptIn: signals.manualAudioSourcesOptIn,
audioSourceMode: signals.audioSourceMode,
selectedSourceCount: signals.selectedAudioSourceCount,
}), }),
showDeviceAudioMenu: signals.shareContext === 'device' && signals.captureAudioEnabled, showDeviceAudioMenu: signals.shareContext === 'device' && signals.captureAudioEnabled,
}; };
@@ -67,14 +67,17 @@ import MediaEngine, {useVoiceEngineV2Model} from '@app/features/voice/engine/Med
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState'; import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
import {selectVoiceEngineV2AppConnection} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors'; import {selectVoiceEngineV2AppConnection} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices'; import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices';
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
import VoiceSettings, { import VoiceSettings, {
type LastScreenShareSource, type LastScreenShareSource,
type LastScreenShareSourceKind, type LastScreenShareSourceKind,
type ScreenshareResolution, type ScreenshareResolution,
type StreamingMode, type StreamingMode,
} from '@app/features/voice/state/VoiceSettings'; } from '@app/features/voice/state/VoiceSettings';
import {filterRoutableLinuxAudioSources} from '@app/features/voice/utils/LinuxAudioSourceRules';
import {getNativeAudioAvailabilityCached} from '@app/features/voice/utils/NativeAudioCaptureBridge'; import {getNativeAudioAvailabilityCached} from '@app/features/voice/utils/NativeAudioCaptureBridge';
import {isScreenShareAudioCaptureError} from '@app/features/voice/utils/ScreenShareAudioCaptureError'; import {isScreenShareAudioCaptureError} from '@app/features/voice/utils/ScreenShareAudioCaptureError';
import {formatScreenShareAudioSummary} from '@app/features/voice/utils/ScreenShareAudioSummary';
import { import {
getDisplayShareEnvironment, getDisplayShareEnvironment,
shouldShowDesktopDownloadCta, shouldShowDesktopDownloadCta,
@@ -92,6 +95,7 @@ import {
switchConfiguredDeviceScreenShare, switchConfiguredDeviceScreenShare,
switchConfiguredDisplayScreenShare, switchConfiguredDisplayScreenShare,
} from '@app/features/voice/utils/ScreenShareStartFlow'; } from '@app/features/voice/utils/ScreenShareStartFlow';
import {manualAudioSourcesGovernShare} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
import { import {
formatFallbackCameraLabel, formatFallbackCameraLabel,
formatVoiceAudioDeviceLabel, formatVoiceAudioDeviceLabel,
@@ -1039,6 +1043,7 @@ const ScreenSharePickerModalLoadedContent = observer(
cancelled = true; cancelled = true;
}; };
}, []); }, []);
useEffect(() => () => ActiveScreenShareSource.clearPendingWindowAudioScope(), []);
const platform = getElectronAPI()?.platform; const platform = getElectronAPI()?.platform;
const displayPermission = useScreenSharePickerDisplayPermission({ const displayPermission = useScreenSharePickerDisplayPermission({
activeTab, activeTab,
@@ -1453,6 +1458,10 @@ const ScreenSharePickerModalLoadedContent = observer(
: activeTab === 'apps' : activeTab === 'apps'
? VoiceSettings.getShareAppAudio() ? VoiceSettings.getShareAppAudio()
: VoiceSettings.getShareDesktopAudio(); : VoiceSettings.getShareDesktopAudio();
const audioSourceMode = VoiceSettings.getScreenShareAudioSourceMode();
const audioIncludeSources = VoiceSettings.getScreenShareAudioIncludeSources();
const routableAudioSourceCount = filterRoutableLinuxAudioSources(audioIncludeSources).length;
const windowAudioScope = ActiveScreenShareSource.getPendingWindowAudioScope();
const audioMenuState = selectStreamSettingsAudioMenuState({ const audioMenuState = selectStreamSettingsAudioMenuState({
applyToLiveStream: false, applyToLiveStream: false,
shareContext, shareContext,
@@ -1465,18 +1474,28 @@ const ScreenSharePickerModalLoadedContent = observer(
hasLiveScreenShareAudioPublication: false, hasLiveScreenShareAudioPublication: false,
nativeAudioAvailability, nativeAudioAvailability,
platform, platform,
audioSourceMode,
selectedAudioSourceCount: routableAudioSourceCount,
windowAudioScope,
}); });
const captureAudioEnabled = audioMenuState.control.value === 'toggle' && audioMenuState.control.checked; const captureAudioEnabled = audioMenuState.control.value === 'toggle' && audioMenuState.control.checked;
const configuredAudioDeviceId = VoiceSettings.getEffectiveScreenShareAudioDeviceId(); const configuredAudioDeviceId = VoiceSettings.getEffectiveScreenShareAudioDeviceId();
const selectedAudioDevice = inputDevices.find((device) => device.deviceId === configuredAudioDeviceId); const selectedAudioDevice = inputDevices.find((device) => device.deviceId === configuredAudioDeviceId);
const audioSummary = const microphoneLabel = selectedAudioDevice
activeTab !== 'devices' ? formatVoiceAudioDeviceLabel(i18n, selectedAudioDevice, i18n._(UNNAMED_INPUT_DESCRIPTOR))
? null : i18n._(SYSTEM_DEFAULT_DESCRIPTOR);
: captureAudioEnabled const audioSummary = captureAudioEnabled
? selectedAudioDevice ? formatScreenShareAudioSummary(i18n, {
? formatVoiceAudioDeviceLabel(i18n, selectedAudioDevice, i18n._(UNNAMED_INPUT_DESCRIPTOR)) sourceMode: manualAudioSourcesGovernShare({platform, displayShareEnvironment}) ? audioSourceMode : 'system',
: i18n._(SYSTEM_DEFAULT_DESCRIPTOR) includeSources: audioIncludeSources,
: i18n._(DEVICE_AUDIO_MUTED_DESCRIPTOR); shareContext,
microphoneLabel,
displayShareEnvironment,
windowAudioScope,
})
: activeTab === 'devices'
? i18n._(DEVICE_AUDIO_MUTED_DESCRIPTOR)
: null;
const streamSummaryDetails = [ const streamSummaryDetails = [
activeTab === 'devices' ? deviceSelectionError : null, activeTab === 'devices' ? deviceSelectionError : null,
streamSummaryDescription, streamSummaryDescription,
@@ -2229,6 +2229,10 @@ class MediaEngineFacade extends Store {
); );
} }
async ensureWindowScreenShareAudioPublication(sourceId: string): Promise<boolean> {
return voiceEngineV2AppScreenShareExecutionAdapter.ensureWindowScreenShareAudioPublication(this.room, sourceId);
}
getActiveScreenShareVideoDeviceId(): string { getActiveScreenShareVideoDeviceId(): string {
return voiceEngineV2AppScreenShareExecutionAdapter.getActiveScreenShareVideoDeviceId(this.room); return voiceEngineV2AppScreenShareExecutionAdapter.getActiveScreenShareVideoDeviceId(this.room);
} }
@@ -73,6 +73,7 @@ import {
import {disarmVirtmic} from '@app/features/voice/utils/LinuxScreenShareAudio'; import {disarmVirtmic} from '@app/features/voice/utils/LinuxScreenShareAudio';
import { import {
captureNativeAudioTrackForLinuxRouting, captureNativeAudioTrackForLinuxRouting,
captureNativeAudioTrackForWindowPid,
commitNativeAudioBridgeReplacement, commitNativeAudioBridgeReplacement,
disarmNativeAudio, disarmNativeAudio,
reconfigureLinuxNativeAudioRouting, reconfigureLinuxNativeAudioRouting,
@@ -785,6 +786,36 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
return true; return true;
} }
async ensureWindowScreenShareAudioPublication(room: Room | null, sourceId: string): Promise<boolean> {
const participant = room?.localParticipant;
if (!participant || !participant.isScreenShareEnabled) return false;
const targetPid = await getElectronAPI()
?.nativeAudio?.resolveAudioRootPidForSource(sourceId)
.catch((error) => {
logger.warn('Failed to resolve the shared window audio process', {error, sourceId});
return null;
});
if (targetPid == null) return false;
const capturedTrack = await captureNativeAudioTrackForWindowPid(targetPid);
if (!capturedTrack) return false;
let adopted = false;
try {
adopted = await this.replaceActiveScreenShareAudioTrackInternal(participant, capturedTrack);
if (adopted) {
commitNativeAudioBridgeReplacement();
}
} catch (error) {
logger.warn('Failed to publish mid-stream window screen-share audio track', {error, sourceId});
}
if (!adopted) {
stopMediaTrack(capturedTrack);
return false;
}
this.syncLocalScreenShareAudioStateInternal(participant, true);
this.syncPersistedScreenShareAudioPreferenceInternal(participant);
return true;
}
getActiveScreenShareVideoDeviceId(room: Room | null): string { getActiveScreenShareVideoDeviceId(room: Room | null): string {
const publication = room?.localParticipant?.getTrackPublication(Track.Source.ScreenShare); const publication = room?.localParticipant?.getTrackPublication(Track.Source.ScreenShare);
return publication?.videoTrack?.mediaStreamTrack.getSettings().deviceId ?? ''; return publication?.videoTrack?.mediaStreamTrack.getSettings().deviceId ?? '';
@@ -24,4 +24,43 @@ describe('ActiveScreenShareSource', () => {
expect(ActiveScreenShareSource.isOwnWindow()).toBe(false); expect(ActiveScreenShareSource.isOwnWindow()).toBe(false);
expect(ActiveScreenShareSource.getShareContext()).toBeNull(); expect(ActiveScreenShareSource.getShareContext()).toBeNull();
}); });
it('keeps a scope picked before the share starts out of the running share until it is committed', () => {
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('window');
ActiveScreenShareSource.setPendingWindowAudioScope('system');
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('system');
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('window');
ActiveScreenShareSource.clearPendingWindowAudioScope();
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('window');
});
it('offers the running share own scope to the next picker until the user picks another one', () => {
ActiveScreenShareSource.setPublishedSource('app', 'window:42:0');
ActiveScreenShareSource.setWindowAudioScope('system');
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('system');
ActiveScreenShareSource.setPendingWindowAudioScope('window');
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('window');
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('system');
ActiveScreenShareSource.clearPendingWindowAudioScope();
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('system');
});
it('keeps the window audio scope for the running share and drops it when the share ends', () => {
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('window');
ActiveScreenShareSource.setWindowAudioScope('system');
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('system');
ActiveScreenShareSource.setPublishedSource('app', 'window:42:0');
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('system');
ActiveScreenShareSource.setPendingWindowAudioScope('system');
ActiveScreenShareSource.clear();
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('window');
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('window');
});
}); });
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import type {StreamSettingsShareContext} from '@app/features/voice/utils/StreamSettingsUpdatePolicy'; import type {
StreamSettingsShareContext,
WindowShareAudioScope,
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
import {makeAutoObservable} from 'mobx'; import {makeAutoObservable} from 'mobx';
export interface ActiveScreenShareSourceOptions { export interface ActiveScreenShareSourceOptions {
@@ -13,6 +16,8 @@ class ActiveScreenShareSource {
sourceId: string | null = null; sourceId: string | null = null;
ownWindow = false; ownWindow = false;
publishedSource: PublishedScreenShareSource | null = null; publishedSource: PublishedScreenShareSource | null = null;
windowAudioScope: WindowShareAudioScope = 'window';
pendingWindowAudioScope: WindowShareAudioScope | null = null;
constructor() { constructor() {
makeAutoObservable(this, {}, {autoBind: true}); makeAutoObservable(this, {}, {autoBind: true});
@@ -40,6 +45,26 @@ class ActiveScreenShareSource {
return this.publishedSource; return this.publishedSource;
} }
getWindowAudioScope(): WindowShareAudioScope {
return this.windowAudioScope;
}
setWindowAudioScope(scope: WindowShareAudioScope): void {
this.windowAudioScope = scope;
}
getPendingWindowAudioScope(): WindowShareAudioScope {
return this.pendingWindowAudioScope ?? this.windowAudioScope;
}
setPendingWindowAudioScope(scope: WindowShareAudioScope): void {
this.pendingWindowAudioScope = scope;
}
clearPendingWindowAudioScope(): void {
this.pendingWindowAudioScope = null;
}
getShareContext(): StreamSettingsShareContext | null { getShareContext(): StreamSettingsShareContext | null {
if (this.publishedSource === 'app') return 'app'; if (this.publishedSource === 'app') return 'app';
if (this.publishedSource === 'device') return 'device'; if (this.publishedSource === 'device') return 'device';
@@ -51,6 +76,8 @@ class ActiveScreenShareSource {
this.sourceId = null; this.sourceId = null;
this.ownWindow = false; this.ownWindow = false;
this.publishedSource = null; this.publishedSource = null;
this.windowAudioScope = 'window';
this.pendingWindowAudioScope = null;
} }
} }
@@ -119,7 +119,6 @@ type VoiceSettingsUpdate = Partial<{
linuxAudioCaptureIgnoreDevices: boolean; linuxAudioCaptureIgnoreDevices: boolean;
linuxAudioCaptureGranularSelect: boolean; linuxAudioCaptureGranularSelect: boolean;
linuxAudioCaptureDeviceSelect: boolean; linuxAudioCaptureDeviceSelect: boolean;
screenShareManualAudioSourcesOptIn: boolean;
screenShareAudioSourceMode: 'none' | 'system' | 'specific'; screenShareAudioSourceMode: 'none' | 'system' | 'specific';
screenShareAudioIncludeSources: Array<Record<string, string>>; screenShareAudioIncludeSources: Array<Record<string, string>>;
screenShareAudioExcludeSources: Array<Record<string, string>>; screenShareAudioExcludeSources: Array<Record<string, string>>;
@@ -236,35 +235,16 @@ function applyScreenShareAudioDefaultOnMigrationV1(parsed: Record<string, unknow
return true; return true;
} }
const MANUAL_AUDIO_SOURCE_TUNING_DEFAULTS: Record<string, boolean> = { export function applyManualAudioSourcesOptOutResetMigrationV1(parsed: Record<string, unknown>): boolean {
linuxAudioCaptureOnlySpeakers: true, if (parsed.manualAudioSourcesOptOutResetMigratedV1 === true) {
linuxAudioCaptureOnlyDefaultSpeakers: true,
linuxAudioCaptureIgnoreInputMedia: true,
linuxAudioCaptureIgnoreVirtual: false,
linuxAudioCaptureIgnoreDevices: true,
linuxAudioCaptureGranularSelect: false,
linuxAudioCaptureDeviceSelect: false,
};
export function applyManualAudioSourcesOptInMigrationV1(parsed: Record<string, unknown>): boolean {
if (parsed.manualScreenShareAudioSourcesOptInMigratedV1 === true) {
return false; return false;
} }
const storedMode = parsed.screenShareAudioSourceMode; if (parsed.screenShareManualAudioSourcesOptIn === false) {
const includeSources = parsed.screenShareAudioIncludeSources; parsed.screenShareAudioSourceMode = 'system';
const excludeSources = parsed.screenShareAudioExcludeSources; parsed.screenShareAudioIncludeSources = [];
const hadManualSelection = parsed.screenShareAudioExcludeSources = [];
parsed.linuxDeviceShareAppAudioOptIn === true ||
(typeof storedMode === 'string' && storedMode !== 'system') ||
(Array.isArray(includeSources) && includeSources.length > 0) ||
(Array.isArray(excludeSources) && excludeSources.length > 0) ||
Object.entries(MANUAL_AUDIO_SOURCE_TUNING_DEFAULTS).some(
([key, fallback]) => typeof parsed[key] === 'boolean' && parsed[key] !== fallback,
);
if (hadManualSelection) {
parsed.screenShareManualAudioSourcesOptIn = true;
} }
parsed.manualScreenShareAudioSourcesOptInMigratedV1 = true; parsed.manualAudioSourcesOptOutResetMigratedV1 = true;
return true; return true;
} }
@@ -450,8 +430,7 @@ class VoiceSettings {
linuxAudioCaptureIgnoreDevices = true; linuxAudioCaptureIgnoreDevices = true;
linuxAudioCaptureGranularSelect = false; linuxAudioCaptureGranularSelect = false;
linuxAudioCaptureDeviceSelect = false; linuxAudioCaptureDeviceSelect = false;
screenShareManualAudioSourcesOptIn = false; manualAudioSourcesOptOutResetMigratedV1 = false;
manualScreenShareAudioSourcesOptInMigratedV1 = false;
screenShareAudioSourceMode: 'none' | 'system' | 'specific' = 'system'; screenShareAudioSourceMode: 'none' | 'system' | 'specific' = 'system';
screenShareAudioIncludeSources: Array<Record<string, string>> = []; screenShareAudioIncludeSources: Array<Record<string, string>> = [];
screenShareAudioExcludeSources: Array<Record<string, string>> = []; screenShareAudioExcludeSources: Array<Record<string, string>> = [];
@@ -525,7 +504,6 @@ class VoiceSettings {
getLinuxAudioCaptureIgnoreDevices: false, getLinuxAudioCaptureIgnoreDevices: false,
getLinuxAudioCaptureGranularSelect: false, getLinuxAudioCaptureGranularSelect: false,
getLinuxAudioCaptureDeviceSelect: false, getLinuxAudioCaptureDeviceSelect: false,
getScreenShareManualAudioSourcesOptIn: false,
getEffectiveScreenShareAudioSourceMode: false, getEffectiveScreenShareAudioSourceMode: false,
getEffectiveScreenShareAudioIncludeSources: false, getEffectiveScreenShareAudioIncludeSources: false,
getEffectiveScreenShareAudioExcludeSources: false, getEffectiveScreenShareAudioExcludeSources: false,
@@ -556,7 +534,7 @@ class VoiceSettings {
changed = applyScreenShareAudioConsentMigrationV1(parsed) || changed; changed = applyScreenShareAudioConsentMigrationV1(parsed) || changed;
changed = applyScreenShareAudioDefaultOnMigrationV1(parsed) || changed; changed = applyScreenShareAudioDefaultOnMigrationV1(parsed) || changed;
changed = applyStreamingModeDefaultMigrationV1(parsed) || changed; changed = applyStreamingModeDefaultMigrationV1(parsed) || changed;
changed = applyManualAudioSourcesOptInMigrationV1(parsed) || changed; changed = applyManualAudioSourcesOptOutResetMigrationV1(parsed) || changed;
changed = applyScreenShareContentHintDefaultMigrationV1(parsed) || changed; changed = applyScreenShareContentHintDefaultMigrationV1(parsed) || changed;
changed = applyOutputVolumeRecalibrationMigrationV1(parsed) || changed; changed = applyOutputVolumeRecalibrationMigrationV1(parsed) || changed;
changed = applyNoiseSuppressionStandardDefaultMigrationV1(parsed) || changed; changed = applyNoiseSuppressionStandardDefaultMigrationV1(parsed) || changed;
@@ -569,6 +547,7 @@ class VoiceSettings {
this.outputVolumeRecalibratedV1 = parsed.outputVolumeRecalibratedV1 === true; this.outputVolumeRecalibratedV1 = parsed.outputVolumeRecalibratedV1 === true;
this.screenShareAv1OptOutMigratedV1 = parsed.screenShareAv1OptOutMigratedV1 === true; this.screenShareAv1OptOutMigratedV1 = parsed.screenShareAv1OptOutMigratedV1 === true;
this.screenShareHevcOptOutMigratedV1 = parsed.screenShareHevcOptOutMigratedV1 === true; this.screenShareHevcOptOutMigratedV1 = parsed.screenShareHevcOptOutMigratedV1 === true;
this.manualAudioSourcesOptOutResetMigratedV1 = parsed.manualAudioSourcesOptOutResetMigratedV1 === true;
} catch (error) { } catch (error) {
logger.warn('Failed to migrate persisted voice settings:', error); logger.warn('Failed to migrate persisted voice settings:', error);
} }
@@ -640,8 +619,7 @@ class VoiceSettings {
'linuxAudioCaptureIgnoreDevices', 'linuxAudioCaptureIgnoreDevices',
'linuxAudioCaptureGranularSelect', 'linuxAudioCaptureGranularSelect',
'linuxAudioCaptureDeviceSelect', 'linuxAudioCaptureDeviceSelect',
'screenShareManualAudioSourcesOptIn', 'manualAudioSourcesOptOutResetMigratedV1',
'manualScreenShareAudioSourcesOptInMigratedV1',
'screenShareAudioSourceMode', 'screenShareAudioSourceMode',
'screenShareAudioIncludeSources', 'screenShareAudioIncludeSources',
'screenShareAudioExcludeSources', 'screenShareAudioExcludeSources',
@@ -1018,10 +996,6 @@ class VoiceSettings {
return this.linuxAudioCaptureDeviceSelect; return this.linuxAudioCaptureDeviceSelect;
} }
getScreenShareManualAudioSourcesOptIn(): boolean {
return this.screenShareManualAudioSourcesOptIn;
}
getScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' { getScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' {
return this.screenShareAudioSourceMode; return this.screenShareAudioSourceMode;
} }
@@ -1035,15 +1009,15 @@ class VoiceSettings {
} }
getEffectiveScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' { getEffectiveScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' {
return this.screenShareManualAudioSourcesOptIn ? this.screenShareAudioSourceMode : 'system'; return this.getScreenShareAudioSourceMode();
} }
getEffectiveScreenShareAudioIncludeSources(): Array<Record<string, string>> { getEffectiveScreenShareAudioIncludeSources(): Array<Record<string, string>> {
return this.screenShareManualAudioSourcesOptIn ? this.screenShareAudioIncludeSources : []; return this.getScreenShareAudioIncludeSources();
} }
getEffectiveScreenShareAudioExcludeSources(): Array<Record<string, string>> { getEffectiveScreenShareAudioExcludeSources(): Array<Record<string, string>> {
return this.screenShareManualAudioSourcesOptIn ? this.screenShareAudioExcludeSources : []; return this.getScreenShareAudioExcludeSources();
} }
getOpenH264Enabled(): boolean { getOpenH264Enabled(): boolean {
@@ -1146,8 +1120,6 @@ class VoiceSettings {
this.linuxAudioCaptureGranularSelect = validated.linuxAudioCaptureGranularSelect; this.linuxAudioCaptureGranularSelect = validated.linuxAudioCaptureGranularSelect;
if (validated.linuxAudioCaptureDeviceSelect !== undefined) if (validated.linuxAudioCaptureDeviceSelect !== undefined)
this.linuxAudioCaptureDeviceSelect = validated.linuxAudioCaptureDeviceSelect; this.linuxAudioCaptureDeviceSelect = validated.linuxAudioCaptureDeviceSelect;
if (validated.screenShareManualAudioSourcesOptIn !== undefined)
this.screenShareManualAudioSourcesOptIn = validated.screenShareManualAudioSourcesOptIn;
if (validated.screenShareAudioSourceMode !== undefined) if (validated.screenShareAudioSourceMode !== undefined)
this.screenShareAudioSourceMode = validated.screenShareAudioSourceMode; this.screenShareAudioSourceMode = validated.screenShareAudioSourceMode;
if (validated.screenShareAudioIncludeSources !== undefined) if (validated.screenShareAudioIncludeSources !== undefined)
@@ -1278,8 +1250,6 @@ class VoiceSettings {
linuxAudioCaptureIgnoreDevices: data.linuxAudioCaptureIgnoreDevices ?? this.linuxAudioCaptureIgnoreDevices, linuxAudioCaptureIgnoreDevices: data.linuxAudioCaptureIgnoreDevices ?? this.linuxAudioCaptureIgnoreDevices,
linuxAudioCaptureGranularSelect: data.linuxAudioCaptureGranularSelect ?? this.linuxAudioCaptureGranularSelect, linuxAudioCaptureGranularSelect: data.linuxAudioCaptureGranularSelect ?? this.linuxAudioCaptureGranularSelect,
linuxAudioCaptureDeviceSelect: data.linuxAudioCaptureDeviceSelect ?? this.linuxAudioCaptureDeviceSelect, linuxAudioCaptureDeviceSelect: data.linuxAudioCaptureDeviceSelect ?? this.linuxAudioCaptureDeviceSelect,
screenShareManualAudioSourcesOptIn:
data.screenShareManualAudioSourcesOptIn ?? this.screenShareManualAudioSourcesOptIn,
screenShareAudioSourceMode: validateAudioSourceMode( screenShareAudioSourceMode: validateAudioSourceMode(
data.screenShareAudioSourceMode ?? this.screenShareAudioSourceMode, data.screenShareAudioSourceMode ?? this.screenShareAudioSourceMode,
), ),
@@ -24,6 +24,12 @@ AppStorage.setItem(
preferredScreenShareCodec: 'av1', preferredScreenShareCodec: 'av1',
screenShareContentHintPrefV2: 'auto', screenShareContentHintPrefV2: 'auto',
screenshareResolution: 'low_240p', screenshareResolution: 'low_240p',
inputDeviceId: 'headset-1',
screenShareAudioSourceMode: 'specific',
screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
screenShareAudioExcludeSources: [{'application.name': 'Fluxer'}],
screenShareManualAudioSourcesOptIn: true,
manualScreenShareAudioSourcesOptInMigratedV1: true,
__mps__: {version: 1}, __mps__: {version: 1},
}), }),
); );
@@ -68,7 +74,7 @@ async function loadVoiceSettings() {
} }
const VoiceSettings = await loadVoiceSettings(); const VoiceSettings = await loadVoiceSettings();
const {applyManualAudioSourcesOptInMigrationV1} = await import('./VoiceSettings'); const {applyManualAudioSourcesOptOutResetMigrationV1} = await import('./VoiceSettings');
describe('AV1/HEVC screen-share opt-in', () => { describe('AV1/HEVC screen-share opt-in', () => {
it('rewrites a stored AV1 screen-share preference back to automatic on first launch', () => { it('rewrites a stored AV1 screen-share preference back to automatic on first launch', () => {
@@ -142,92 +148,100 @@ describe('AV1/HEVC screen-share opt-in', () => {
}); });
}); });
describe('manual screen-share audio source opt-in', () => { describe('manual screen-share audio sources', () => {
it('defaults to off and is never stored by an unrelated patch', () => { it('hydrates a profile that still stores the removed opt-in keys without losing anything else', () => {
expect(VoiceSettings.screenShareManualAudioSourcesOptIn).toBe(false); const stored = JSON.parse(storageWrites[0]);
expect(VoiceSettings.getScreenShareManualAudioSourcesOptIn()).toBe(false); expect(stored.screenShareManualAudioSourcesOptIn).toBe(true);
VoiceSettings.updateSettings({outputDeviceId: 'default'}); expect(stored.manualScreenShareAudioSourcesOptInMigratedV1).toBe(true);
expect(VoiceSettings.getScreenShareManualAudioSourcesOptIn()).toBe(false); expect(stored.manualAudioSourcesOptOutResetMigratedV1).toBe(true);
expect(VoiceSettings.inputDeviceId).toBe('headset-1');
expect(VoiceSettings.getScreenShareAudioIncludeSources()).toEqual([{'application.name': 'mpv'}]);
expect(VoiceSettings.getScreenShareAudioExcludeSources()).toEqual([{'application.name': 'Fluxer'}]);
}); });
it('leaves the manual source settings inert until the opt-in is on', () => { it('keeps the reset migration out of the way of a profile that had the opt-in on', () => {
expect(VoiceSettings.manualAudioSourcesOptOutResetMigratedV1).toBe(true);
expect(VoiceSettings.getScreenShareAudioSourceMode()).toBe('specific');
});
it('returns a profile that switched the opt-in off to the defaults it was already getting', () => {
const stored: Record<string, unknown> = {
screenShareManualAudioSourcesOptIn: false,
screenShareAudioSourceMode: 'none',
screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
screenShareAudioExcludeSources: [{'application.name': 'Fluxer'}],
inputDeviceId: 'headset-1',
};
expect(applyManualAudioSourcesOptOutResetMigrationV1(stored)).toBe(true);
expect(stored.screenShareAudioSourceMode).toBe('system');
expect(stored.screenShareAudioIncludeSources).toEqual([]);
expect(stored.screenShareAudioExcludeSources).toEqual([]);
expect(stored.inputDeviceId).toBe('headset-1');
expect(stored.manualAudioSourcesOptOutResetMigratedV1).toBe(true);
});
it('leaves every other profile untouched and never runs twice', () => {
const optedIn: Record<string, unknown> = {
screenShareManualAudioSourcesOptIn: true,
screenShareAudioSourceMode: 'specific',
screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
};
expect(applyManualAudioSourcesOptOutResetMigrationV1(optedIn)).toBe(true);
expect(optedIn.screenShareAudioSourceMode).toBe('specific');
expect(optedIn.screenShareAudioIncludeSources).toEqual([{'application.name': 'mpv'}]);
const fresh: Record<string, unknown> = {};
expect(applyManualAudioSourcesOptOutResetMigrationV1(fresh)).toBe(true);
expect(fresh).toEqual({manualAudioSourcesOptOutResetMigratedV1: true});
const alreadyMigrated: Record<string, unknown> = {
manualAudioSourcesOptOutResetMigratedV1: true,
screenShareManualAudioSourcesOptIn: false,
screenShareAudioSourceMode: 'none',
};
expect(applyManualAudioSourcesOptOutResetMigrationV1(alreadyMigrated)).toBe(false);
expect(alreadyMigrated.screenShareAudioSourceMode).toBe('none');
});
it('hands every Linux user the stored selection with no opt-in left to satisfy', () => {
VoiceSettings.updateSettings({ VoiceSettings.updateSettings({
screenShareAudioSourceMode: 'specific', screenShareAudioSourceMode: 'specific',
screenShareAudioIncludeSources: [{'application.name': 'mpv'}], screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
screenShareAudioExcludeSources: [{'application.name': 'Fluxer'}], screenShareAudioExcludeSources: [{'application.name': 'Fluxer'}],
}); });
expect(VoiceSettings.getScreenShareAudioSourceMode()).toBe('specific'); expect(VoiceSettings.getScreenShareAudioSourceMode()).toBe('specific');
expect(VoiceSettings.getEffectiveScreenShareAudioSourceMode()).toBe('system');
expect(VoiceSettings.getEffectiveScreenShareAudioIncludeSources()).toEqual([]);
expect(VoiceSettings.getEffectiveScreenShareAudioExcludeSources()).toEqual([]);
VoiceSettings.updateSettings({screenShareManualAudioSourcesOptIn: true});
expect(VoiceSettings.getEffectiveScreenShareAudioSourceMode()).toBe('specific'); expect(VoiceSettings.getEffectiveScreenShareAudioSourceMode()).toBe('specific');
expect(VoiceSettings.getEffectiveScreenShareAudioIncludeSources()).toEqual([{'application.name': 'mpv'}]); expect(VoiceSettings.getEffectiveScreenShareAudioIncludeSources()).toEqual([{'application.name': 'mpv'}]);
expect(VoiceSettings.getEffectiveScreenShareAudioExcludeSources()).toEqual([{'application.name': 'Fluxer'}]); expect(VoiceSettings.getEffectiveScreenShareAudioExcludeSources()).toEqual([{'application.name': 'Fluxer'}]);
VoiceSettings.updateSettings({screenShareAudioSourceMode: 'none'});
expect(VoiceSettings.getEffectiveScreenShareAudioSourceMode()).toBe('none');
VoiceSettings.updateSettings({ VoiceSettings.updateSettings({
screenShareManualAudioSourcesOptIn: false, screenShareAudioSourceMode: 'system',
screenShareAudioSourceMode: 'none', screenShareAudioIncludeSources: [],
screenShareAudioExcludeSources: [],
}); });
expect(VoiceSettings.getEffectiveScreenShareAudioSourceMode()).toBe('system'); expect(VoiceSettings.getEffectiveScreenShareAudioSourceMode()).toBe('system');
expect(VoiceSettings.getScreenShareAudioSourceMode()).toBe('none'); expect(VoiceSettings.getEffectiveScreenShareAudioIncludeSources()).toEqual([]);
expect(VoiceSettings.getEffectiveScreenShareAudioExcludeSources()).toEqual([]);
}); });
it('stores and persists the opt-in in both directions', async () => { it('drops the removed opt-in keys from storage on the next write and keeps the selection', async () => {
VoiceSettings.updateSettings({screenShareManualAudioSourcesOptIn: true}); VoiceSettings.updateSettings({
expect(VoiceSettings.getScreenShareManualAudioSourcesOptIn()).toBe(true);
await vi.waitFor(() =>
expect(JSON.parse(storageWrites[storageWrites.length - 1]).screenShareManualAudioSourcesOptIn).toBe(true),
);
VoiceSettings.updateSettings({screenShareManualAudioSourcesOptIn: false});
expect(VoiceSettings.getScreenShareManualAudioSourcesOptIn()).toBe(false);
await vi.waitFor(() =>
expect(JSON.parse(storageWrites[storageWrites.length - 1]).screenShareManualAudioSourcesOptIn).toBe(false),
);
});
it('opts a fresh profile out and records the migration', () => {
const migrated = JSON.parse(storageWrites[0]);
expect(migrated.manualScreenShareAudioSourcesOptInMigratedV1).toBe(true);
expect(migrated.screenShareManualAudioSourcesOptIn).toBeUndefined();
});
it('opts in only profiles that already configured audio sources', () => {
const cases: Array<[Record<string, unknown>, boolean]> = [
[{}, false],
[{screenShareAudioSourceMode: 'system'}, false],
[{screenShareAudioIncludeSources: [], screenShareAudioExcludeSources: []}, false],
[{linuxDeviceShareAppAudioOptIn: true}, true],
[{screenShareAudioSourceMode: 'specific'}, true],
[{screenShareAudioSourceMode: 'none'}, true],
[{screenShareAudioIncludeSources: [{'application.name': 'mpv'}]}, true],
[{screenShareAudioExcludeSources: [{'application.name': 'mpv'}]}, true],
[{linuxAudioCaptureIgnoreInputMedia: true, linuxAudioCaptureOnlySpeakers: true}, false],
[{linuxAudioCaptureIgnoreInputMedia: false}, true],
[{linuxAudioCaptureOnlySpeakers: false}, true],
[{linuxAudioCaptureIgnoreVirtual: true}, true],
[{linuxAudioCaptureDeviceSelect: true}, true],
[{linuxAudioCaptureWorkaround: true}, false],
];
for (const [stored, expected] of cases) {
const before = JSON.stringify(stored);
expect(applyManualAudioSourcesOptInMigrationV1(stored)).toBe(true);
expect(stored.manualScreenShareAudioSourcesOptInMigratedV1).toBe(true);
expect(stored.screenShareManualAudioSourcesOptIn === true).toBe(expected);
delete stored.manualScreenShareAudioSourcesOptInMigratedV1;
delete stored.screenShareManualAudioSourcesOptIn;
expect(JSON.stringify(stored)).toBe(before);
}
});
it('never runs twice over the same profile', () => {
const stored: Record<string, unknown> = {
manualScreenShareAudioSourcesOptInMigratedV1: true,
screenShareAudioSourceMode: 'specific', screenShareAudioSourceMode: 'specific',
}; screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
expect(applyManualAudioSourcesOptInMigrationV1(stored)).toBe(false); });
expect(stored.screenShareManualAudioSourcesOptIn).toBeUndefined(); await vi.waitFor(() => {
const latest = JSON.parse(storageWrites[storageWrites.length - 1]);
expect(latest.screenShareManualAudioSourcesOptIn).toBeUndefined();
expect(latest.manualScreenShareAudioSourcesOptInMigratedV1).toBeUndefined();
expect(latest.manualAudioSourcesOptOutResetMigratedV1).toBe(true);
expect(latest.screenShareAudioSourceMode).toBe('specific');
expect(latest.inputDeviceId).toBe('headset-1');
});
}); });
}); });
@@ -3,6 +3,7 @@
import type {VirtmicNode} from '@app/types/electron.d'; import type {VirtmicNode} from '@app/types/electron.d';
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import { import {
filterRoutableLinuxAudioSources,
getLinuxAudioSourceDisplayName, getLinuxAudioSourceDisplayName,
LINUX_AUDIO_DISPLAY_NAME_PATTERN_KEY, LINUX_AUDIO_DISPLAY_NAME_PATTERN_KEY,
LINUX_AUDIO_PRESERVE_VOLATILE_IDENTITY_PATTERN_KEY, LINUX_AUDIO_PRESERVE_VOLATILE_IDENTITY_PATTERN_KEY,
@@ -136,6 +137,16 @@ describe('LinuxAudioSourceRules', () => {
}), }),
).toBe('Built-in speakers'); ).toBe('Built-in speakers');
}); });
it('keeps only the selections the capture layer can still match on', () => {
const routable = {'application.name': 'Firefox'};
const displayOnly = {[LINUX_AUDIO_DISPLAY_NAME_PATTERN_KEY]: 'Firefox'};
const volatileOnly = {'application.process.id': '1234'};
expect(filterRoutableLinuxAudioSources([routable, displayOnly, volatileOnly])).toEqual([routable, volatileOnly]);
expect(filterRoutableLinuxAudioSources([{...routable, [LINUX_AUDIO_DISPLAY_NAME_PATTERN_KEY]: 'Firefox'}])).toEqual(
[{...routable, [LINUX_AUDIO_DISPLAY_NAME_PATTERN_KEY]: 'Firefox'}],
);
expect(filterRoutableLinuxAudioSources([])).toEqual([]);
});
it('deduplicates identical item values without hiding same-named different selectors', () => { it('deduplicates identical item values without hiding same-named different selectors', () => {
const duplicate = {name: 'Firefox', value: {'application.name': 'Firefox'}}; const duplicate = {name: 'Firefox', value: {'application.name': 'Firefox'}};
const pidSpecific = {name: 'Firefox', value: {'application.name': 'Firefox', 'application.process.id': '1234'}}; const pidSpecific = {name: 'Firefox', value: {'application.name': 'Firefox', 'application.process.id': '1234'}};
@@ -180,3 +180,7 @@ export function toNativeLinuxAudioPatterns(patterns: Array<VirtmicNode>): Array<
return next ? [next] : []; return next ? [next] : [];
}); });
} }
export function filterRoutableLinuxAudioSources(patterns: ReadonlyArray<VirtmicNode>): Array<VirtmicNode> {
return patterns.filter((pattern) => toNativeLinuxAudioPattern(pattern) != null);
}
@@ -0,0 +1,181 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it, vi} from 'vitest';
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
});
const {
APP_COUNT_DESCRIPTOR,
CUSTOM_SOURCES_DESCRIPTOR,
ENTIRE_SYSTEM_DESCRIPTOR,
MICROPHONE_DESCRIPTOR,
MICROPHONE_WITH_DEVICE_DESCRIPTOR,
NO_AUDIO_DESCRIPTOR,
resolveScreenShareAudioSummary,
SHARED_WINDOW_DESCRIPTOR,
} = await import('./ScreenShareAudioSummary');
type ScreenShareAudioSummaryInput = Parameters<typeof resolveScreenShareAudioSummary>[0];
function summary(overrides: Partial<ScreenShareAudioSummaryInput> = {}) {
return resolveScreenShareAudioSummary({
sourceMode: 'system',
includeSources: [],
shareContext: 'display',
displayShareEnvironment: 'desktop-custom',
...overrides,
});
}
describe('resolveScreenShareAudioSummary', () => {
it('says no audio on a display share once the sources are switched off', () => {
expect(summary({shareContext: 'display', sourceMode: 'none'})).toEqual({
kind: 'message',
descriptor: NO_AUDIO_DESCRIPTOR,
});
expect(summary({shareContext: 'app', windowAudioScope: 'system', sourceMode: 'none'})).toEqual({
kind: 'message',
descriptor: NO_AUDIO_DESCRIPTOR,
});
});
it('never says no audio on a device share, whose microphone the source mode cannot switch off', () => {
expect(summary({shareContext: 'device', sourceMode: 'none', microphoneLabel: 'Yeti'})).toEqual({
kind: 'message',
descriptor: MICROPHONE_WITH_DEVICE_DESCRIPTOR,
values: {deviceLabel: 'Yeti'},
});
});
it('never says no audio on a window share it still captures the window of', () => {
expect(summary({shareContext: 'app', sourceMode: 'none', windowAudioScope: 'window'})).toEqual({
kind: 'message',
descriptor: SHARED_WINDOW_DESCRIPTOR,
});
});
it('names the single picked application in every share context, capture cards included', () => {
for (const shareContext of ['device', 'display'] as const) {
expect(
summary({
shareContext,
sourceMode: 'specific',
includeSources: [{'application.name': 'mpv'}],
microphoneLabel: 'Yeti',
}),
).toEqual({kind: 'sourceName', name: 'mpv'});
}
expect(
summary({
shareContext: 'app',
windowAudioScope: 'system',
sourceMode: 'specific',
includeSources: [{'application.name': 'mpv'}],
}),
).toEqual({kind: 'sourceName', name: 'mpv'});
});
it('reads a selection the capture layer cannot express as the scope actually armed', () => {
expect(summary({sourceMode: 'specific', includeSources: [{'fluxer.display.name': 'mpv'}]})).toEqual({
kind: 'message',
descriptor: ENTIRE_SYSTEM_DESCRIPTOR,
});
expect(
summary({
shareContext: 'app',
windowAudioScope: 'system',
sourceMode: 'specific',
includeSources: [{'fluxer.display.name': 'mpv'}],
}),
).toEqual({kind: 'message', descriptor: ENTIRE_SYSTEM_DESCRIPTOR});
expect(
summary({
shareContext: 'device',
microphoneLabel: 'Yeti',
sourceMode: 'specific',
includeSources: [{'fluxer.display.name': 'mpv'}],
}),
).toEqual({kind: 'message', descriptor: MICROPHONE_WITH_DEVICE_DESCRIPTOR, values: {deviceLabel: 'Yeti'}});
});
it('counts the picked applications past the first one', () => {
expect(
summary({
sourceMode: 'specific',
includeSources: [{'application.name': 'mpv'}, {'application.name': 'Firefox'}],
}),
).toEqual({kind: 'message', descriptor: APP_COUNT_DESCRIPTOR, values: {length: 2}});
});
it('falls back to a custom label for a picked source with no readable name', () => {
expect(summary({sourceMode: 'specific', includeSources: [{'object.serial': '41'}]})).toEqual({
kind: 'message',
descriptor: CUSTOM_SOURCES_DESCRIPTOR,
});
});
it('reads an empty specific selection as the wide scope it actually routes', () => {
expect(summary({sourceMode: 'specific', includeSources: []})).toEqual({
kind: 'message',
descriptor: ENTIRE_SYSTEM_DESCRIPTOR,
});
expect(summary({shareContext: 'app', sourceMode: 'specific', includeSources: []})).toEqual({
kind: 'message',
descriptor: SHARED_WINDOW_DESCRIPTOR,
});
expect(
summary({shareContext: 'app', windowAudioScope: 'system', sourceMode: 'specific', includeSources: []}),
).toEqual({kind: 'message', descriptor: ENTIRE_SYSTEM_DESCRIPTOR});
});
it('names the microphone on a device share and its device when one is resolved', () => {
expect(summary({shareContext: 'device', microphoneLabel: 'Yeti'})).toEqual({
kind: 'message',
descriptor: MICROPHONE_WITH_DEVICE_DESCRIPTOR,
values: {deviceLabel: 'Yeti'},
});
expect(summary({shareContext: 'device'})).toEqual({kind: 'message', descriptor: MICROPHONE_DESCRIPTOR});
expect(summary({shareContext: 'device', microphoneLabel: ''})).toEqual({
kind: 'message',
descriptor: MICROPHONE_DESCRIPTOR,
});
});
it('keeps a window share on the shared window whatever the stored display selection says', () => {
for (const sourceMode of ['none', 'system', 'specific'] as const) {
expect(summary({shareContext: 'app', sourceMode, includeSources: [{'application.name': 'mpv'}]})).toEqual({
kind: 'message',
descriptor: SHARED_WINDOW_DESCRIPTOR,
});
}
});
it('says the shared window on a window share and the entire system once it is widened', () => {
expect(summary({shareContext: 'app'})).toEqual({kind: 'message', descriptor: SHARED_WINDOW_DESCRIPTOR});
expect(summary({shareContext: 'app', windowAudioScope: 'window'})).toEqual({
kind: 'message',
descriptor: SHARED_WINDOW_DESCRIPTOR,
});
expect(summary({shareContext: 'app', windowAudioScope: 'system'})).toEqual({
kind: 'message',
descriptor: ENTIRE_SYSTEM_DESCRIPTOR,
});
});
it('never promises a Wayland window share its own window audio', () => {
expect(
summary({shareContext: 'app', displayShareEnvironment: 'desktop-wayland', windowAudioScope: 'window'}),
).toEqual({kind: 'message', descriptor: ENTIRE_SYSTEM_DESCRIPTOR});
});
it('says the entire system on a display share whatever the window scope holds', () => {
for (const windowAudioScope of ['window', 'system'] as const) {
expect(summary({shareContext: 'display', windowAudioScope})).toEqual({
kind: 'message',
descriptor: ENTIRE_SYSTEM_DESCRIPTOR,
});
}
});
});
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
filterRoutableLinuxAudioSources,
getLinuxAudioSourceDisplayName,
} from '@app/features/voice/utils/LinuxAudioSourceRules';
import type {DisplayShareEnvironment} from '@app/features/voice/utils/ScreenShareEnvironment';
import {
resolveWindowShareAudioScope,
type ScreenShareAudioSourceMode,
type StreamSettingsShareContext,
selectAppShareAudioRoute,
supportsWindowShareAudioScope,
type WindowShareAudioScope,
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
import type {VirtmicNode} from '@app/types/electron.d';
import type {I18n, MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
export const NO_AUDIO_DESCRIPTOR = msg({
message: 'No audio',
comment: 'Screen-share audio summary shown when the share publishes no audio at all.',
});
export const CUSTOM_SOURCES_DESCRIPTOR = msg({
message: 'Custom',
comment: 'Screen-share audio summary shown when one audio source is selected but it has no readable name.',
});
export const APP_COUNT_DESCRIPTOR = msg({
message: '{length} apps',
comment: 'Screen-share audio summary listing how many apps are captured. {length} is the integer app count.',
});
export const ENTIRE_SYSTEM_DESCRIPTOR = msg({
message: 'Entire system',
comment: 'Screen-share audio summary shown when the whole system audio mix is captured.',
});
export const SHARED_WINDOW_DESCRIPTOR = msg({
message: 'Shared window',
comment: 'Screen-share audio summary shown when a window share captures only the audio of the window it shares.',
});
export const MICROPHONE_DESCRIPTOR = msg({
message: 'Microphone',
comment: 'Screen-share audio summary on a video device share whose audio comes from an unnamed microphone.',
});
export const MICROPHONE_WITH_DEVICE_DESCRIPTOR = msg({
message: 'Microphone ({deviceLabel})',
comment:
'Screen-share audio summary on a video device share. {deviceLabel} is the name of the selected audio input device.',
});
export interface ScreenShareAudioSummaryInput {
sourceMode: ScreenShareAudioSourceMode;
includeSources: ReadonlyArray<VirtmicNode>;
shareContext: StreamSettingsShareContext;
microphoneLabel?: string | null;
displayShareEnvironment?: DisplayShareEnvironment;
windowAudioScope?: WindowShareAudioScope;
}
export type ScreenShareAudioSummary =
| {readonly kind: 'sourceName'; readonly name: string}
| {
readonly kind: 'message';
readonly descriptor: MessageDescriptor;
readonly values?: Record<string, string | number>;
};
function summariseSelectedSources(selected: ReadonlyArray<VirtmicNode>): ScreenShareAudioSummary {
if (selected.length === 1) {
const name = getLinuxAudioSourceDisplayName(selected[0]);
return name == null ? {kind: 'message', descriptor: CUSTOM_SOURCES_DESCRIPTOR} : {kind: 'sourceName', name};
}
return {kind: 'message', descriptor: APP_COUNT_DESCRIPTOR, values: {length: selected.length}};
}
function summariseMicrophone(microphoneLabel?: string | null): ScreenShareAudioSummary {
if (microphoneLabel == null || microphoneLabel === '') {
return {kind: 'message', descriptor: MICROPHONE_DESCRIPTOR};
}
return {kind: 'message', descriptor: MICROPHONE_WITH_DEVICE_DESCRIPTOR, values: {deviceLabel: microphoneLabel}};
}
export function resolveScreenShareAudioSummary(input: ScreenShareAudioSummaryInput): ScreenShareAudioSummary {
const selected = filterRoutableLinuxAudioSources(input.includeSources);
const routesSelectedSources = input.sourceMode === 'specific' && selected.length > 0;
if (input.shareContext === 'device') {
return routesSelectedSources ? summariseSelectedSources(selected) : summariseMicrophone(input.microphoneLabel);
}
if (supportsWindowShareAudioScope(input)) {
const route = selectAppShareAudioRoute({
audioSourceMode: input.sourceMode,
selectedSourceCount: selected.length,
windowAudioScope: resolveWindowShareAudioScope(input),
});
if (route === 'window') return {kind: 'message', descriptor: SHARED_WINDOW_DESCRIPTOR};
if (route === 'none') return {kind: 'message', descriptor: NO_AUDIO_DESCRIPTOR};
if (route === 'apps') return summariseSelectedSources(selected);
return {kind: 'message', descriptor: ENTIRE_SYSTEM_DESCRIPTOR};
}
if (input.sourceMode === 'none') return {kind: 'message', descriptor: NO_AUDIO_DESCRIPTOR};
if (routesSelectedSources) return summariseSelectedSources(selected);
return {kind: 'message', descriptor: ENTIRE_SYSTEM_DESCRIPTOR};
}
export function formatScreenShareAudioSummary(i18n: I18n, input: ScreenShareAudioSummaryInput): string {
const summary = resolveScreenShareAudioSummary(input);
if (summary.kind === 'sourceName') return summary.name;
return i18n._(summary.descriptor, summary.values);
}
@@ -8,11 +8,14 @@ const voiceSettings = {
audioSourceMode: 'system' as 'none' | 'system' | 'specific', audioSourceMode: 'system' as 'none' | 'system' | 'specific',
includeSources: [] as Array<Record<string, string>>, includeSources: [] as Array<Record<string, string>>,
excludeSources: [] as Array<Record<string, string>>, excludeSources: [] as Array<Record<string, string>>,
manualAudioSourcesOptIn: false,
audioDeviceId: 'default', audioDeviceId: 'default',
}; };
const activeShareContext = {current: null as 'app' | 'device' | 'display' | null}; const activeShareContext = {current: null as 'app' | 'device' | 'display' | null};
const activeShareSourceId = {current: null as string | null};
const activeShareOwnWindow = {current: false};
const activeWindowAudioScope = {current: 'window' as 'window' | 'system'};
const pendingWindowAudioScope = {current: null as 'window' | 'system' | null};
const activeShareVideoDeviceId = {current: ''}; const activeShareVideoDeviceId = {current: ''};
const nativeAudioAvailability = { const nativeAudioAvailability = {
@@ -27,8 +30,10 @@ const setScreenShareEnabled = vi.fn(async () => {});
const replaceActiveDisplayScreenShare = vi.fn(async () => true); const replaceActiveDisplayScreenShare = vi.fn(async () => true);
const startDeviceScreenShare = vi.fn(async () => {}); const startDeviceScreenShare = vi.fn(async () => {});
const replaceActiveDeviceScreenShare = vi.fn(async () => true); const replaceActiveDeviceScreenShare = vi.fn(async () => true);
const disarmNativeAudio = vi.fn();
const ensureLinuxScreenShareAudioPublication = vi.fn(async (_rule: Record<string, unknown>) => true); const ensureLinuxScreenShareAudioPublication = vi.fn(async (_rule: Record<string, unknown>) => true);
const ensureDeviceScreenShareMicPublication = vi.fn(async () => true); const ensureDeviceScreenShareMicPublication = vi.fn(async () => true);
const ensureWindowScreenShareAudioPublication = vi.fn(async (_sourceId: string) => true);
const armNativeAudioForNextCapture = vi.fn(async () => true); const armNativeAudioForNextCapture = vi.fn(async () => true);
const armNativeSystemAudioForNextCapture = vi.fn(async () => true); const armNativeSystemAudioForNextCapture = vi.fn(async () => true);
const armNativeAudioForLinuxRouting = vi.fn(async (_rule: Record<string, unknown>) => true); const armNativeAudioForLinuxRouting = vi.fn(async (_rule: Record<string, unknown>) => true);
@@ -53,6 +58,7 @@ vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({
replaceActiveDeviceScreenShare, replaceActiveDeviceScreenShare,
ensureLinuxScreenShareAudioPublication, ensureLinuxScreenShareAudioPublication,
ensureDeviceScreenShareMicPublication, ensureDeviceScreenShareMicPublication,
ensureWindowScreenShareAudioPublication,
getActiveScreenShareVideoDeviceId: () => activeShareVideoDeviceId.current, getActiveScreenShareVideoDeviceId: () => activeShareVideoDeviceId.current,
}, },
})); }));
@@ -66,7 +72,7 @@ vi.mock('@app/features/voice/utils/NativeAudioCaptureBridge', () => ({
armNativeAudioForLinuxRouting, armNativeAudioForLinuxRouting,
armNativeAudioForNextCapture, armNativeAudioForNextCapture,
armNativeSystemAudioForNextCapture, armNativeSystemAudioForNextCapture,
disarmNativeAudio: vi.fn(), disarmNativeAudio,
disarmPendingNativeAudio: vi.fn(), disarmPendingNativeAudio: vi.fn(),
getLastNativeAudioArmFailure: () => null, getLastNativeAudioArmFailure: () => null,
getNativeAudioAvailabilityCached: async () => nativeAudioAvailability.current, getNativeAudioAvailabilityCached: async () => nativeAudioAvailability.current,
@@ -80,8 +86,14 @@ vi.mock('@app/features/voice/state/ActiveScreenShareSource', () => ({
default: { default: {
setPublishedSource: vi.fn(), setPublishedSource: vi.fn(),
clear: vi.fn(), clear: vi.fn(),
getSourceId: () => null, getSourceId: () => activeShareSourceId.current,
getShareContext: () => activeShareContext.current, getShareContext: () => activeShareContext.current,
isOwnWindow: () => activeShareOwnWindow.current,
getWindowAudioScope: () => activeWindowAudioScope.current,
setWindowAudioScope: vi.fn((scope: 'window' | 'system') => {
activeWindowAudioScope.current = scope;
}),
getPendingWindowAudioScope: () => pendingWindowAudioScope.current ?? activeWindowAudioScope.current,
}, },
})); }));
@@ -137,13 +149,9 @@ vi.mock('@app/features/voice/state/VoiceSettings', () => ({
getScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode, getScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode,
getScreenShareAudioIncludeSources: () => voiceSettings.includeSources, getScreenShareAudioIncludeSources: () => voiceSettings.includeSources,
getScreenShareAudioExcludeSources: () => voiceSettings.excludeSources, getScreenShareAudioExcludeSources: () => voiceSettings.excludeSources,
getScreenShareManualAudioSourcesOptIn: () => voiceSettings.manualAudioSourcesOptIn, getEffectiveScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode,
getEffectiveScreenShareAudioSourceMode: () => getEffectiveScreenShareAudioIncludeSources: () => voiceSettings.includeSources,
voiceSettings.manualAudioSourcesOptIn ? voiceSettings.audioSourceMode : 'system', getEffectiveScreenShareAudioExcludeSources: () => voiceSettings.excludeSources,
getEffectiveScreenShareAudioIncludeSources: () =>
voiceSettings.manualAudioSourcesOptIn ? voiceSettings.includeSources : [],
getEffectiveScreenShareAudioExcludeSources: () =>
voiceSettings.manualAudioSourcesOptIn ? voiceSettings.excludeSources : [],
getLinuxAudioCaptureIgnoreInputMedia: () => true, getLinuxAudioCaptureIgnoreInputMedia: () => true,
getLinuxAudioCaptureIgnoreVirtual: () => false, getLinuxAudioCaptureIgnoreVirtual: () => false,
getLinuxAudioCaptureIgnoreDevices: () => true, getLinuxAudioCaptureIgnoreDevices: () => true,
@@ -153,8 +161,9 @@ vi.mock('@app/features/voice/state/VoiceSettings', () => ({
})); }));
const { const {
reapplyActiveScreenShareAudioSources, applyLiveScreenShareAudioSourceChange,
reconfigureActiveDeviceShareAudio, reconfigureActiveDeviceShareAudio,
reconfigureActiveLinuxAppShareAudio,
startConfiguredDeviceScreenShare, startConfiguredDeviceScreenShare,
startConfiguredDisplayScreenShare, startConfiguredDisplayScreenShare,
switchConfiguredDeviceScreenShare, switchConfiguredDeviceScreenShare,
@@ -168,9 +177,12 @@ beforeEach(() => {
voiceSettings.audioSourceMode = 'system'; voiceSettings.audioSourceMode = 'system';
voiceSettings.includeSources = []; voiceSettings.includeSources = [];
voiceSettings.excludeSources = []; voiceSettings.excludeSources = [];
voiceSettings.manualAudioSourcesOptIn = false;
voiceSettings.audioDeviceId = 'default'; voiceSettings.audioDeviceId = 'default';
activeShareContext.current = null; activeShareContext.current = null;
activeShareSourceId.current = null;
activeShareOwnWindow.current = false;
activeWindowAudioScope.current = 'window';
pendingWindowAudioScope.current = null;
activeShareVideoDeviceId.current = ''; activeShareVideoDeviceId.current = '';
nativeAudioAvailability.current = { nativeAudioAvailability.current = {
available: true, available: true,
@@ -183,6 +195,7 @@ beforeEach(() => {
replaceActiveDeviceScreenShare.mockResolvedValue(true); replaceActiveDeviceScreenShare.mockResolvedValue(true);
ensureLinuxScreenShareAudioPublication.mockResolvedValue(true); ensureLinuxScreenShareAudioPublication.mockResolvedValue(true);
ensureDeviceScreenShareMicPublication.mockResolvedValue(true); ensureDeviceScreenShareMicPublication.mockResolvedValue(true);
ensureWindowScreenShareAudioPublication.mockResolvedValue(true);
enumerateDevices.mockResolvedValue([]); enumerateDevices.mockResolvedValue([]);
}); });
@@ -203,32 +216,42 @@ describe('sharing a video device', () => {
} }
}); });
test('keeps the microphone and ignores a stored source selection while the opt-in is off', async () => { test('opens no microphone and links the selected applications', async () => {
platform.current = 'linux'; platform.current = 'linux';
voiceSettings.audioSourceMode = 'specific'; voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}]; voiceSettings.includeSources = [{'application.name': 'mpv'}];
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true); expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBe('mic-1');
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
});
test('opens no microphone and links the selected applications once the opt-in is on', async () => {
platform.current = 'linux';
voiceSettings.manualAudioSourcesOptIn = true;
voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}];
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBeUndefined(); expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBeUndefined();
expect(ensureLinuxScreenShareAudioPublication).toHaveBeenCalledTimes(1); expect(ensureLinuxScreenShareAudioPublication).toHaveBeenCalledTimes(1);
}); });
test('keeps the microphone while the opt-in is on but no application is selected', async () => { test('keeps the microphone while no application is selected', async () => {
platform.current = 'linux'; platform.current = 'linux';
voiceSettings.manualAudioSourcesOptIn = true;
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBe('mic-1');
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
});
test('keeps the microphone on a device share whose stored source mode says none', async () => {
platform.current = 'linux';
voiceSettings.audioSourceMode = 'none';
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBe('mic-1');
expect(await reconfigureActiveDeviceShareAudio()).toBe(true);
expect(ensureDeviceScreenShareMicPublication).toHaveBeenCalledWith('mic-1');
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
});
test('keeps the microphone when the picked applications cannot be routed', async () => {
platform.current = 'linux';
voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'fluxer.display.name': 'mpv'}];
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true); expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
@@ -237,7 +260,6 @@ describe('sharing a video device', () => {
}); });
test('never routes application audio into a device share off Linux', async () => { test('never routes application audio into a device share off Linux', async () => {
voiceSettings.manualAudioSourcesOptIn = true;
voiceSettings.audioSourceMode = 'specific'; voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}]; voiceSettings.includeSources = [{'application.name': 'mpv'}];
@@ -250,7 +272,6 @@ describe('sharing a video device', () => {
test('never routes application audio when the capture layer cannot express a selection', async () => { test('never routes application audio when the capture layer cannot express a selection', async () => {
platform.current = 'linux'; platform.current = 'linux';
nativeAudioAvailability.current = {available: false, backend: 'linux-pipewire'}; nativeAudioAvailability.current = {available: false, backend: 'linux-pipewire'};
voiceSettings.manualAudioSourcesOptIn = true;
voiceSettings.audioSourceMode = 'specific'; voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}]; voiceSettings.includeSources = [{'application.name': 'mpv'}];
@@ -301,7 +322,6 @@ describe('sharing a video device', () => {
test('relinks the selected applications after switching the capture device', async () => { test('relinks the selected applications after switching the capture device', async () => {
platform.current = 'linux'; platform.current = 'linux';
voiceSettings.manualAudioSourcesOptIn = true;
voiceSettings.audioSourceMode = 'specific'; voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}]; voiceSettings.includeSources = [{'application.name': 'mpv'}];
@@ -313,7 +333,6 @@ describe('sharing a video device', () => {
test('does not relink application audio when the device switch fails', async () => { test('does not relink application audio when the device switch fails', async () => {
platform.current = 'linux'; platform.current = 'linux';
voiceSettings.manualAudioSourcesOptIn = true;
voiceSettings.audioSourceMode = 'specific'; voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}]; voiceSettings.includeSources = [{'application.name': 'mpv'}];
replaceActiveDeviceScreenShare.mockResolvedValue(false); replaceActiveDeviceScreenShare.mockResolvedValue(false);
@@ -325,7 +344,6 @@ describe('sharing a video device', () => {
test('swaps a live device share between the microphone and the selected applications', async () => { test('swaps a live device share between the microphone and the selected applications', async () => {
platform.current = 'linux'; platform.current = 'linux';
voiceSettings.manualAudioSourcesOptIn = true;
voiceSettings.audioSourceMode = 'specific'; voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}]; voiceSettings.includeSources = [{'application.name': 'mpv'}];
@@ -360,10 +378,8 @@ describe('sharing a video device', () => {
}); });
describe('sharing a whole display', () => { describe('sharing a whole display', () => {
test('captures the desktop mix without Fluxer on Linux, whatever is stored while opted out', async () => { test('captures the desktop mix without Fluxer on Linux when nothing is selected', async () => {
platform.current = 'linux'; platform.current = 'linux';
voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}];
voiceSettings.excludeSources = [{'application.name': 'Discord'}]; voiceSettings.excludeSources = [{'application.name': 'Discord'}];
expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true); expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true);
@@ -371,26 +387,26 @@ describe('sharing a whole display', () => {
expect(armNativeAudioForLinuxRouting).toHaveBeenCalledTimes(1); expect(armNativeAudioForLinuxRouting).toHaveBeenCalledTimes(1);
expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({ expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({
include: [], include: [],
exclude: [], exclude: [{'application.name': 'Discord'}],
ignoreInputMedia: true, ignoreInputMedia: true,
onlySpeakers: true, onlySpeakers: true,
onlyDefaultSpeakers: true, onlyDefaultSpeakers: true,
}); });
}); });
test('still publishes the desktop mix when a stored no-audio selection is opted out of', async () => { test('publishes no audio at all when the stored selection asks for none', async () => {
platform.current = 'linux'; platform.current = 'linux';
voiceSettings.audioSourceMode = 'none'; voiceSettings.audioSourceMode = 'none';
expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true); expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true);
expect(armNativeAudioForLinuxRouting).toHaveBeenCalledTimes(1); expect(armNativeAudioForLinuxRouting).not.toHaveBeenCalled();
expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({include: []}); const call = setScreenShareEnabled.mock.calls[0] as unknown as [boolean, {audio: boolean}, unknown];
expect(call[1].audio).toBe(false);
}); });
test('honours the stored include and exclude lists once the opt-in is on', async () => { test('honours the stored include list', async () => {
platform.current = 'linux'; platform.current = 'linux';
voiceSettings.manualAudioSourcesOptIn = true;
voiceSettings.audioSourceMode = 'specific'; voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}]; voiceSettings.includeSources = [{'application.name': 'mpv'}];
@@ -447,6 +463,206 @@ describe('sharing another application window while app audio is enabled', () =>
expect(armNativeAudioForNextCapture).toHaveBeenCalledWith('window:42:0'); expect(armNativeAudioForNextCapture).toHaveBeenCalledWith('window:42:0');
expect(setScreenShareEnabled).not.toHaveBeenCalled(); expect(setScreenShareEnabled).not.toHaveBeenCalled();
}); });
test('takes the shared window own audio by default on Linux, never the whole system mix', async () => {
platform.current = 'linux';
voiceSettings.excludeSources = [{'application.name': 'Discord'}];
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
expect(armNativeAudioForNextCapture).toHaveBeenCalledWith('window:42:0');
expect(armNativeAudioForLinuxRouting).not.toHaveBeenCalled();
});
test('widens a Linux window share to the system mix minus the stored exclusions on request', async () => {
platform.current = 'linux';
pendingWindowAudioScope.current = 'system';
voiceSettings.excludeSources = [{'application.name': 'Discord'}];
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
expect(armNativeAudioForNextCapture).not.toHaveBeenCalled();
expect(armNativeAudioForLinuxRouting).toHaveBeenCalledTimes(1);
expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({
include: [],
exclude: [{'application.name': 'Discord'}],
onlySpeakers: true,
onlyDefaultSpeakers: true,
});
});
test('ignores a stored display-share selection until the window scope is widened', async () => {
for (const audioSourceMode of ['none', 'specific'] as const) {
vi.clearAllMocks();
platform.current = 'linux';
voiceSettings.audioSourceMode = audioSourceMode;
voiceSettings.includeSources = [{'application.name': 'mpv'}];
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
expect(armNativeAudioForNextCapture).toHaveBeenCalledWith('window:42:0');
expect(armNativeAudioForLinuxRouting).not.toHaveBeenCalled();
}
});
test('routes the picked applications once the window share was widened to the system', async () => {
platform.current = 'linux';
pendingWindowAudioScope.current = 'system';
voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}];
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
expect(armNativeAudioForNextCapture).not.toHaveBeenCalled();
expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({
include: [{'application.name': 'mpv'}],
});
});
test('captures the shared window rather than the whole desktop when the picked apps cannot be routed', async () => {
platform.current = 'linux';
voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'fluxer.display.name': 'mpv'}];
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
expect(armNativeAudioForNextCapture).toHaveBeenCalledWith('window:42:0');
expect(armNativeAudioForLinuxRouting).not.toHaveBeenCalled();
});
test('publishes no audio at all on a widened Linux window share asked for none', async () => {
platform.current = 'linux';
pendingWindowAudioScope.current = 'system';
voiceSettings.audioSourceMode = 'none';
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
expect(armNativeAudioForNextCapture).not.toHaveBeenCalled();
expect(armNativeAudioForLinuxRouting).not.toHaveBeenCalled();
const call = setScreenShareEnabled.mock.calls[0] as unknown as [boolean, {audio: boolean}, unknown];
expect(call[1].audio).toBe(false);
});
test('commits the scope it armed to the running share', async () => {
platform.current = 'linux';
pendingWindowAudioScope.current = 'system';
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
expect(ActiveScreenShareSource.setWindowAudioScope).toHaveBeenCalledWith('system');
});
test('never carries a window scope over into a display share', async () => {
platform.current = 'linux';
pendingWindowAudioScope.current = 'system';
expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true);
expect(ActiveScreenShareSource.setWindowAudioScope).toHaveBeenCalledWith('window');
});
});
describe('changing the audio sources of a live window share', () => {
test('re-publishes the shared window own audio while the scope is the window', async () => {
platform.current = 'linux';
activeShareSourceId.current = 'window:42:0';
expect(await reconfigureActiveLinuxAppShareAudio()).toBe(true);
expect(ensureWindowScreenShareAudioPublication).toHaveBeenCalledWith('window:42:0');
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
});
test('relinks a widened window share through the manual rule instead', async () => {
platform.current = 'linux';
activeShareSourceId.current = 'window:42:0';
activeWindowAudioScope.current = 'system';
voiceSettings.excludeSources = [{'application.name': 'Discord'}];
expect(await reconfigureActiveLinuxAppShareAudio()).toBe(true);
expect(ensureWindowScreenShareAudioPublication).not.toHaveBeenCalled();
expect(ensureLinuxScreenShareAudioPublication.mock.calls[0][0]).toMatchObject({
include: [],
exclude: [{'application.name': 'Discord'}],
});
});
test('relinks the picked applications on a live window share that was widened', async () => {
platform.current = 'linux';
activeShareSourceId.current = 'window:42:0';
activeWindowAudioScope.current = 'system';
voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}];
expect(await reconfigureActiveLinuxAppShareAudio()).toBe(true);
expect(ensureWindowScreenShareAudioPublication).not.toHaveBeenCalled();
expect(ensureLinuxScreenShareAudioPublication.mock.calls[0][0]).toMatchObject({
include: [{'application.name': 'mpv'}],
});
});
test('leaves a window share alone off Linux, and drops its audio when no window source is published', async () => {
platform.current = 'darwin';
activeShareSourceId.current = 'window:42:0';
expect(await reconfigureActiveLinuxAppShareAudio()).toBe(false);
expect(disarmNativeAudio).not.toHaveBeenCalled();
platform.current = 'linux';
activeShareSourceId.current = null;
expect(await reconfigureActiveLinuxAppShareAudio()).toBe(false);
expect(disarmNativeAudio).toHaveBeenCalledTimes(1);
expect(ensureWindowScreenShareAudioPublication).not.toHaveBeenCalled();
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
});
test('drops the audio rather than keeping the system mix live when narrowing back to the window fails', async () => {
platform.current = 'linux';
activeShareSourceId.current = 'window:42:0';
activeWindowAudioScope.current = 'system';
ensureWindowScreenShareAudioPublication.mockResolvedValue(false);
expect(await applyLiveScreenShareAudioSourceChange('app', 'window')).toBe(false);
expect(ensureWindowScreenShareAudioPublication).toHaveBeenCalledWith('window:42:0');
expect(disarmNativeAudio).toHaveBeenCalledTimes(1);
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('system');
});
test('drops the audio rather than keeping the system mix live when the window publication throws', async () => {
platform.current = 'linux';
activeShareSourceId.current = 'window:42:0';
activeWindowAudioScope.current = 'system';
ensureWindowScreenShareAudioPublication.mockRejectedValue(new Error('window vanished'));
expect(await applyLiveScreenShareAudioSourceChange('app', 'window')).toBe(false);
expect(disarmNativeAudio).toHaveBeenCalledTimes(1);
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('system');
});
test('commits the narrowed scope only once the window audio is actually publishing', async () => {
platform.current = 'linux';
activeShareSourceId.current = 'window:42:0';
activeWindowAudioScope.current = 'system';
expect(await applyLiveScreenShareAudioSourceChange('app', 'window')).toBe(true);
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('window');
});
test('never routes audio into a Fluxer-owned window share', async () => {
platform.current = 'linux';
activeShareSourceId.current = 'window:99:0';
activeShareOwnWindow.current = true;
expect(await reconfigureActiveLinuxAppShareAudio()).toBe(false);
expect(ensureWindowScreenShareAudioPublication).not.toHaveBeenCalled();
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
});
}); });
describe('switching the display source', () => { describe('switching the display source', () => {
@@ -470,95 +686,3 @@ describe('switching the display source', () => {
expect(ActiveScreenShareSource.clear).not.toHaveBeenCalled(); expect(ActiveScreenShareSource.clear).not.toHaveBeenCalled();
}); });
}); });
describe('turning the advanced audio source opt-in off mid-share', () => {
test('relinks a live display share back to the desktop mix instead of leaving the selection running', async () => {
platform.current = 'linux';
activeShareContext.current = 'display';
voiceSettings.manualAudioSourcesOptIn = true;
voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}];
expect(await reapplyActiveScreenShareAudioSources()).toBe(true);
expect(ensureLinuxScreenShareAudioPublication.mock.calls[0][0]).toMatchObject({
include: [{'application.name': 'mpv'}],
});
voiceSettings.manualAudioSourcesOptIn = false;
expect(await reapplyActiveScreenShareAudioSources()).toBe(true);
expect(ensureLinuxScreenShareAudioPublication.mock.calls[1][0]).toMatchObject({
include: [],
exclude: [],
onlySpeakers: true,
onlyDefaultSpeakers: true,
});
});
test('gives a live device share its microphone back instead of leaving the applications routed', async () => {
platform.current = 'linux';
activeShareContext.current = 'device';
voiceSettings.manualAudioSourcesOptIn = true;
voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}];
expect(await reapplyActiveScreenShareAudioSources()).toBe(true);
expect(ensureDeviceScreenShareMicPublication).not.toHaveBeenCalled();
voiceSettings.manualAudioSourcesOptIn = false;
expect(await reapplyActiveScreenShareAudioSources()).toBe(true);
expect(ensureDeviceScreenShareMicPublication).toHaveBeenCalledWith('mic-1');
});
test('leaves a share alone when nothing was ever selected, on every share type', async () => {
platform.current = 'linux';
for (const shareContext of ['app', 'device', 'display'] as const) {
vi.clearAllMocks();
activeShareContext.current = shareContext;
voiceSettings.manualAudioSourcesOptIn = true;
voiceSettings.audioSourceMode = 'system';
voiceSettings.includeSources = [];
voiceSettings.excludeSources = [];
expect(await reapplyActiveScreenShareAudioSources()).toBe(false);
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
expect(ensureDeviceScreenShareMicPublication).not.toHaveBeenCalled();
}
});
test('leaves a window share captured by process alone instead of swapping it for the desktop mix', async () => {
platform.current = 'linux';
activeShareContext.current = 'app';
voiceSettings.manualAudioSourcesOptIn = true;
voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}];
expect(await reapplyActiveScreenShareAudioSources()).toBe(false);
voiceSettings.manualAudioSourcesOptIn = false;
expect(await reapplyActiveScreenShareAudioSources()).toBe(false);
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
});
test('leaves a video-only share, an idle client and an incapable host alone', async () => {
platform.current = 'linux';
voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}];
activeShareContext.current = null;
expect(await reapplyActiveScreenShareAudioSources()).toBe(false);
activeShareContext.current = 'display';
nativeAudioAvailability.current = {available: false, backend: 'linux-pipewire'};
expect(await reapplyActiveScreenShareAudioSources()).toBe(false);
nativeAudioAvailability.current = {available: true, capabilities: {process: true, system: true}};
platform.current = 'win32';
expect(await reapplyActiveScreenShareAudioSources()).toBe(false);
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
expect(ensureDeviceScreenShareMicPublication).not.toHaveBeenCalled();
});
});
@@ -13,6 +13,7 @@ import {
type ScreenShareContentSource, type ScreenShareContentSource,
} from '@app/features/voice/utils/CodecCapabilityDetector'; } from '@app/features/voice/utils/CodecCapabilityDetector';
import { import {
filterRoutableLinuxAudioSources,
LINUX_AUDIO_TARGET_OBJECTS_PATTERN_KEY, LINUX_AUDIO_TARGET_OBJECTS_PATTERN_KEY,
toNativeLinuxAudioPatterns, toNativeLinuxAudioPatterns,
} from '@app/features/voice/utils/LinuxAudioSourceRules'; } from '@app/features/voice/utils/LinuxAudioSourceRules';
@@ -48,10 +49,12 @@ import {
} from '@app/features/voice/utils/ScreenSharePortalUnavailableError'; } from '@app/features/voice/utils/ScreenSharePortalUnavailableError';
import {executeScreenShareOperation} from '@app/features/voice/utils/ScreenShareUtils'; import {executeScreenShareOperation} from '@app/features/voice/utils/ScreenShareUtils';
import { import {
canSelectManualAudioSources, type AppShareAudioRoute,
resolveWindowShareAudioScope,
routesManualAudioSources, routesManualAudioSources,
type StreamSettingsShareContext, type StreamSettingsShareContext,
supportsManualScreenShareAudioSourceSelection, selectAppShareAudioRoute,
type WindowShareAudioScope,
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy'; } from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
import {hasHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement'; import {hasHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
import type {NativeAudioStartOptions, VirtmicNode} from '@app/types/electron.d'; import type {NativeAudioStartOptions, VirtmicNode} from '@app/types/electron.d';
@@ -151,56 +154,88 @@ async function getManualAudioSourceSelectionInput(shareContext: StreamSettingsSh
platform, platform,
shareContext, shareContext,
nativeAudioAvailability: platform === 'linux' ? await getNativeAudioAvailabilityCached() : null, nativeAudioAvailability: platform === 'linux' ? await getNativeAudioAvailabilityCached() : null,
manualOptIn: VoiceSettings.getScreenShareManualAudioSourcesOptIn(),
audioSourceMode: VoiceSettings.getScreenShareAudioSourceMode(), audioSourceMode: VoiceSettings.getScreenShareAudioSourceMode(),
selectedSourceCount: VoiceSettings.getScreenShareAudioIncludeSources().length, selectedSourceCount: countRoutableAudioSources(),
}; };
} }
export async function canSelectManualAudioSourcesForShare(shareContext: StreamSettingsShareContext): Promise<boolean> {
return canSelectManualAudioSources(await getManualAudioSourceSelectionInput(shareContext));
}
export async function shouldRouteManualAudioSourcesForShare( export async function shouldRouteManualAudioSourcesForShare(
shareContext: StreamSettingsShareContext, shareContext: StreamSettingsShareContext,
): Promise<boolean> { ): Promise<boolean> {
return routesManualAudioSources(await getManualAudioSourceSelectionInput(shareContext)); return routesManualAudioSources(await getManualAudioSourceSelectionInput(shareContext));
} }
function hasStoredManualAudioSourceSelection(): boolean { function appShareAudioRouteToSourceMode(route: AppShareAudioRoute | null): 'none' | 'system' | 'specific' | null {
return ( if (route === 'none') return 'none';
VoiceSettings.getScreenShareAudioSourceMode() !== 'system' || if (route === 'apps') return 'specific';
VoiceSettings.getScreenShareAudioIncludeSources().length > 0 || if (route === 'system') return 'system';
VoiceSettings.getScreenShareAudioExcludeSources().length > 0 return null;
}
function countRoutableAudioSources(): number {
return filterRoutableLinuxAudioSources(VoiceSettings.getScreenShareAudioIncludeSources()).length;
}
async function resolveAppShareAudioScope(requestedScope: WindowShareAudioScope): Promise<WindowShareAudioScope> {
return resolveWindowShareAudioScope({
shareContext: 'app',
displayShareEnvironment: await getDisplayShareEnvironment(),
windowAudioScope: requestedScope,
});
}
function selectAppShareAudioRouteForScope(scope: WindowShareAudioScope): AppShareAudioRoute {
return selectAppShareAudioRoute({
audioSourceMode: VoiceSettings.getEffectiveScreenShareAudioSourceMode(),
selectedSourceCount: countRoutableAudioSources(),
windowAudioScope: scope,
});
}
export async function reconfigureActiveLinuxAppShareAudio(
requestedWindowAudioScope?: WindowShareAudioScope,
): Promise<boolean> {
const electronApi = getElectronAPI();
if (!electronApi || electronApi.platform !== 'linux') return false;
if (ActiveScreenShareSource.isOwnWindow()) {
logger.warn('Refusing to route audio into a Fluxer-owned window share');
await stopActiveLinuxScreenShareAudioLink();
return false;
}
const scope = await resolveAppShareAudioScope(
requestedWindowAudioScope ?? ActiveScreenShareSource.getWindowAudioScope(),
); );
if (selectAppShareAudioRouteForScope(scope) !== 'window') {
return reconfigureActiveLinuxScreenShareAudioLink();
}
const sourceId = ActiveScreenShareSource.getSourceId();
if (sourceId == null) {
logger.warn('Cannot route the shared window own audio without a published window source');
disarmNativeAudio();
return false;
}
if (await MediaEngine.ensureWindowScreenShareAudioPublication(sourceId).catch(() => false)) {
return true;
}
logger.warn('Failed to narrow the live share back to the shared window own audio; dropping its audio instead', {
sourceId,
});
disarmNativeAudio();
return false;
} }
function captureAudioEnabledForShare(shareContext: StreamSettingsShareContext): boolean { export async function applyLiveScreenShareAudioSourceChange(
if (shareContext === 'app') return VoiceSettings.getShareAppAudio(); shareContext: StreamSettingsShareContext,
if (shareContext === 'device') return VoiceSettings.getShareDeviceAudio(); requestedWindowAudioScope?: WindowShareAudioScope,
return VoiceSettings.getShareDesktopAudio(); ): Promise<boolean> {
} if (shareContext === 'device') return reconfigureActiveDeviceShareAudio();
if (shareContext !== 'app') return reconfigureActiveLinuxScreenShareAudioLink();
export async function reapplyActiveScreenShareAudioSources(): Promise<boolean> { const applied = await reconfigureActiveLinuxAppShareAudio(requestedWindowAudioScope).catch((error) => {
const shareContext = ActiveScreenShareSource.getShareContext(); logger.warn('Failed to apply the window share audio scope', {error, requestedWindowAudioScope});
if (shareContext === null) return false;
if (shareContext === 'app') return false;
if (!hasStoredManualAudioSourceSelection()) return false;
if (!captureAudioEnabledForShare(shareContext)) return false;
const selectionInput = await getManualAudioSourceSelectionInput(shareContext);
if (!supportsManualScreenShareAudioSourceSelection(selectionInput)) return false;
const applied = await (shareContext === 'device'
? reconfigureActiveDeviceShareAudio()
: reconfigureActiveLinuxScreenShareAudioLink()
).catch((error) => {
logger.warn('Failed to reapply the active screen share audio sources', {shareContext, error});
return false; return false;
}); });
if (!applied) { if (applied && requestedWindowAudioScope != null) {
logger.warn('Active screen share audio sources could not be reapplied', { ActiveScreenShareSource.setWindowAudioScope(requestedWindowAudioScope);
shareContext,
sourceMode: VoiceSettings.getEffectiveScreenShareAudioSourceMode(),
});
} }
return applied; return applied;
} }
@@ -426,11 +461,16 @@ async function runConfiguredDisplayScreenShare(
(electronApi.platform === 'darwin' || electronApi.platform === 'win32') && (electronApi.platform === 'darwin' || electronApi.platform === 'win32') &&
sourceId?.startsWith('screen:'); sourceId?.startsWith('screen:');
const requestedNativePickerAudioOnLinux = requestedAudio && electronApi.platform === 'linux' && useWaylandPortal; const requestedNativePickerAudioOnLinux = requestedAudio && electronApi.platform === 'linux' && useWaylandPortal;
const linuxDesktopAudioSourceMode = const appShareAudioScope = requestedAppAudioOnLinux
electronApi.platform === 'linux' && (requestedDesktopAudio || requestedNativePickerAudioOnLinux) ? await resolveAppShareAudioScope(ActiveScreenShareSource.getPendingWindowAudioScope())
: null;
const appShareAudioRoute = appShareAudioScope == null ? null : selectAppShareAudioRouteForScope(appShareAudioScope);
const linuxAudioSourceMode = requestedAppAudioOnLinux
? appShareAudioRouteToSourceMode(appShareAudioRoute)
: electronApi.platform === 'linux' && (requestedDesktopAudio || requestedNativePickerAudioOnLinux)
? VoiceSettings.getEffectiveScreenShareAudioSourceMode() ? VoiceSettings.getEffectiveScreenShareAudioSourceMode()
: null; : null;
if (requestedAppAudioOnLinux) { if (requestedAppAudioOnLinux && appShareAudioRoute === 'window') {
try { try {
nativeAudioArmed = await armNativeAudioForNextCapture(sourceId ?? ''); nativeAudioArmed = await armNativeAudioForNextCapture(sourceId ?? '');
} catch (error) { } catch (error) {
@@ -467,10 +507,10 @@ async function runConfiguredDisplayScreenShare(
logger.warn('Desktop audio unavailable; aborting screen share because audio was requested', debugInfo); logger.warn('Desktop audio unavailable; aborting screen share because audio was requested', debugInfo);
failRequestedAudioCapture(debugInfo); failRequestedAudioCapture(debugInfo);
} }
} else if (linuxDesktopAudioSourceMode === 'none') { } else if (linuxAudioSourceMode === 'none') {
removeAudioFromCaptureOptions(captureOptions); removeAudioFromCaptureOptions(captureOptions);
} else if ((requestedDesktopAudio || requestedNativePickerAudioOnLinux) && electronApi.platform === 'linux') { } else if (linuxAudioSourceMode !== null && electronApi.platform === 'linux') {
const sourceMode = linuxDesktopAudioSourceMode ?? 'system'; const sourceMode = linuxAudioSourceMode;
const userIncludeSources = VoiceSettings.getEffectiveScreenShareAudioIncludeSources().map((entry) => ({ const userIncludeSources = VoiceSettings.getEffectiveScreenShareAudioIncludeSources().map((entry) => ({
...entry, ...entry,
})); }));
@@ -496,6 +536,7 @@ async function runConfiguredDisplayScreenShare(
if (!nativeAudioArmed) { if (!nativeAudioArmed) {
failRequestedAudioCapture( failRequestedAudioCapture(
buildAudioCaptureFailureDebug({ buildAudioCaptureFailureDebug({
sourceId,
sourceMode, sourceMode,
reason: getLastNativeAudioArmFailure()?.reason ?? 'linux-system-audio-route-unavailable', reason: getLastNativeAudioArmFailure()?.reason ?? 'linux-system-audio-route-unavailable',
}), }),
@@ -579,6 +620,7 @@ async function runConfiguredDisplayScreenShare(
ActiveScreenShareSource.setPublishedSource(sourceId.startsWith('window:') ? 'app' : 'display', sourceId, { ActiveScreenShareSource.setPublishedSource(sourceId.startsWith('window:') ? 'app' : 'display', sourceId, {
isOwnWindow: isOwnWindowShare, isOwnWindow: isOwnWindowShare,
}); });
ActiveScreenShareSource.setWindowAudioScope(appShareAudioScope ?? 'window');
} }
if (captured && useWaylandPortal) { if (captured && useWaylandPortal) {
ActiveScreenShareSource.setPublishedSource('wayland', null); ActiveScreenShareSource.setPublishedSource('wayland', null);
@@ -594,8 +636,8 @@ async function runConfiguredDisplayScreenShare(
captured && captured &&
requestedAudio && requestedAudio &&
electronApi.platform === 'linux' && electronApi.platform === 'linux' &&
linuxDesktopAudioSourceMode !== null && linuxAudioSourceMode !== null &&
linuxDesktopAudioSourceMode !== 'none' linuxAudioSourceMode !== 'none'
) { ) {
const audioRelinked = await reconfigureActiveLinuxScreenShareAudioLink().catch((error) => { const audioRelinked = await reconfigureActiveLinuxScreenShareAudioLink().catch((error) => {
logger.warn('Failed to link Linux screen-share audio after capture start', {mode, error}); logger.warn('Failed to link Linux screen-share audio after capture start', {mode, error});
@@ -603,7 +645,7 @@ async function runConfiguredDisplayScreenShare(
}); });
if (!audioRelinked) { if (!audioRelinked) {
const debugInfo = buildAudioCaptureFailureDebug({ const debugInfo = buildAudioCaptureFailureDebug({
sourceMode: linuxDesktopAudioSourceMode, sourceMode: linuxAudioSourceMode,
platform: electronApi.platform, platform: electronApi.platform,
reason: getLastNativeAudioArmFailure()?.reason ?? 'linux-system-audio-route-unavailable', reason: getLastNativeAudioArmFailure()?.reason ?? 'linux-system-audio-route-unavailable',
}); });
@@ -5,10 +5,12 @@ import {describe, expect, it} from 'vitest';
import { import {
canSelectManualAudioSources, canSelectManualAudioSources,
isLinuxDesktopAudioShare, isLinuxDesktopAudioShare,
maySupportManualScreenShareAudioSourceSelection, manualAudioSourcesGovernShare,
resolveWindowShareAudioScope,
routesManualAudioSources, routesManualAudioSources,
selectAppShareAudioRoute,
shouldReconfigureAudioForActiveStreamSettings, shouldReconfigureAudioForActiveStreamSettings,
supportsManualScreenShareAudioSourceSelection, supportsWindowShareAudioScope,
} from './StreamSettingsUpdatePolicy'; } from './StreamSettingsUpdatePolicy';
const LINUX_PIPEWIRE: NativeAudioAvailability = { const LINUX_PIPEWIRE: NativeAudioAvailability = {
@@ -88,90 +90,119 @@ describe('StreamSettingsUpdatePolicy', () => {
}); });
it('offers manual audio source selection only where the capture layer can express it', () => { it('offers manual audio source selection only where the capture layer can express it', () => {
expect(canSelectManualAudioSources({platform: 'linux', nativeAudioAvailability: LINUX_PIPEWIRE})).toBe(true);
expect(canSelectManualAudioSources({platform: 'win32', nativeAudioAvailability: WINDOWS_WASAPI})).toBe(false);
expect(canSelectManualAudioSources({platform: 'darwin', nativeAudioAvailability: MACOS_SCK})).toBe(false);
expect(canSelectManualAudioSources({platform: 'linux', nativeAudioAvailability: null})).toBe(false);
expect( expect(
supportsManualScreenShareAudioSourceSelection({platform: 'linux', nativeAudioAvailability: LINUX_PIPEWIRE}), canSelectManualAudioSources({
).toBe(true);
expect(
supportsManualScreenShareAudioSourceSelection({platform: 'win32', nativeAudioAvailability: WINDOWS_WASAPI}),
).toBe(false);
expect(
supportsManualScreenShareAudioSourceSelection({platform: 'darwin', nativeAudioAvailability: MACOS_SCK}),
).toBe(false);
expect(supportsManualScreenShareAudioSourceSelection({platform: 'linux', nativeAudioAvailability: null})).toBe(
false,
);
expect(
supportsManualScreenShareAudioSourceSelection({
platform: 'linux', platform: 'linux',
nativeAudioAvailability: {available: false, backend: 'linux-pipewire', reason: 'no-pipewire'}, nativeAudioAvailability: {available: false, backend: 'linux-pipewire', reason: 'no-pipewire'},
}), }),
).toBe(false); ).toBe(false);
expect( expect(
supportsManualScreenShareAudioSourceSelection({ canSelectManualAudioSources({
platform: 'linux', platform: 'linux',
nativeAudioAvailability: {available: true, capabilities: {process: false, system: true}}, nativeAudioAvailability: {available: true, capabilities: {process: false, system: true}},
}), }),
).toBe(false); ).toBe(false);
}); });
it('shows the advanced toggle on Linux while the capture layer is still being probed', () => { it('offers manual audio source selection on every desktop share type, with no opt-in left to satisfy', () => {
expect(maySupportManualScreenShareAudioSourceSelection({platform: 'linux', nativeAudioAvailability: null})).toBe(
true,
);
expect( expect(
maySupportManualScreenShareAudioSourceSelection({platform: 'linux', nativeAudioAvailability: LINUX_PIPEWIRE}), canSelectManualAudioSources({
).toBe(true);
expect(
maySupportManualScreenShareAudioSourceSelection({
platform: 'linux', platform: 'linux',
nativeAudioAvailability: {available: false, backend: 'linux-pipewire', reason: 'no-pipewire'}, nativeAudioAvailability: LINUX_PIPEWIRE,
}), }),
).toBe(false); ).toBe(true);
expect(maySupportManualScreenShareAudioSourceSelection({platform: 'win32', nativeAudioAvailability: null})).toBe( for (const displayShareEnvironment of ['desktop-custom', 'desktop-wayland'] as const) {
false, expect(manualAudioSourcesGovernShare({platform: 'linux', displayShareEnvironment})).toBe(true);
); }
expect(
maySupportManualScreenShareAudioSourceSelection({platform: 'darwin', nativeAudioAvailability: MACOS_SCK}),
).toBe(false);
expect(maySupportManualScreenShareAudioSourceSelection({platform: null, nativeAudioAvailability: null})).toBe(
false,
);
}); });
it('keeps manual audio source selection behind the advanced opt-in in every share context', () => { it('leaves the audio source decision to the platform wherever the stored mode cannot reach the capture', () => {
for (const shareContext of ['app', 'device', 'display'] as const) { expect(manualAudioSourcesGovernShare({platform: 'linux', displayShareEnvironment: 'web'})).toBe(false);
expect( for (const platform of ['win32', 'darwin', null, undefined]) {
canSelectManualAudioSources({ expect(manualAudioSourcesGovernShare({platform, displayShareEnvironment: 'desktop-custom'})).toBe(false);
platform: 'linux',
shareContext,
nativeAudioAvailability: LINUX_PIPEWIRE,
manualOptIn: true,
}),
).toBe(true);
expect(
canSelectManualAudioSources({
platform: 'linux',
shareContext,
nativeAudioAvailability: LINUX_PIPEWIRE,
}),
).toBe(false);
expect(
canSelectManualAudioSources({
platform: 'win32',
shareContext,
nativeAudioAvailability: WINDOWS_WASAPI,
manualOptIn: true,
}),
).toBe(false);
} }
}); });
it('offers the window audio scope only to a window share whose window the capture layer can name', () => {
expect(supportsWindowShareAudioScope({shareContext: 'app', displayShareEnvironment: 'desktop-custom'})).toBe(true);
expect(supportsWindowShareAudioScope({shareContext: 'app', displayShareEnvironment: 'desktop-wayland'})).toBe(
false,
);
expect(supportsWindowShareAudioScope({shareContext: 'app', displayShareEnvironment: 'web'})).toBe(false);
expect(supportsWindowShareAudioScope({shareContext: 'app'})).toBe(false);
for (const shareContext of ['device', 'display'] as const) {
expect(supportsWindowShareAudioScope({shareContext, displayShareEnvironment: 'desktop-custom'})).toBe(false);
}
});
it('defaults a window share to its own window and widens only where the user asked for it', () => {
expect(resolveWindowShareAudioScope({shareContext: 'app', displayShareEnvironment: 'desktop-custom'})).toBe(
'window',
);
expect(
resolveWindowShareAudioScope({
shareContext: 'app',
displayShareEnvironment: 'desktop-custom',
windowAudioScope: 'window',
}),
).toBe('window');
expect(
resolveWindowShareAudioScope({
shareContext: 'app',
displayShareEnvironment: 'desktop-custom',
windowAudioScope: 'system',
}),
).toBe('system');
expect(
resolveWindowShareAudioScope({
shareContext: 'app',
displayShareEnvironment: 'desktop-wayland',
windowAudioScope: 'window',
}),
).toBe('system');
for (const shareContext of ['device', 'display'] as const) {
expect(
resolveWindowShareAudioScope({
shareContext,
displayShareEnvironment: 'desktop-custom',
windowAudioScope: 'window',
}),
).toBe('system');
}
});
it('keeps a window share on its own window until the user widens the scope', () => {
for (const audioSourceMode of ['none', 'system', 'specific'] as const) {
for (const selectedSourceCount of [0, 2]) {
expect(selectAppShareAudioRoute({audioSourceMode, selectedSourceCount, windowAudioScope: 'window'})).toBe(
'window',
);
}
}
expect(selectAppShareAudioRoute({windowAudioScope: 'window'})).toBe('window');
});
it('lets the stored selection govern a window share only once it is widened to the system', () => {
expect(selectAppShareAudioRoute({audioSourceMode: 'system', windowAudioScope: 'system'})).toBe('system');
expect(selectAppShareAudioRoute({windowAudioScope: 'system'})).toBe('system');
expect(selectAppShareAudioRoute({audioSourceMode: 'none', windowAudioScope: 'system'})).toBe('none');
expect(
selectAppShareAudioRoute({audioSourceMode: 'specific', selectedSourceCount: 2, windowAudioScope: 'system'}),
).toBe('apps');
expect(
selectAppShareAudioRoute({audioSourceMode: 'specific', selectedSourceCount: 0, windowAudioScope: 'system'}),
).toBe('system');
});
it('routes manual audio sources only once specific sources are selected', () => { it('routes manual audio sources only once specific sources are selected', () => {
const base = { const base = {
platform: 'linux', platform: 'linux',
shareContext: 'device', shareContext: 'device',
nativeAudioAvailability: LINUX_PIPEWIRE, nativeAudioAvailability: LINUX_PIPEWIRE,
manualOptIn: true,
} as const; } as const;
expect(routesManualAudioSources({...base, audioSourceMode: 'specific', selectedSourceCount: 1})).toBe(true); expect(routesManualAudioSources({...base, audioSourceMode: 'specific', selectedSourceCount: 1})).toBe(true);
expect(routesManualAudioSources({...base, audioSourceMode: 'specific', selectedSourceCount: 0})).toBe(false); expect(routesManualAudioSources({...base, audioSourceMode: 'specific', selectedSourceCount: 0})).toBe(false);
@@ -180,7 +211,8 @@ describe('StreamSettingsUpdatePolicy', () => {
expect( expect(
routesManualAudioSources({ routesManualAudioSources({
...base, ...base,
manualOptIn: false, platform: 'win32',
nativeAudioAvailability: WINDOWS_WASAPI,
audioSourceMode: 'specific', audioSourceMode: 'specific',
selectedSourceCount: 2, selectedSourceCount: 2,
}), }),
@@ -5,6 +5,8 @@ import type {NativeAudioAvailability} from '@app/types/electron.d';
export type StreamSettingsShareContext = 'app' | 'device' | 'display'; export type StreamSettingsShareContext = 'app' | 'device' | 'display';
export type ScreenShareAudioSourceMode = 'none' | 'system' | 'specific'; export type ScreenShareAudioSourceMode = 'none' | 'system' | 'specific';
export type WindowShareAudioScope = 'window' | 'system';
export type AppShareAudioRoute = 'none' | 'window' | 'apps' | 'system';
export interface StreamSettingsUpdatePolicyInput { export interface StreamSettingsUpdatePolicyInput {
platform?: string | null; platform?: string | null;
@@ -16,16 +18,44 @@ export interface ManualAudioSourceSelectionInput {
platform?: string | null; platform?: string | null;
shareContext: StreamSettingsShareContext; shareContext: StreamSettingsShareContext;
nativeAudioAvailability?: NativeAudioAvailability | null; nativeAudioAvailability?: NativeAudioAvailability | null;
manualOptIn?: boolean;
audioSourceMode?: ScreenShareAudioSourceMode; audioSourceMode?: ScreenShareAudioSourceMode;
selectedSourceCount?: number; selectedSourceCount?: number;
} }
export function manualAudioSourcesGovernShare(input: { export interface WindowShareAudioScopeInput {
shareContext: StreamSettingsShareContext; shareContext: StreamSettingsShareContext;
displayShareEnvironment?: DisplayShareEnvironment; displayShareEnvironment?: DisplayShareEnvironment;
windowAudioScope?: WindowShareAudioScope;
}
export interface AppShareAudioRouteInput {
audioSourceMode?: ScreenShareAudioSourceMode;
selectedSourceCount?: number;
windowAudioScope: WindowShareAudioScope;
}
export function manualAudioSourcesGovernShare(input: {
platform?: string | null;
displayShareEnvironment?: DisplayShareEnvironment;
}): boolean { }): boolean {
return !(input.shareContext === 'app' && input.displayShareEnvironment === 'desktop-custom'); return input.platform === 'linux' && input.displayShareEnvironment !== 'web';
}
export function supportsWindowShareAudioScope(input: WindowShareAudioScopeInput): boolean {
if (input.shareContext !== 'app') return false;
return input.displayShareEnvironment === 'desktop-custom';
}
export function resolveWindowShareAudioScope(input: WindowShareAudioScopeInput): WindowShareAudioScope {
if (!supportsWindowShareAudioScope(input)) return 'system';
return input.windowAudioScope ?? 'window';
}
export function selectAppShareAudioRoute(input: AppShareAudioRouteInput): AppShareAudioRoute {
if (input.windowAudioScope !== 'system') return 'window';
if (input.audioSourceMode === 'none') return 'none';
if (input.audioSourceMode === 'specific' && (input.selectedSourceCount ?? 0) > 0) return 'apps';
return 'system';
} }
export function isLinuxDesktopAudioShare( export function isLinuxDesktopAudioShare(
@@ -34,7 +64,7 @@ export function isLinuxDesktopAudioShare(
return input.platform === 'linux' && input.shareContext !== 'device'; return input.platform === 'linux' && input.shareContext !== 'device';
} }
export function supportsManualScreenShareAudioSourceSelection( export function canSelectManualAudioSources(
input: Pick<ManualAudioSourceSelectionInput, 'platform' | 'nativeAudioAvailability'>, input: Pick<ManualAudioSourceSelectionInput, 'platform' | 'nativeAudioAvailability'>,
): boolean { ): boolean {
if (input.platform !== 'linux') return false; if (input.platform !== 'linux') return false;
@@ -43,18 +73,6 @@ export function supportsManualScreenShareAudioSourceSelection(
return availability.available === true && availability.capabilities?.process !== false; return availability.available === true && availability.capabilities?.process !== false;
} }
export function maySupportManualScreenShareAudioSourceSelection(
input: Pick<ManualAudioSourceSelectionInput, 'platform' | 'nativeAudioAvailability'>,
): boolean {
if (input.platform !== 'linux') return false;
if (input.nativeAudioAvailability == null) return true;
return supportsManualScreenShareAudioSourceSelection(input);
}
export function canSelectManualAudioSources(input: ManualAudioSourceSelectionInput): boolean {
return input.manualOptIn === true && supportsManualScreenShareAudioSourceSelection(input);
}
export function routesManualAudioSources(input: ManualAudioSourceSelectionInput): boolean { export function routesManualAudioSources(input: ManualAudioSourceSelectionInput): boolean {
return ( return (
canSelectManualAudioSources(input) && input.audioSourceMode === 'specific' && (input.selectedSourceCount ?? 0) > 0 canSelectManualAudioSources(input) && input.audioSourceMode === 'specific' && (input.selectedSourceCount ?? 0) > 0