Refactor screen share picker internals

This commit is contained in:
Fedor Indutny 2024-09-19 18:03:44 -07:00 committed by GitHub
parent 855b1c03b0
commit d0b8a2991f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 547 additions and 244 deletions

View file

@ -1913,6 +1913,10 @@
"messageformat": "Signal is sharing {window}.", "messageformat": "Signal is sharing {window}.",
"description": "Text that appears in the screen sharing controller to inform person that they are presenting" "description": "Text that appears in the screen sharing controller to inform person that they are presenting"
}, },
"icu:calling__presenting--info--unknown": {
"messageformat": "Signal screen sharing is active",
"description": "Text that appears in the screen sharing controller to inform person that they are presenting"
},
"icu:calling__presenting--reconnecting": { "icu:calling__presenting--reconnecting": {
"messageformat": "Reconnecting...", "messageformat": "Reconnecting...",
"description": "Text that appears in the screen sharing controller to inform person that the call is in reconnecting state" "description": "Text that appears in the screen sharing controller to inform person that the call is in reconnecting state"

View file

@ -16,7 +16,6 @@ import {
app, app,
BrowserWindow, BrowserWindow,
clipboard, clipboard,
desktopCapturer,
dialog, dialog,
ipcMain as ipc, ipcMain as ipc,
Menu, Menu,
@ -1229,7 +1228,7 @@ function setupAsStandalone() {
} }
let screenShareWindow: BrowserWindow | undefined; let screenShareWindow: BrowserWindow | undefined;
async function showScreenShareWindow(sourceName: string) { async function showScreenShareWindow(sourceName: string | undefined) {
if (screenShareWindow) { if (screenShareWindow) {
screenShareWindow.showInactive(); screenShareWindow.showInactive();
return; return;
@ -1981,7 +1980,7 @@ app.on('ready', async () => {
realpath(app.getAppPath()), realpath(app.getAppPath()),
]); ]);
updateDefaultSession(session.defaultSession); updateDefaultSession(session.defaultSession, getLogger);
if (getEnvironment() !== Environment.Test) { if (getEnvironment() !== Environment.Test) {
installFileHandler({ installFileHandler({
@ -2621,9 +2620,12 @@ ipc.on('stop-screen-share', () => {
} }
}); });
ipc.on('show-screen-share', (_event: Electron.Event, sourceName: string) => { ipc.on(
'show-screen-share',
(_event: Electron.Event, sourceName: string | undefined) => {
drop(showScreenShareWindow(sourceName)); drop(showScreenShareWindow(sourceName));
}); }
);
ipc.on('update-tray-icon', (_event: Electron.Event, unreadCount: number) => { ipc.on('update-tray-icon', (_event: Electron.Event, unreadCount: number) => {
if (systemTrayService) { if (systemTrayService) {
@ -2895,8 +2897,8 @@ function handleSignalRoute(route: ParsedSignalRoute) {
}); });
} else if (route.key === 'showWindow') { } else if (route.key === 'showWindow') {
mainWindow.webContents.send('show-window'); mainWindow.webContents.send('show-window');
} else if (route.key === 'setIsPresenting') { } else if (route.key === 'cancelPresenting') {
mainWindow.webContents.send('set-is-presenting'); mainWindow.webContents.send('cancel-presenting');
} else if (route.key === 'captcha') { } else if (route.key === 'captcha') {
challengeHandler.handleCaptcha(route.args.captchaId); challengeHandler.handleCaptcha(route.args.captchaId);
// Show window after handling captcha // Show window after handling captcha
@ -3023,17 +3025,6 @@ ipc.handle('show-save-dialog', async (_event, { defaultPath }) => {
return { canceled: false, filePath: finalFilePath }; return { canceled: false, filePath: finalFilePath };
}); });
ipc.handle(
'getScreenCaptureSources',
async (_event, types: Array<'screen' | 'window'> = ['screen', 'window']) => {
return desktopCapturer.getSources({
fetchWindowIcons: true,
thumbnailSize: { height: 102, width: 184 },
types,
});
}
);
ipc.handle('executeMenuRole', async ({ sender }, untypedRole) => { ipc.handle('executeMenuRole', async ({ sender }, untypedRole) => {
const role = untypedRole as MenuItemConstructorOptions['role']; const role = untypedRole as MenuItemConstructorOptions['role'];

View file

@ -9,7 +9,7 @@ import type { WindowsNotificationData } from '../ts/services/notifications';
import { NotificationType } from '../ts/services/notifications'; import { NotificationType } from '../ts/services/notifications';
import { missingCaseError } from '../ts/util/missingCaseError'; import { missingCaseError } from '../ts/util/missingCaseError';
import { import {
setIsPresentingRoute, cancelPresentingRoute,
showConversationRoute, showConversationRoute,
showWindowRoute, showWindowRoute,
startCallLobbyRoute, startCallLobbyRoute,
@ -69,7 +69,7 @@ export function renderWindowsToast({
} else if (type === NotificationType.IncomingCall) { } else if (type === NotificationType.IncomingCall) {
launch = showWindowRoute.toAppUrl({}); launch = showWindowRoute.toAppUrl({});
} else if (type === NotificationType.IsPresenting) { } else if (type === NotificationType.IsPresenting) {
launch = setIsPresentingRoute.toAppUrl({}); launch = cancelPresentingRoute.toAppUrl({});
} else { } else {
throw missingCaseError(type); throw missingCaseError(type);
} }

View file

@ -1,12 +1,72 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { Session } from 'electron'; import type { Session, DesktopCapturerSource, IpcMainEvent } from 'electron';
import { desktopCapturer, ipcMain } from 'electron';
import { v4 as generateUuid } from 'uuid';
import OS from '../ts/util/os/osMain';
import type { LoggerType } from '../ts/types/Logging';
import { strictAssert } from '../ts/util/assert';
import { type IpcResponseType } from '../ts/util/desktopCapturer';
const SPELL_CHECKER_DICTIONARY_DOWNLOAD_URL = `https://updates.signal.org/desktop/hunspell_dictionaries/${process.versions.electron}/`; const SPELL_CHECKER_DICTIONARY_DOWNLOAD_URL = `https://updates.signal.org/desktop/hunspell_dictionaries/${process.versions.electron}/`;
export function updateDefaultSession(session: Session): void { export function updateDefaultSession(
session: Session,
getLogger: () => LoggerType
): void {
session.setSpellCheckerDictionaryDownloadURL( session.setSpellCheckerDictionaryDownloadURL(
SPELL_CHECKER_DICTIONARY_DOWNLOAD_URL SPELL_CHECKER_DICTIONARY_DOWNLOAD_URL
); );
session.setDisplayMediaRequestHandler(
async (request, callback) => {
const { frame, videoRequested, audioRequested } = request;
try {
strictAssert(videoRequested, 'Not requesting video');
strictAssert(!audioRequested, 'Requesting audio');
const sources = await desktopCapturer.getSources({
fetchWindowIcons: true,
thumbnailSize: { height: 102, width: 184 },
types: ['screen', 'window'],
});
// Wayland already shows a window/screen selection modal so we just
// have to go with the source that we were given.
if (OS.isLinux() && OS.isWaylandEnabled() && sources.length === 1) {
callback({ video: sources[0] });
return;
}
const id = generateUuid();
ipcMain.once(
`select-capture-sources:${id}:response`,
(_event: IpcMainEvent, stream: DesktopCapturerSource | undefined) => {
try {
callback({ video: stream });
} catch {
// Don't let Electron errors crash the app
}
}
);
frame.send('select-capture-sources', {
id,
sources,
} satisfies IpcResponseType);
} catch (error) {
try {
callback({});
} catch {
// Electron throws error here, but this is the only way to cancel the
// request.
}
getLogger().error('Failed to get desktopCapturer sources', error);
}
},
{ useSystemPicker: false }
);
} }

View file

@ -1,13 +0,0 @@
diff --git a/node_modules/mac-screen-capture-permissions/screen-capture-permissions.m b/node_modules/mac-screen-capture-permissions/screen-capture-permissions.m
index d9d6a00..78fa83f 100644
--- a/node_modules/mac-screen-capture-permissions/screen-capture-permissions.m
+++ b/node_modules/mac-screen-capture-permissions/screen-capture-permissions.m
@@ -2,6 +2,8 @@
#import <Foundation/Foundation.h>
#include <node_api.h>
+CG_EXTERN bool CGPreflightScreenCaptureAccess(void) CG_AVAILABLE_STARTING(10.15);
+
static napi_value hasPermissions(napi_env env, napi_callback_info info) {
napi_status status;
bool hasPermissions;

View file

@ -120,17 +120,18 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
playRingtone: action('play-ringtone'), playRingtone: action('play-ringtone'),
removeClient: action('remove-client'), removeClient: action('remove-client'),
blockClient: action('block-client'), blockClient: action('block-client'),
cancelPresenting: action('cancel-presenting'),
renderDeviceSelection: () => <div />, renderDeviceSelection: () => <div />,
renderEmojiPicker: () => <>EmojiPicker</>, renderEmojiPicker: () => <>EmojiPicker</>,
renderReactionPicker: () => <div />, renderReactionPicker: () => <div />,
sendGroupCallRaiseHand: action('send-group-call-raise-hand'), sendGroupCallRaiseHand: action('send-group-call-raise-hand'),
sendGroupCallReaction: action('send-group-call-reaction'), sendGroupCallReaction: action('send-group-call-reaction'),
selectPresentingSource: action('select-presenting-source'),
setGroupCallVideoRequest: action('set-group-call-video-request'), setGroupCallVideoRequest: action('set-group-call-video-request'),
setIsCallActive: action('set-is-call-active'), setIsCallActive: action('set-is-call-active'),
setLocalAudio: action('set-local-audio'), setLocalAudio: action('set-local-audio'),
setLocalPreview: action('set-local-preview'), setLocalPreview: action('set-local-preview'),
setLocalVideo: action('set-local-video'), setLocalVideo: action('set-local-video'),
setPresenting: action('toggle-presenting'),
setRendererCanvas: action('set-renderer-canvas'), setRendererCanvas: action('set-renderer-canvas'),
setOutgoingRing: action('set-outgoing-ring'), setOutgoingRing: action('set-outgoing-ring'),
showContactModal: action('show-contact-modal'), showContactModal: action('show-contact-modal'),

View file

@ -16,7 +16,6 @@ import type {
CallingConversationType, CallingConversationType,
CallViewMode, CallViewMode,
GroupCallVideoRequest, GroupCallVideoRequest,
PresentedSource,
} from '../types/Calling'; } from '../types/Calling';
import { import {
CallEndedReason, CallEndedReason,
@ -105,6 +104,7 @@ export type PropsType = {
batchUserAction: (payload: BatchUserActionPayloadType) => void; batchUserAction: (payload: BatchUserActionPayloadType) => void;
bounceAppIconStart: () => unknown; bounceAppIconStart: () => unknown;
bounceAppIconStop: () => unknown; bounceAppIconStop: () => unknown;
cancelPresenting: () => void;
declineCall: (_: DeclineCallType) => void; declineCall: (_: DeclineCallType) => void;
denyUser: (payload: PendingUserActionPayloadType) => void; denyUser: (payload: PendingUserActionPayloadType) => void;
hasInitialLoadCompleted: boolean; hasInitialLoadCompleted: boolean;
@ -120,6 +120,7 @@ export type PropsType = {
playRingtone: () => unknown; playRingtone: () => unknown;
removeClient: (payload: RemoveClientType) => void; removeClient: (payload: RemoveClientType) => void;
blockClient: (payload: RemoveClientType) => void; blockClient: (payload: RemoveClientType) => void;
selectPresentingSource: (id: string) => void;
sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void; sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void;
sendGroupCallReaction: (payload: SendGroupCallReactionType) => void; sendGroupCallReaction: (payload: SendGroupCallReactionType) => void;
setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void; setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void;
@ -128,7 +129,6 @@ export type PropsType = {
setLocalVideo: (_: SetLocalVideoType) => void; setLocalVideo: (_: SetLocalVideoType) => void;
setLocalPreview: (_: SetLocalPreviewType) => void; setLocalPreview: (_: SetLocalPreviewType) => void;
setOutgoingRing: (_: boolean) => void; setOutgoingRing: (_: boolean) => void;
setPresenting: (_?: PresentedSource) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void;
showShareCallLinkViaSignal: ( showShareCallLinkViaSignal: (
callLink: CallLinkType, callLink: CallLinkType,
@ -171,6 +171,7 @@ function ActiveCallManager({
blockClient, blockClient,
callLink, callLink,
cancelCall, cancelCall,
cancelPresenting,
changeCallView, changeCallView,
closeNeedPermissionScreen, closeNeedPermissionScreen,
denyUser, denyUser,
@ -186,13 +187,13 @@ function ActiveCallManager({
renderEmojiPicker, renderEmojiPicker,
renderReactionPicker, renderReactionPicker,
removeClient, removeClient,
selectPresentingSource,
sendGroupCallRaiseHand, sendGroupCallRaiseHand,
sendGroupCallReaction, sendGroupCallReaction,
setGroupCallVideoRequest, setGroupCallVideoRequest,
setLocalAudio, setLocalAudio,
setLocalPreview, setLocalPreview,
setLocalVideo, setLocalVideo,
setPresenting,
setRendererCanvas, setRendererCanvas,
setOutgoingRing, setOutgoingRing,
showContactModal, showContactModal,
@ -452,6 +453,7 @@ function ActiveCallManager({
activeCall={activeCall} activeCall={activeCall}
approveUser={approveUser} approveUser={approveUser}
batchUserAction={batchUserAction} batchUserAction={batchUserAction}
cancelPresenting={cancelPresenting}
changeCallView={changeCallView} changeCallView={changeCallView}
denyUser={denyUser} denyUser={denyUser}
getPresentingSources={getPresentingSources} getPresentingSources={getPresentingSources}
@ -473,7 +475,6 @@ function ActiveCallManager({
setRendererCanvas={setRendererCanvas} setRendererCanvas={setRendererCanvas}
setLocalAudio={setLocalAudio} setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo} setLocalVideo={setLocalVideo}
setPresenting={setPresenting}
stickyControls={showParticipantsList} stickyControls={showParticipantsList}
switchToPresentationView={switchToPresentationView} switchToPresentationView={switchToPresentationView}
switchFromPresentationView={switchFromPresentationView} switchFromPresentationView={switchFromPresentationView}
@ -491,7 +492,8 @@ function ActiveCallManager({
<CallingSelectPresentingSourcesModal <CallingSelectPresentingSourcesModal
i18n={i18n} i18n={i18n}
presentingSourcesAvailable={presentingSourcesAvailable} presentingSourcesAvailable={presentingSourcesAvailable}
setPresenting={setPresenting} selectPresentingSource={selectPresentingSource}
cancelPresenting={cancelPresenting}
/> />
) : null} ) : null}
{settingsDialogOpen && renderDeviceSelection()} {settingsDialogOpen && renderDeviceSelection()}
@ -536,6 +538,7 @@ export function CallManager({
bounceAppIconStop, bounceAppIconStop,
callLink, callLink,
cancelCall, cancelCall,
cancelPresenting,
changeCallView, changeCallView,
closeNeedPermissionScreen, closeNeedPermissionScreen,
declineCall, declineCall,
@ -558,6 +561,7 @@ export function CallManager({
renderDeviceSelection, renderDeviceSelection,
renderEmojiPicker, renderEmojiPicker,
renderReactionPicker, renderReactionPicker,
selectPresentingSource,
sendGroupCallRaiseHand, sendGroupCallRaiseHand,
sendGroupCallReaction, sendGroupCallReaction,
setGroupCallVideoRequest, setGroupCallVideoRequest,
@ -566,7 +570,6 @@ export function CallManager({
setLocalPreview, setLocalPreview,
setLocalVideo, setLocalVideo,
setOutgoingRing, setOutgoingRing,
setPresenting,
setRendererCanvas, setRendererCanvas,
showContactModal, showContactModal,
showShareCallLinkViaSignal, showShareCallLinkViaSignal,
@ -635,6 +638,7 @@ export function CallManager({
blockClient={blockClient} blockClient={blockClient}
callLink={callLink} callLink={callLink}
cancelCall={cancelCall} cancelCall={cancelCall}
cancelPresenting={cancelPresenting}
changeCallView={changeCallView} changeCallView={changeCallView}
closeNeedPermissionScreen={closeNeedPermissionScreen} closeNeedPermissionScreen={closeNeedPermissionScreen}
denyUser={denyUser} denyUser={denyUser}
@ -653,6 +657,7 @@ export function CallManager({
renderDeviceSelection={renderDeviceSelection} renderDeviceSelection={renderDeviceSelection}
renderEmojiPicker={renderEmojiPicker} renderEmojiPicker={renderEmojiPicker}
renderReactionPicker={renderReactionPicker} renderReactionPicker={renderReactionPicker}
selectPresentingSource={selectPresentingSource}
sendGroupCallRaiseHand={sendGroupCallRaiseHand} sendGroupCallRaiseHand={sendGroupCallRaiseHand}
sendGroupCallReaction={sendGroupCallReaction} sendGroupCallReaction={sendGroupCallReaction}
setGroupCallVideoRequest={setGroupCallVideoRequest} setGroupCallVideoRequest={setGroupCallVideoRequest}
@ -660,7 +665,6 @@ export function CallManager({
setLocalPreview={setLocalPreview} setLocalPreview={setLocalPreview}
setLocalVideo={setLocalVideo} setLocalVideo={setLocalVideo}
setOutgoingRing={setOutgoingRing} setOutgoingRing={setOutgoingRing}
setPresenting={setPresenting}
setRendererCanvas={setRendererCanvas} setRendererCanvas={setRendererCanvas}
showContactModal={showContactModal} showContactModal={showContactModal}
showShareCallLinkViaSignal={showShareCallLinkViaSignal} showShareCallLinkViaSignal={showShareCallLinkViaSignal}

View file

@ -206,13 +206,13 @@ const createProps = (
openSystemPreferencesAction: action('open-system-preferences-action'), openSystemPreferencesAction: action('open-system-preferences-action'),
renderEmojiPicker: () => <>EmojiPicker</>, renderEmojiPicker: () => <>EmojiPicker</>,
renderReactionPicker: () => <div />, renderReactionPicker: () => <div />,
cancelPresenting: action('cancel-presenting'),
sendGroupCallRaiseHand: action('send-group-call-raise-hand'), sendGroupCallRaiseHand: action('send-group-call-raise-hand'),
sendGroupCallReaction: action('send-group-call-reaction'), sendGroupCallReaction: action('send-group-call-reaction'),
setGroupCallVideoRequest: action('set-group-call-video-request'), setGroupCallVideoRequest: action('set-group-call-video-request'),
setLocalAudio: action('set-local-audio'), setLocalAudio: action('set-local-audio'),
setLocalPreview: action('set-local-preview'), setLocalPreview: action('set-local-preview'),
setLocalVideo: action('set-local-video'), setLocalVideo: action('set-local-video'),
setPresenting: action('toggle-presenting'),
setRendererCanvas: action('set-renderer-canvas'), setRendererCanvas: action('set-renderer-canvas'),
stickyControls: false, stickyControls: false,
switchToPresentationView: action('switch-to-presentation-view'), switchToPresentationView: action('switch-to-presentation-view'),

View file

@ -29,7 +29,6 @@ import type {
ActiveCallReactionsType, ActiveCallReactionsType,
ConversationsByDemuxIdType, ConversationsByDemuxIdType,
GroupCallVideoRequest, GroupCallVideoRequest,
PresentedSource,
} from '../types/Calling'; } from '../types/Calling';
import { import {
CALLING_REACTIONS_LIFETIME, CALLING_REACTIONS_LIFETIME,
@ -97,6 +96,7 @@ export type PropsType = {
activeCall: ActiveCallType; activeCall: ActiveCallType;
approveUser: (payload: PendingUserActionPayloadType) => void; approveUser: (payload: PendingUserActionPayloadType) => void;
batchUserAction: (payload: BatchUserActionPayloadType) => void; batchUserAction: (payload: BatchUserActionPayloadType) => void;
cancelPresenting: () => void;
denyUser: (payload: PendingUserActionPayloadType) => void; denyUser: (payload: PendingUserActionPayloadType) => void;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
getPresentingSources: () => void; getPresentingSources: () => void;
@ -120,7 +120,6 @@ export type PropsType = {
setLocalAudio: (_: SetLocalAudioType) => void; setLocalAudio: (_: SetLocalAudioType) => void;
setLocalVideo: (_: SetLocalVideoType) => void; setLocalVideo: (_: SetLocalVideoType) => void;
setLocalPreview: (_: SetLocalPreviewType) => void; setLocalPreview: (_: SetLocalPreviewType) => void;
setPresenting: (_?: PresentedSource) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void;
stickyControls: boolean; stickyControls: boolean;
switchToPresentationView: () => void; switchToPresentationView: () => void;
@ -190,6 +189,7 @@ export function CallScreen({
activeCall, activeCall,
approveUser, approveUser,
batchUserAction, batchUserAction,
cancelPresenting,
changeCallView, changeCallView,
denyUser, denyUser,
getGroupCallVideoFrameSource, getGroupCallVideoFrameSource,
@ -210,7 +210,6 @@ export function CallScreen({
setLocalAudio, setLocalAudio,
setLocalVideo, setLocalVideo,
setLocalPreview, setLocalPreview,
setPresenting,
setRendererCanvas, setRendererCanvas,
stickyControls, stickyControls,
switchToPresentationView, switchToPresentationView,
@ -260,11 +259,11 @@ export function CallScreen({
const togglePresenting = useCallback(() => { const togglePresenting = useCallback(() => {
if (presentingSource) { if (presentingSource) {
setPresenting(); cancelPresenting();
} else { } else {
getPresentingSources(); getPresentingSources();
} }
}, [getPresentingSources, presentingSource, setPresenting]); }, [getPresentingSources, presentingSource, cancelPresenting]);
const hangUp = useCallback(() => { const hangUp = useCallback(() => {
hangUpActiveCall('button click'); hangUpActiveCall('button click');

View file

@ -11,7 +11,7 @@ export type PropsType = {
onCloseController: () => unknown; onCloseController: () => unknown;
onStopSharing: () => unknown; onStopSharing: () => unknown;
status: ScreenShareStatus; status: ScreenShareStatus;
presentedSourceName: string; presentedSourceName: string | undefined;
}; };
export function CallingScreenSharingController({ export function CallingScreenSharingController({
@ -25,10 +25,12 @@ export function CallingScreenSharingController({
if (status === ScreenShareStatus.Reconnecting) { if (status === ScreenShareStatus.Reconnecting) {
text = i18n('icu:calling__presenting--reconnecting'); text = i18n('icu:calling__presenting--reconnecting');
} else { } else if (presentedSourceName) {
text = i18n('icu:calling__presenting--info', { text = i18n('icu:calling__presenting--info', {
window: presentedSourceName, window: presentedSourceName,
}); });
} else {
text = i18n('icu:calling__presenting--info--unknown');
} }
return ( return (

View file

@ -51,7 +51,8 @@ const createProps = (): PropsType => ({
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+O/wHwAEhgJAyqFnAgAAAABJRU5ErkJggg==', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+O/wHwAEhgJAyqFnAgAAAABJRU5ErkJggg==',
}, },
], ],
setPresenting: action('set-presenting'), selectPresentingSource: action('select-presenting-source'),
cancelPresenting: action('cancel-presenting'),
}); });
export default { export default {

View file

@ -9,11 +9,13 @@ import type { LocalizerType } from '../types/Util';
import { Modal } from './Modal'; import { Modal } from './Modal';
import type { PresentedSource, PresentableSource } from '../types/Calling'; import type { PresentedSource, PresentableSource } from '../types/Calling';
import { Theme } from '../util/theme'; import { Theme } from '../util/theme';
import { strictAssert } from '../util/assert';
export type PropsType = { export type PropsType = {
i18n: LocalizerType; i18n: LocalizerType;
presentingSourcesAvailable: Array<PresentableSource>; presentingSourcesAvailable: ReadonlyArray<PresentableSource>;
setPresenting: (_?: PresentedSource) => void; selectPresentingSource: (id: string) => void;
cancelPresenting: () => void;
}; };
function Source({ function Source({
@ -67,7 +69,8 @@ function Source({
export function CallingSelectPresentingSourcesModal({ export function CallingSelectPresentingSourcesModal({
i18n, i18n,
presentingSourcesAvailable, presentingSourcesAvailable,
setPresenting, selectPresentingSource,
cancelPresenting,
}: PropsType): JSX.Element | null { }: PropsType): JSX.Element | null {
const [sourceToPresent, setSourceToPresent] = useState< const [sourceToPresent, setSourceToPresent] = useState<
PresentedSource | undefined PresentedSource | undefined
@ -84,12 +87,15 @@ export function CallingSelectPresentingSourcesModal({
const footer = ( const footer = (
<> <>
<Button onClick={() => setPresenting()} variant={ButtonVariant.Secondary}> <Button onClick={cancelPresenting} variant={ButtonVariant.Secondary}>
{i18n('icu:cancel')} {i18n('icu:cancel')}
</Button> </Button>
<Button <Button
disabled={!sourceToPresent} disabled={!sourceToPresent}
onClick={() => setPresenting(sourceToPresent)} onClick={() => {
strictAssert(sourceToPresent, 'No source to present');
selectPresentingSource(sourceToPresent.id);
}}
> >
{i18n('icu:calling__SelectPresentingSourcesModal--confirm')} {i18n('icu:calling__SelectPresentingSourcesModal--confirm')}
</Button> </Button>
@ -102,9 +108,7 @@ export function CallingSelectPresentingSourcesModal({
hasXButton hasXButton
i18n={i18n} i18n={i18n}
moduleClassName="module-CallingSelectPresentingSourcesModal" moduleClassName="module-CallingSelectPresentingSourcesModal"
onClose={() => { onClose={cancelPresenting}
setPresenting();
}}
theme={Theme.Dark} theme={Theme.Dark}
title={i18n('icu:calling__SelectPresentingSourcesModal--title')} title={i18n('icu:calling__SelectPresentingSourcesModal--title')}
modalFooter={footer} modalFooter={footer}

View file

@ -1,7 +1,6 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { DesktopCapturerSource } from 'electron';
import { ipcRenderer } from 'electron'; import { ipcRenderer } from 'electron';
import type { import type {
AudioDevice, AudioDevice,
@ -64,7 +63,6 @@ import type {
AvailableIODevicesType, AvailableIODevicesType,
CallEndedReason, CallEndedReason,
MediaDeviceSettings, MediaDeviceSettings,
PresentableSource,
PresentedSource, PresentedSource,
} from '../types/Calling'; } from '../types/Calling';
import { import {
@ -77,7 +75,6 @@ import {
findBestMatchingAudioDeviceIndex, findBestMatchingAudioDeviceIndex,
findBestMatchingCameraId, findBestMatchingCameraId,
} from '../calling/findBestMatchingDevice'; } from '../calling/findBestMatchingDevice';
import type { LocalizerType } from '../types/Util';
import { normalizeAci } from '../util/normalizeAci'; import { normalizeAci } from '../util/normalizeAci';
import { isAciString } from '../util/isAciString'; import { isAciString } from '../util/isAciString';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
@ -102,7 +99,6 @@ import {
} from '../calling/constants'; } from '../calling/constants';
import { callingMessageToProto } from '../util/callingMessageToProto'; import { callingMessageToProto } from '../util/callingMessageToProto';
import { requestMicrophonePermissions } from '../util/requestMicrophonePermissions'; import { requestMicrophonePermissions } from '../util/requestMicrophonePermissions';
import OS from '../util/os/osMain';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
import { DataReader, DataWriter } from '../sql/Client'; import { DataReader, DataWriter } from '../sql/Client';
import { import {
@ -192,6 +188,7 @@ type CallingReduxInterface = Pick<
CallingReduxActionsType, CallingReduxActionsType,
| 'callStateChange' | 'callStateChange'
| 'cancelIncomingGroupCallRing' | 'cancelIncomingGroupCallRing'
| 'cancelPresenting'
| 'groupCallAudioLevelsChange' | 'groupCallAudioLevelsChange'
| 'groupCallEnded' | 'groupCallEnded'
| 'groupCallRaisedHandsChange' | 'groupCallRaisedHandsChange'
@ -204,7 +201,6 @@ type CallingReduxInterface = Pick<
| 'refreshIODevices' | 'refreshIODevices'
| 'remoteSharingScreenChange' | 'remoteSharingScreenChange'
| 'remoteVideoChange' | 'remoteVideoChange'
| 'setPresenting'
| 'startCallingLobby' | 'startCallingLobby'
| 'startCallLinkLobby' | 'startCallLinkLobby'
| 'startCallLinkLobbyByRoomId' | 'startCallLinkLobbyByRoomId'
@ -213,9 +209,13 @@ type CallingReduxInterface = Pick<
areAnyCallsActiveOrRinging(): boolean; areAnyCallsActiveOrRinging(): boolean;
}; };
function isScreenSource(source: PresentedSource): boolean { export type SetPresentingOptionsType = Readonly<{
return source.id.startsWith('screen'); conversationId: string;
} hasLocalVideo: boolean;
mediaStream?: MediaStream;
source?: PresentedSource;
callLinkRootKey?: string;
}>;
function truncateForLogging(name: string | undefined): string | undefined { function truncateForLogging(name: string | undefined): string | undefined {
if (!name || name.length <= 4) { if (!name || name.length <= 4) {
@ -251,29 +251,6 @@ function cleanForLogging(settings?: MediaDeviceSettings): unknown {
}; };
} }
function translateSourceName(
i18n: LocalizerType,
source: PresentedSource
): string {
const { name } = source;
if (!isScreenSource(source)) {
return name;
}
if (name === 'Entire Screen') {
return i18n('icu:calling__SelectPresentingSourcesModal--entireScreen');
}
const match = name.match(/^Screen (\d+)$/);
if (match) {
return i18n('icu:calling__SelectPresentingSourcesModal--screen', {
id: match[1],
});
}
return name;
}
function protoToCallingMessage({ function protoToCallingMessage({
offer, offer,
answer, answer,
@ -416,7 +393,7 @@ export class CallingClass {
}); });
ipcRenderer.on('stop-screen-share', () => { ipcRenderer.on('stop-screen-share', () => {
reduxInterface.setPresenting(); reduxInterface.cancelPresenting();
}); });
ipcRenderer.on( ipcRenderer.on(
'calling:set-rtc-stats-interval', 'calling:set-rtc-stats-interval',
@ -2046,51 +2023,13 @@ export class CallingClass {
} }
} }
async getPresentingSources(): Promise<Array<PresentableSource>> { async setPresenting({
// There's a Linux Wayland Electron bug where requesting desktopCapturer. conversationId,
// getSources() with types as ['screen', 'window'] (the default) pops 2 hasLocalVideo,
// OS permissions dialogs in an unusable state (Dialog 1 for Share Window mediaStream,
// is the foreground and ignores input; Dialog 2 for Share Screen is background source,
// and requires input. As a workaround, request both sources sequentially. callLinkRootKey,
// https://github.com/signalapp/Signal-Desktop/issues/5350#issuecomment-1688614149 }: SetPresentingOptionsType): Promise<void> {
const sources: ReadonlyArray<DesktopCapturerSource> =
OS.isLinux() && OS.isWaylandEnabled()
? (
await ipcRenderer.invoke('getScreenCaptureSources', ['screen'])
).concat(
await ipcRenderer.invoke('getScreenCaptureSources', ['window'])
)
: await ipcRenderer.invoke('getScreenCaptureSources');
const presentableSources: Array<PresentableSource> = [];
sources.forEach(source => {
// If electron can't retrieve a thumbnail then it won't be able to
// present this source so we filter these out.
if (source.thumbnail.isEmpty()) {
return;
}
presentableSources.push({
appIcon:
source.appIcon && !source.appIcon.isEmpty()
? source.appIcon.toDataURL()
: undefined,
id: source.id,
name: translateSourceName(window.i18n, source),
isScreen: isScreenSource(source),
thumbnail: source.thumbnail.toDataURL(),
});
});
return presentableSources;
}
async setPresenting(
conversationId: string,
hasLocalVideo: boolean,
source?: PresentedSource,
callLinkRootKey?: string
): Promise<void> {
const call = getOwn(this.callsLookup, conversationId); const call = getOwn(this.callsLookup, conversationId);
if (!call) { if (!call) {
log.warn('Trying to set presenting for a non-existent call'); log.warn('Trying to set presenting for a non-existent call');
@ -2098,7 +2037,8 @@ export class CallingClass {
} }
this.videoCapturer.disable(); this.videoCapturer.disable();
if (source) { const isPresenting = mediaStream != null;
if (isPresenting) {
this.hadLocalVideoBeforePresenting = hasLocalVideo; this.hadLocalVideoBeforePresenting = hasLocalVideo;
drop( drop(
this.enableCaptureAndSend(call, { this.enableCaptureAndSend(call, {
@ -2106,7 +2046,7 @@ export class CallingClass {
maxFramerate: 5, maxFramerate: 5,
maxHeight: 1800, maxHeight: 1800,
maxWidth: 2880, maxWidth: 2880,
screenShareSourceId: source.id, mediaStream,
}) })
); );
this.setOutgoingVideo(conversationId, true); this.setOutgoingVideo(conversationId, true);
@ -2118,11 +2058,10 @@ export class CallingClass {
this.hadLocalVideoBeforePresenting = undefined; this.hadLocalVideoBeforePresenting = undefined;
} }
const isPresenting = Boolean(source);
this.setOutgoingVideoIsScreenShare(call, isPresenting); this.setOutgoingVideoIsScreenShare(call, isPresenting);
if (source) { if (isPresenting) {
ipcRenderer.send('show-screen-share', source.name); ipcRenderer.send('show-screen-share', source?.name);
let url: string; let url: string;
let absolutePath: string | undefined; let absolutePath: string | undefined;

View file

@ -218,7 +218,7 @@ class NotificationService extends EventEmitter {
isVideoCall: true, isVideoCall: true,
}); });
} else if (type === NotificationType.IsPresenting) { } else if (type === NotificationType.IsPresenting) {
window.reduxActions?.calling?.setPresenting(); window.reduxActions?.calling?.cancelPresenting();
} else if (type === NotificationType.IncomingCall) { } else if (type === NotificationType.IncomingCall) {
window.IPC.showWindow(); window.IPC.showWindow();
} else { } else {

View file

@ -19,6 +19,10 @@ import { getIntl, getPlatform } from '../selectors/user';
import { isConversationTooBigToRing } from '../../conversations/isConversationTooBigToRing'; import { isConversationTooBigToRing } from '../../conversations/isConversationTooBigToRing';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { drop } from '../../util/drop'; import { drop } from '../../util/drop';
import {
DesktopCapturer,
type DesktopCapturerBaton,
} from '../../util/desktopCapturer';
import { calling } from '../../services/calling'; import { calling } from '../../services/calling';
import { truncateAudioLevel } from '../../calling/truncateAudioLevel'; import { truncateAudioLevel } from '../../calling/truncateAudioLevel';
import type { StateType as RootStateType } from '../reducer'; import type { StateType as RootStateType } from '../reducer';
@ -179,7 +183,8 @@ export type ActiveCallStateType = {
outgoingRing: boolean; outgoingRing: boolean;
pip: boolean; pip: boolean;
presentingSource?: PresentedSource; presentingSource?: PresentedSource;
presentingSourcesAvailable?: Array<PresentableSource>; presentingSourcesAvailable?: ReadonlyArray<PresentableSource>;
capturerBaton?: DesktopCapturerBaton;
settingsDialogOpen: boolean; settingsDialogOpen: boolean;
showNeedsScreenRecordingPermissionsWarning?: boolean; showNeedsScreenRecordingPermissionsWarning?: boolean;
showParticipantsList: boolean; showParticipantsList: boolean;
@ -636,6 +641,7 @@ const REMOTE_SHARING_SCREEN_CHANGE = 'calling/REMOTE_SHARING_SCREEN_CHANGE';
const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE'; const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE';
const REMOVE_CLIENT = 'calling/REMOVE_CLIENT'; const REMOVE_CLIENT = 'calling/REMOVE_CLIENT';
const RETURN_TO_ACTIVE_CALL = 'calling/RETURN_TO_ACTIVE_CALL'; const RETURN_TO_ACTIVE_CALL = 'calling/RETURN_TO_ACTIVE_CALL';
const SELECT_PRESENTING_SOURCE = 'calling/SELECT_PRESENTING_SOURCE';
const SEND_GROUP_CALL_REACTION = 'calling/SEND_GROUP_CALL_REACTION'; const SEND_GROUP_CALL_REACTION = 'calling/SEND_GROUP_CALL_REACTION';
const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED'; const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED';
const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED'; const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED';
@ -866,6 +872,11 @@ type ReturnToActiveCallActionType = ReadonlyDeep<{
type: 'calling/RETURN_TO_ACTIVE_CALL'; type: 'calling/RETURN_TO_ACTIVE_CALL';
}>; }>;
type SelectPresentingSourceActionType = ReadonlyDeep<{
type: 'calling/SELECT_PRESENTING_SOURCE';
payload: string;
}>;
type SetLocalAudioActionType = ReadonlyDeep<{ type SetLocalAudioActionType = ReadonlyDeep<{
type: 'calling/SET_LOCAL_AUDIO_FULFILLED'; type: 'calling/SET_LOCAL_AUDIO_FULFILLED';
payload: SetLocalAudioType; payload: SetLocalAudioType;
@ -881,11 +892,13 @@ type SetPresentingFulfilledActionType = ReadonlyDeep<{
payload?: PresentedSource; payload?: PresentedSource;
}>; }>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep type SetPresentingSourcesActionType = ReadonlyDeep<{
type SetPresentingSourcesActionType = {
type: 'calling/SET_PRESENTING_SOURCES'; type: 'calling/SET_PRESENTING_SOURCES';
payload: Array<PresentableSource>; payload: {
}; presentableSources: ReadonlyArray<PresentableSource>;
capturerBaton: DesktopCapturerBaton;
};
}>;
type SetOutgoingRingActionType = ReadonlyDeep<{ type SetOutgoingRingActionType = ReadonlyDeep<{
type: 'calling/SET_OUTGOING_RING'; type: 'calling/SET_OUTGOING_RING';
@ -962,6 +975,7 @@ export type CallingActionType =
| RemoveClientActionType | RemoveClientActionType
| ReturnToActiveCallActionType | ReturnToActiveCallActionType
| SendGroupCallReactionActionType | SendGroupCallReactionActionType
| SelectPresentingSourceActionType
| SetLocalAudioActionType | SetLocalAudioActionType
| SetLocalVideoFulfilledActionType | SetLocalVideoFulfilledActionType
| SetPresentingSourcesActionType | SetPresentingSourcesActionType
@ -1275,6 +1289,8 @@ function declineCall(
}; };
} }
const globalCapturers = new WeakMap<DesktopCapturerBaton, DesktopCapturer>();
function getPresentingSources(): ThunkAction< function getPresentingSources(): ThunkAction<
void, void,
RootStateType, RootStateType,
@ -1283,6 +1299,8 @@ function getPresentingSources(): ThunkAction<
| ToggleNeedsScreenRecordingPermissionsActionType | ToggleNeedsScreenRecordingPermissionsActionType
> { > {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const i18n = getIntl(getState());
// We check if the user has permissions first before calling desktopCapturer // We check if the user has permissions first before calling desktopCapturer
// Next we call getPresentingSources so that one gets the prompt for permissions, // Next we call getPresentingSources so that one gets the prompt for permissions,
// if necessary. // if necessary.
@ -1294,19 +1312,51 @@ function getPresentingSources(): ThunkAction<
const needsPermission = const needsPermission =
platform === 'darwin' && !hasScreenCapturePermission(); platform === 'darwin' && !hasScreenCapturePermission();
const sources = await calling.getPresentingSources(); const capturer = new DesktopCapturer({
i18n,
onPresentableSources(presentableSources) {
if (needsPermission) { if (needsPermission) {
dispatch({ // Abort
type: TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS, capturer.selectSource(undefined);
});
return; return;
} }
dispatch({ dispatch({
type: SET_PRESENTING_SOURCES, type: SET_PRESENTING_SOURCES,
payload: sources, payload: {
presentableSources,
capturerBaton: capturer.baton,
},
}); });
},
onMediaStream(mediaStream) {
let presentingSource: PresentedSource | undefined;
const { activeCallState } = getState().calling;
if (activeCallState?.state === 'Active') {
({ presentingSource } = activeCallState);
}
dispatch(
_setPresenting(
presentingSource || {
id: 'media-stream',
name: '',
},
mediaStream
)
);
},
onError(error) {
log.error('getPresentingSources: got error', Errors.toLogFormat(error));
},
});
globalCapturers.set(capturer.baton, capturer);
if (needsPermission) {
dispatch({
type: TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS,
});
}
}; };
} }
@ -1774,6 +1824,13 @@ function returnToActiveCall(): ReturnToActiveCallActionType {
}; };
} }
function selectPresentingSource(id: string): SelectPresentingSourceActionType {
return {
type: SELECT_PRESENTING_SOURCE,
payload: id,
};
}
function setIsCallActive( function setIsCallActive(
isCallActive: boolean isCallActive: boolean
): ThunkAction<void, RootStateType, unknown, never> { ): ThunkAction<void, RootStateType, unknown, never> {
@ -1871,8 +1928,9 @@ function setGroupCallVideoRequest(
}; };
} }
function setPresenting( function _setPresenting(
sourceToPresent?: PresentedSource sourceToPresent?: PresentedSource,
mediaStream?: MediaStream
): ThunkAction<void, RootStateType, unknown, SetPresentingFulfilledActionType> { ): ThunkAction<void, RootStateType, unknown, SetPresentingFulfilledActionType> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
@ -1898,24 +1956,34 @@ function setPresenting(
rootKey = callLink?.rootKey; rootKey = callLink?.rootKey;
} }
await calling.setPresenting( await calling.setPresenting({
activeCall.conversationId, conversationId: activeCall.conversationId,
activeCallState.hasLocalVideo, hasLocalVideo: activeCallState.hasLocalVideo,
sourceToPresent, mediaStream,
rootKey source: sourceToPresent,
); callLinkRootKey: rootKey,
});
dispatch({ dispatch({
type: SET_PRESENTING, type: SET_PRESENTING,
payload: sourceToPresent, payload: sourceToPresent,
}); });
if (sourceToPresent) { if (mediaStream) {
await callingTones.someonePresenting(); await callingTones.someonePresenting();
} }
}; };
} }
function cancelPresenting(): ThunkAction<
void,
RootStateType,
unknown,
SetPresentingFulfilledActionType
> {
return _setPresenting(undefined, undefined);
}
function setOutgoingRing(payload: boolean): SetOutgoingRingActionType { function setOutgoingRing(payload: boolean): SetOutgoingRingActionType {
return { return {
type: SET_OUTGOING_RING, type: SET_OUTGOING_RING,
@ -2557,6 +2625,7 @@ export const actions = {
callStateChange, callStateChange,
cancelCall, cancelCall,
cancelIncomingGroupCallRing, cancelIncomingGroupCallRing,
cancelPresenting,
changeCallView, changeCallView,
changeIODevice, changeIODevice,
closeNeedPermissionScreen, closeNeedPermissionScreen,
@ -2592,13 +2661,13 @@ export const actions = {
returnToActiveCall, returnToActiveCall,
sendGroupCallRaiseHand, sendGroupCallRaiseHand,
sendGroupCallReaction, sendGroupCallReaction,
selectPresentingSource,
setGroupCallVideoRequest, setGroupCallVideoRequest,
setIsCallActive, setIsCallActive,
setLocalAudio, setLocalAudio,
setLocalPreview, setLocalPreview,
setLocalVideo, setLocalVideo,
setOutgoingRing, setOutgoingRing,
setPresenting,
setRendererCanvas, setRendererCanvas,
startCall, startCall,
startCallLinkLobby, startCallLinkLobby,
@ -2612,6 +2681,9 @@ export const actions = {
toggleSettings, toggleSettings,
updateCallLinkName, updateCallLinkName,
updateCallLinkRestrictions, updateCallLinkRestrictions,
// Exported only for tests
_setPresenting,
}; };
export const useCallingActions = (): BoundActionCreatorsMapObject< export const useCallingActions = (): BoundActionCreatorsMapObject<
@ -3677,12 +3749,20 @@ export function reducer(
return state; return state;
} }
// Cancel source selection if running
const { capturerBaton } = activeCallState;
if (capturerBaton != null) {
const capturer = globalCapturers.get(capturerBaton);
capturer?.selectSource(undefined);
}
return { return {
...state, ...state,
activeCallState: { activeCallState: {
...activeCallState, ...activeCallState,
presentingSource: action.payload, presentingSource: action.payload,
presentingSourcesAvailable: undefined, presentingSourcesAvailable: undefined,
capturerBaton: undefined,
}, },
}; };
} }
@ -3698,7 +3778,43 @@ export function reducer(
...state, ...state,
activeCallState: { activeCallState: {
...activeCallState, ...activeCallState,
presentingSourcesAvailable: action.payload, presentingSourcesAvailable: action.payload.presentableSources,
capturerBaton: action.payload.capturerBaton,
},
};
}
if (action.type === SELECT_PRESENTING_SOURCE) {
const { activeCallState } = state;
if (activeCallState?.state !== 'Active') {
log.warn('Cannot set presenting sources when there is no active call');
return state;
}
const { capturerBaton, presentingSourcesAvailable } = activeCallState;
if (!capturerBaton || !presentingSourcesAvailable) {
log.warn(
'Cannot set presenting sources when there is no presenting modal'
);
return state;
}
const capturer = globalCapturers.get(capturerBaton);
if (!capturer) {
log.warn('Cannot toggle presenting when there is no capturer');
return state;
}
capturer.selectSource(action.payload);
return {
...state,
activeCallState: {
...activeCallState,
presentingSource: presentingSourcesAvailable.find(
source => source.id === action.payload
),
presentingSourcesAvailable: undefined,
capturerBaton: undefined,
}, },
}; };
} }

View file

@ -441,15 +441,16 @@ export const SmartCallManager = memo(function SmartCallManager() {
openSystemPreferencesAction, openSystemPreferencesAction,
removeClient, removeClient,
blockClient, blockClient,
cancelPresenting,
sendGroupCallRaiseHand, sendGroupCallRaiseHand,
sendGroupCallReaction, sendGroupCallReaction,
selectPresentingSource,
setGroupCallVideoRequest, setGroupCallVideoRequest,
setIsCallActive, setIsCallActive,
setLocalAudio, setLocalAudio,
setLocalVideo, setLocalVideo,
setLocalPreview, setLocalPreview,
setOutgoingRing, setOutgoingRing,
setPresenting,
setRendererCanvas, setRendererCanvas,
switchToPresentationView, switchToPresentationView,
switchFromPresentationView, switchFromPresentationView,
@ -477,6 +478,7 @@ export const SmartCallManager = memo(function SmartCallManager() {
bounceAppIconStop={bounceAppIconStop} bounceAppIconStop={bounceAppIconStop}
callLink={callLink} callLink={callLink}
cancelCall={cancelCall} cancelCall={cancelCall}
cancelPresenting={cancelPresenting}
changeCallView={changeCallView} changeCallView={changeCallView}
closeNeedPermissionScreen={closeNeedPermissionScreen} closeNeedPermissionScreen={closeNeedPermissionScreen}
declineCall={declineCall} declineCall={declineCall}
@ -503,13 +505,13 @@ export const SmartCallManager = memo(function SmartCallManager() {
renderReactionPicker={renderReactionPicker} renderReactionPicker={renderReactionPicker}
sendGroupCallRaiseHand={sendGroupCallRaiseHand} sendGroupCallRaiseHand={sendGroupCallRaiseHand}
sendGroupCallReaction={sendGroupCallReaction} sendGroupCallReaction={sendGroupCallReaction}
selectPresentingSource={selectPresentingSource}
setGroupCallVideoRequest={setGroupCallVideoRequest} setGroupCallVideoRequest={setGroupCallVideoRequest}
setIsCallActive={setIsCallActive} setIsCallActive={setIsCallActive}
setLocalAudio={setLocalAudio} setLocalAudio={setLocalAudio}
setLocalPreview={setLocalPreview} setLocalPreview={setLocalPreview}
setLocalVideo={setLocalVideo} setLocalVideo={setLocalVideo}
setOutgoingRing={setOutgoingRing} setOutgoingRing={setOutgoingRing}
setPresenting={setPresenting}
setRendererCanvas={setRendererCanvas} setRendererCanvas={setRendererCanvas}
showContactModal={showContactModal} showContactModal={showContactModal}
showShareCallLinkViaSignal={showShareCallLinkViaSignal} showShareCallLinkViaSignal={showShareCallLinkViaSignal}

View file

@ -240,46 +240,6 @@ describe('calling duck', () => {
}); });
describe('actions', () => { describe('actions', () => {
describe('getPresentingSources', () => {
beforeEach(function (this: Mocha.Context) {
this.callingServiceGetPresentingSources = this.sandbox
.stub(callingService, 'getPresentingSources')
.resolves([
{
id: 'foo.bar',
name: 'Foo Bar',
thumbnail: 'xyz',
},
]);
});
it('retrieves sources from the calling service', async function (this: Mocha.Context) {
const { getPresentingSources } = actions;
const dispatch = sinon.spy();
await getPresentingSources()(dispatch, getEmptyRootState, null);
sinon.assert.calledOnce(this.callingServiceGetPresentingSources);
});
it('dispatches SET_PRESENTING_SOURCES', async () => {
const { getPresentingSources } = actions;
const dispatch = sinon.spy();
await getPresentingSources()(dispatch, getEmptyRootState, null);
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWith(dispatch, {
type: 'calling/SET_PRESENTING_SOURCES',
payload: [
{
id: 'foo.bar',
name: 'Foo Bar',
thumbnail: 'xyz',
},
],
});
});
});
describe('remoteSharingScreenChange', () => { describe('remoteSharingScreenChange', () => {
it("updates whether someone's screen is being shared", () => { it("updates whether someone's screen is being shared", () => {
const { remoteSharingScreenChange } = actions; const { remoteSharingScreenChange } = actions;
@ -308,7 +268,7 @@ describe('calling duck', () => {
}); });
}); });
describe('setPresenting', () => { describe('_setPresenting', () => {
beforeEach(function (this: Mocha.Context) { beforeEach(function (this: Mocha.Context) {
this.callingServiceSetPresenting = this.sandbox.stub( this.callingServiceSetPresenting = this.sandbox.stub(
callingService, callingService,
@ -316,8 +276,8 @@ describe('calling duck', () => {
); );
}); });
it('calls setPresenting on the calling service', async function (this: Mocha.Context) { it('calls _setPresenting on the calling service', async function (this: Mocha.Context) {
const { setPresenting } = actions; const { _setPresenting } = actions;
const dispatch = sinon.spy(); const dispatch = sinon.spy();
const presentedSource = { const presentedSource = {
id: 'window:786', id: 'window:786',
@ -330,19 +290,20 @@ describe('calling duck', () => {
}, },
}); });
await setPresenting(presentedSource)(dispatch, getState, null); await _setPresenting(presentedSource)(dispatch, getState, null);
sinon.assert.calledOnce(this.callingServiceSetPresenting); sinon.assert.calledOnce(this.callingServiceSetPresenting);
sinon.assert.calledWith( sinon.assert.calledWith(this.callingServiceSetPresenting, {
this.callingServiceSetPresenting, conversationId: 'fake-group-call-conversation-id',
'fake-group-call-conversation-id', hasLocalVideo: false,
false, mediaStream: undefined,
presentedSource source: presentedSource,
); callLinkRootKey: undefined,
});
}); });
it('dispatches SET_PRESENTING', async () => { it('dispatches SET_PRESENTING', async () => {
const { setPresenting } = actions; const { _setPresenting } = actions;
const dispatch = sinon.spy(); const dispatch = sinon.spy();
const presentedSource = { const presentedSource = {
id: 'window:786', id: 'window:786',
@ -355,7 +316,7 @@ describe('calling duck', () => {
}, },
}); });
await setPresenting(presentedSource)(dispatch, getState, null); await _setPresenting(presentedSource)(dispatch, getState, null);
sinon.assert.calledOnce(dispatch); sinon.assert.calledOnce(dispatch);
sinon.assert.calledWith(dispatch, { sinon.assert.calledWith(dispatch, {
@ -366,7 +327,7 @@ describe('calling duck', () => {
it('turns off presenting when no value is passed in', async () => { it('turns off presenting when no value is passed in', async () => {
const dispatch = sinon.spy(); const dispatch = sinon.spy();
const { setPresenting } = actions; const { _setPresenting } = actions;
const presentedSource = { const presentedSource = {
id: 'window:786', id: 'window:786',
name: 'Application', name: 'Application',
@ -379,7 +340,7 @@ describe('calling duck', () => {
}, },
}); });
await setPresenting(presentedSource)(dispatch, getState, null); await _setPresenting(presentedSource)(dispatch, getState, null);
const action = dispatch.getCall(0).args[0]; const action = dispatch.getCall(0).args[0];
@ -401,7 +362,7 @@ describe('calling duck', () => {
it('sets the presenting value when one is passed in', async () => { it('sets the presenting value when one is passed in', async () => {
const dispatch = sinon.spy(); const dispatch = sinon.spy();
const { setPresenting } = actions; const { _setPresenting } = actions;
const getState = (): RootStateType => ({ const getState = (): RootStateType => ({
...getEmptyRootState(), ...getEmptyRootState(),
@ -410,7 +371,7 @@ describe('calling duck', () => {
}, },
}); });
await setPresenting()(dispatch, getState, null); await _setPresenting()(dispatch, getState, null);
const action = dispatch.getCall(0).args[0]; const action = dispatch.getCall(0).args[0];

View file

@ -89,7 +89,7 @@ describe('renderWindowsToast', () => {
}); });
const expected = const expected =
'<toast launch="sgnl://set-is-presenting" activationType="protocol"><visual><binding template="ToastText02"><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>'; '<toast launch="sgnl://cancel-presenting" activationType="protocol"><visual><binding template="ToastText02"><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
assert.strictEqual(xml, expected); assert.strictEqual(xml, expected);
}); });

View file

@ -21,13 +21,15 @@ describe('updateDefaultSession', () => {
it('sets the spellcheck URL', () => { it('sets the spellcheck URL', () => {
const sesh = session.fromPartition(uuid()); const sesh = session.fromPartition(uuid());
const stub = sandbox.stub(sesh, 'setSpellCheckerDictionaryDownloadURL'); const stub = sandbox.stub(sesh, 'setSpellCheckerDictionaryDownloadURL');
const getLogger = sandbox.stub();
updateDefaultSession(sesh); updateDefaultSession(sesh, getLogger);
sinon.assert.calledOnce(stub); sinon.assert.calledOnce(stub);
sinon.assert.calledWith( sinon.assert.calledWith(
stub, stub,
`https://updates.signal.org/desktop/hunspell_dictionaries/${process.versions.electron}/` `https://updates.signal.org/desktop/hunspell_dictionaries/${process.versions.electron}/`
); );
sinon.assert.notCalled(getLogger);
}); });
}); });

View file

@ -205,13 +205,13 @@ describe('signalRoutes', () => {
check('sgnl://show-window', result); check('sgnl://show-window', result);
}); });
it('setIsPresenting', () => { it('cancelPresenting', () => {
const result: ParsedSignalRoute = { const result: ParsedSignalRoute = {
key: 'setIsPresenting', key: 'cancelPresenting',
args: {}, args: {},
}; };
const check = createCheck({ isRoute: true, hasWebUrl: false }); const check = createCheck({ isRoute: true, hasWebUrl: false });
check('sgnl://set-is-presenting/', result); check('sgnl://cancel-presenting/', result);
check('sgnl://set-is-presenting', result); check('sgnl://cancel-presenting', result);
}); });
}); });

View file

@ -1,6 +1,7 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ReadonlyDeep } from 'type-fest';
import type { AudioDevice, Reaction as CallReaction } from '@signalapp/ringrtc'; import type { AudioDevice, Reaction as CallReaction } from '@signalapp/ringrtc';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { AciString, ServiceIdString } from './ServiceId'; import type { AciString, ServiceIdString } from './ServiceId';
@ -19,13 +20,13 @@ export enum CallViewMode {
Presentation = 'Presentation', Presentation = 'Presentation',
} }
export type PresentableSource = { export type PresentableSource = ReadonlyDeep<{
appIcon?: string; appIcon?: string;
id: string; id: string;
name: string; name: string;
isScreen: boolean; isScreen: boolean;
thumbnail: string; thumbnail: string;
}; }>;
export type PresentedSource = { export type PresentedSource = {
id: string; id: string;
@ -54,7 +55,7 @@ export type ActiveCallBaseType = {
outgoingRing: boolean; outgoingRing: boolean;
pip: boolean; pip: boolean;
presentingSource?: PresentedSource; presentingSource?: PresentedSource;
presentingSourcesAvailable?: Array<PresentableSource>; presentingSourcesAvailable?: ReadonlyArray<PresentableSource>;
settingsDialogOpen: boolean; settingsDialogOpen: boolean;
showNeedsScreenRecordingPermissionsWarning?: boolean; showNeedsScreenRecordingPermissionsWarning?: boolean;
showParticipantsList: boolean; showParticipantsList: boolean;

229
ts/util/desktopCapturer.ts Normal file
View file

@ -0,0 +1,229 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ipcRenderer, type DesktopCapturerSource } from 'electron';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import type { PresentableSource } from '../types/Calling';
import type { LocalizerType } from '../types/Util';
import {
REQUESTED_VIDEO_WIDTH,
REQUESTED_VIDEO_HEIGHT,
REQUESTED_VIDEO_FRAMERATE,
} from '../calling/constants';
import { strictAssert } from './assert';
import { explodePromise } from './explodePromise';
import { isNotNil } from './isNotNil';
enum Step {
RequestingMedia = 'RequestingMedia',
Done = 'Done',
Error = 'Error',
// Skipped on macOS Sequoia
SelectingSource = 'SelectingSource',
SelectedSource = 'SelectedSource',
}
type State = Readonly<
| {
step: Step.RequestingMedia;
promise: Promise<void>;
}
| {
step: Step.SelectingSource;
promise: Promise<void>;
sources: ReadonlyArray<DesktopCapturerSource>;
onSource: (source: DesktopCapturerSource | undefined) => void;
}
| {
step: Step.SelectedSource;
promise: Promise<void>;
}
| {
step: Step.Done;
}
| {
step: Step.Error;
}
>;
export const liveCapturers = new Set<DesktopCapturer>();
export type IpcResponseType = Readonly<{
id: string;
sources: ReadonlyArray<DesktopCapturerSource>;
}>;
export type DesktopCapturerOptionsType = Readonly<{
i18n: LocalizerType;
onPresentableSources(sources: ReadonlyArray<PresentableSource>): void;
onMediaStream(stream: MediaStream): void;
onError(error: Error): void;
}>;
export type DesktopCapturerBaton = Readonly<{
__desktop_capturer_baton: never;
}>;
export class DesktopCapturer {
private state: State;
private static isInitialized = false;
// For use as a key in weak maps
public readonly baton = {} as DesktopCapturerBaton;
constructor(private readonly options: DesktopCapturerOptionsType) {
if (!DesktopCapturer.isInitialized) {
DesktopCapturer.initialize();
}
this.state = { step: Step.RequestingMedia, promise: this.getStream() };
}
public selectSource(id: string | undefined): void {
strictAssert(
this.state.step === Step.SelectingSource,
`Invalid state in "selectSource" ${this.state.step}`
);
const { promise, sources, onSource } = this.state;
const source = id == null ? undefined : sources.find(s => s.id === id);
this.state = { step: Step.SelectedSource, promise };
onSource(source);
}
/** @internal */
private onSources(
sources: ReadonlyArray<DesktopCapturerSource>
): Promise<DesktopCapturerSource | undefined> {
strictAssert(
this.state.step === Step.RequestingMedia,
`Invalid state in "onSources" ${this.state.step}`
);
const presentableSources = sources
.map(source => {
// If electron can't retrieve a thumbnail then it won't be able to
// present this source so we filter these out.
if (source.thumbnail.isEmpty()) {
return undefined;
}
return {
appIcon:
source.appIcon && !source.appIcon.isEmpty()
? source.appIcon.toDataURL()
: undefined,
id: source.id,
name: this.translateSourceName(source),
isScreen: isScreenSource(source),
thumbnail: source.thumbnail.toDataURL(),
};
})
.filter(isNotNil);
const { promise } = this.state;
const { promise: source, resolve: onSource } = explodePromise<
DesktopCapturerSource | undefined
>();
this.state = { step: Step.SelectingSource, promise, sources, onSource };
this.options.onPresentableSources(presentableSources);
return source;
}
private async getStream(): Promise<void> {
liveCapturers.add(this);
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
width: {
max: REQUESTED_VIDEO_WIDTH,
ideal: REQUESTED_VIDEO_WIDTH,
},
height: {
max: REQUESTED_VIDEO_HEIGHT,
ideal: REQUESTED_VIDEO_HEIGHT,
},
frameRate: {
max: REQUESTED_VIDEO_FRAMERATE,
ideal: REQUESTED_VIDEO_FRAMERATE,
},
},
});
strictAssert(
this.state.step === Step.RequestingMedia ||
this.state.step === Step.SelectedSource,
`Invalid state in "getStream.success" ${this.state.step}`
);
this.options.onMediaStream(stream);
this.state = { step: Step.Done };
} catch (error) {
strictAssert(
this.state.step === Step.RequestingMedia ||
this.state.step === Step.SelectedSource,
`Invalid state in "getStream.error" ${this.state.step}`
);
this.options.onError(error);
this.state = { step: Step.Error };
} finally {
liveCapturers.delete(this);
}
}
private translateSourceName(source: DesktopCapturerSource): string {
const { i18n } = this.options;
const { name } = source;
if (!isScreenSource(source)) {
return name;
}
if (name === 'Entire Screen') {
return i18n('icu:calling__SelectPresentingSourcesModal--entireScreen');
}
const match = name.match(/^Screen (\d+)$/);
if (match) {
return i18n('icu:calling__SelectPresentingSourcesModal--screen', {
id: match[1],
});
}
return name;
}
private static initialize(): void {
DesktopCapturer.isInitialized = true;
ipcRenderer.on(
'select-capture-sources',
async (_, { id, sources }: IpcResponseType) => {
let selected: DesktopCapturerSource | undefined;
try {
const { value: capturer, done } = liveCapturers.values().next();
strictAssert(!done, 'No capturer available for incoming sources');
liveCapturers.delete(capturer);
selected = await capturer.onSources(sources);
} catch (error) {
log.error(
'desktopCapturer: failed to get the source',
Errors.toLogFormat(error)
);
}
ipcRenderer.send(`select-capture-sources:${id}:response`, selected);
}
);
}
}
function isScreenSource(source: DesktopCapturerSource): boolean {
return source.id.startsWith('screen');
}

View file

@ -49,7 +49,7 @@ type AllHostnamePatterns =
| 'show-conversation' | 'show-conversation'
| 'start-call-lobby' | 'start-call-lobby'
| 'show-window' | 'show-window'
| 'set-is-presenting' | 'cancel-presenting'
| ':captchaId(.+)' | ':captchaId(.+)'
| ''; | '';
@ -535,18 +535,18 @@ export const showWindowRoute = _route('showWindow', {
* Set is presenting * Set is presenting
* @example * @example
* ```ts * ```ts
* setIsPresentingRoute.toAppUrl({}) * cancelPresentingRoute.toAppUrl({})
* // URL { "sgnl://set-is-presenting" } * // URL { "sgnl://cancel-presenting" }
* ``` * ```
*/ */
export const setIsPresentingRoute = _route('setIsPresenting', { export const cancelPresentingRoute = _route('cancelPresenting', {
patterns: [_pattern('sgnl:', 'set-is-presenting', '{/}?', {})], patterns: [_pattern('sgnl:', 'cancel-presenting', '{/}?', {})],
schema: z.object({}), schema: z.object({}),
parse() { parse() {
return {}; return {};
}, },
toAppUrl() { toAppUrl() {
return new URL('sgnl://set-is-presenting'); return new URL('sgnl://cancel-presenting');
}, },
}); });
@ -565,7 +565,7 @@ const _allSignalRoutes = [
showConversationRoute, showConversationRoute,
startCallLobbyRoute, startCallLobbyRoute,
showWindowRoute, showWindowRoute,
setIsPresentingRoute, cancelPresentingRoute,
] as const; ] as const;
strictAssert( strictAssert(

2
ts/window.d.ts vendored
View file

@ -121,7 +121,7 @@ type PermissionsWindowPropsType = {
type ScreenShareWindowPropsType = { type ScreenShareWindowPropsType = {
onStopSharing: () => void; onStopSharing: () => void;
presentedSourceName: string; presentedSourceName: string | undefined;
getStatus: () => ScreenShareStatus; getStatus: () => ScreenShareStatus;
setRenderCallback: (cb: () => void) => void; setRenderCallback: (cb: () => void) => void;
}; };

View file

@ -360,8 +360,8 @@ ipc.on('show-window', () => {
window.IPC.showWindow(); window.IPC.showWindow();
}); });
ipc.on('set-is-presenting', () => { ipc.on('cancel-presenting', () => {
window.reduxActions?.calling?.setPresenting(); window.reduxActions?.calling?.cancelPresenting();
}); });
ipc.on( ipc.on(