Refactor screen share picker internals
This commit is contained in:
parent
855b1c03b0
commit
d0b8a2991f
25 changed files with 547 additions and 244 deletions
|
@ -1913,6 +1913,10 @@
|
|||
"messageformat": "Signal is sharing {window}.",
|
||||
"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": {
|
||||
"messageformat": "Reconnecting...",
|
||||
"description": "Text that appears in the screen sharing controller to inform person that the call is in reconnecting state"
|
||||
|
|
29
app/main.ts
29
app/main.ts
|
@ -16,7 +16,6 @@ import {
|
|||
app,
|
||||
BrowserWindow,
|
||||
clipboard,
|
||||
desktopCapturer,
|
||||
dialog,
|
||||
ipcMain as ipc,
|
||||
Menu,
|
||||
|
@ -1229,7 +1228,7 @@ function setupAsStandalone() {
|
|||
}
|
||||
|
||||
let screenShareWindow: BrowserWindow | undefined;
|
||||
async function showScreenShareWindow(sourceName: string) {
|
||||
async function showScreenShareWindow(sourceName: string | undefined) {
|
||||
if (screenShareWindow) {
|
||||
screenShareWindow.showInactive();
|
||||
return;
|
||||
|
@ -1981,7 +1980,7 @@ app.on('ready', async () => {
|
|||
realpath(app.getAppPath()),
|
||||
]);
|
||||
|
||||
updateDefaultSession(session.defaultSession);
|
||||
updateDefaultSession(session.defaultSession, getLogger);
|
||||
|
||||
if (getEnvironment() !== Environment.Test) {
|
||||
installFileHandler({
|
||||
|
@ -2621,9 +2620,12 @@ ipc.on('stop-screen-share', () => {
|
|||
}
|
||||
});
|
||||
|
||||
ipc.on('show-screen-share', (_event: Electron.Event, sourceName: string) => {
|
||||
drop(showScreenShareWindow(sourceName));
|
||||
});
|
||||
ipc.on(
|
||||
'show-screen-share',
|
||||
(_event: Electron.Event, sourceName: string | undefined) => {
|
||||
drop(showScreenShareWindow(sourceName));
|
||||
}
|
||||
);
|
||||
|
||||
ipc.on('update-tray-icon', (_event: Electron.Event, unreadCount: number) => {
|
||||
if (systemTrayService) {
|
||||
|
@ -2895,8 +2897,8 @@ function handleSignalRoute(route: ParsedSignalRoute) {
|
|||
});
|
||||
} else if (route.key === 'showWindow') {
|
||||
mainWindow.webContents.send('show-window');
|
||||
} else if (route.key === 'setIsPresenting') {
|
||||
mainWindow.webContents.send('set-is-presenting');
|
||||
} else if (route.key === 'cancelPresenting') {
|
||||
mainWindow.webContents.send('cancel-presenting');
|
||||
} else if (route.key === 'captcha') {
|
||||
challengeHandler.handleCaptcha(route.args.captchaId);
|
||||
// Show window after handling captcha
|
||||
|
@ -3023,17 +3025,6 @@ ipc.handle('show-save-dialog', async (_event, { defaultPath }) => {
|
|||
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) => {
|
||||
const role = untypedRole as MenuItemConstructorOptions['role'];
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import type { WindowsNotificationData } from '../ts/services/notifications';
|
|||
import { NotificationType } from '../ts/services/notifications';
|
||||
import { missingCaseError } from '../ts/util/missingCaseError';
|
||||
import {
|
||||
setIsPresentingRoute,
|
||||
cancelPresentingRoute,
|
||||
showConversationRoute,
|
||||
showWindowRoute,
|
||||
startCallLobbyRoute,
|
||||
|
@ -69,7 +69,7 @@ export function renderWindowsToast({
|
|||
} else if (type === NotificationType.IncomingCall) {
|
||||
launch = showWindowRoute.toAppUrl({});
|
||||
} else if (type === NotificationType.IsPresenting) {
|
||||
launch = setIsPresentingRoute.toAppUrl({});
|
||||
launch = cancelPresentingRoute.toAppUrl({});
|
||||
} else {
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
|
|
@ -1,12 +1,72 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// 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}/`;
|
||||
|
||||
export function updateDefaultSession(session: Session): void {
|
||||
export function updateDefaultSession(
|
||||
session: Session,
|
||||
getLogger: () => LoggerType
|
||||
): void {
|
||||
session.setSpellCheckerDictionaryDownloadURL(
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -120,17 +120,18 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
|
|||
playRingtone: action('play-ringtone'),
|
||||
removeClient: action('remove-client'),
|
||||
blockClient: action('block-client'),
|
||||
cancelPresenting: action('cancel-presenting'),
|
||||
renderDeviceSelection: () => <div />,
|
||||
renderEmojiPicker: () => <>EmojiPicker</>,
|
||||
renderReactionPicker: () => <div />,
|
||||
sendGroupCallRaiseHand: action('send-group-call-raise-hand'),
|
||||
sendGroupCallReaction: action('send-group-call-reaction'),
|
||||
selectPresentingSource: action('select-presenting-source'),
|
||||
setGroupCallVideoRequest: action('set-group-call-video-request'),
|
||||
setIsCallActive: action('set-is-call-active'),
|
||||
setLocalAudio: action('set-local-audio'),
|
||||
setLocalPreview: action('set-local-preview'),
|
||||
setLocalVideo: action('set-local-video'),
|
||||
setPresenting: action('toggle-presenting'),
|
||||
setRendererCanvas: action('set-renderer-canvas'),
|
||||
setOutgoingRing: action('set-outgoing-ring'),
|
||||
showContactModal: action('show-contact-modal'),
|
||||
|
|
|
@ -16,7 +16,6 @@ import type {
|
|||
CallingConversationType,
|
||||
CallViewMode,
|
||||
GroupCallVideoRequest,
|
||||
PresentedSource,
|
||||
} from '../types/Calling';
|
||||
import {
|
||||
CallEndedReason,
|
||||
|
@ -105,6 +104,7 @@ export type PropsType = {
|
|||
batchUserAction: (payload: BatchUserActionPayloadType) => void;
|
||||
bounceAppIconStart: () => unknown;
|
||||
bounceAppIconStop: () => unknown;
|
||||
cancelPresenting: () => void;
|
||||
declineCall: (_: DeclineCallType) => void;
|
||||
denyUser: (payload: PendingUserActionPayloadType) => void;
|
||||
hasInitialLoadCompleted: boolean;
|
||||
|
@ -120,6 +120,7 @@ export type PropsType = {
|
|||
playRingtone: () => unknown;
|
||||
removeClient: (payload: RemoveClientType) => void;
|
||||
blockClient: (payload: RemoveClientType) => void;
|
||||
selectPresentingSource: (id: string) => void;
|
||||
sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void;
|
||||
sendGroupCallReaction: (payload: SendGroupCallReactionType) => void;
|
||||
setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void;
|
||||
|
@ -128,7 +129,6 @@ export type PropsType = {
|
|||
setLocalVideo: (_: SetLocalVideoType) => void;
|
||||
setLocalPreview: (_: SetLocalPreviewType) => void;
|
||||
setOutgoingRing: (_: boolean) => void;
|
||||
setPresenting: (_?: PresentedSource) => void;
|
||||
setRendererCanvas: (_: SetRendererCanvasType) => void;
|
||||
showShareCallLinkViaSignal: (
|
||||
callLink: CallLinkType,
|
||||
|
@ -171,6 +171,7 @@ function ActiveCallManager({
|
|||
blockClient,
|
||||
callLink,
|
||||
cancelCall,
|
||||
cancelPresenting,
|
||||
changeCallView,
|
||||
closeNeedPermissionScreen,
|
||||
denyUser,
|
||||
|
@ -186,13 +187,13 @@ function ActiveCallManager({
|
|||
renderEmojiPicker,
|
||||
renderReactionPicker,
|
||||
removeClient,
|
||||
selectPresentingSource,
|
||||
sendGroupCallRaiseHand,
|
||||
sendGroupCallReaction,
|
||||
setGroupCallVideoRequest,
|
||||
setLocalAudio,
|
||||
setLocalPreview,
|
||||
setLocalVideo,
|
||||
setPresenting,
|
||||
setRendererCanvas,
|
||||
setOutgoingRing,
|
||||
showContactModal,
|
||||
|
@ -452,6 +453,7 @@ function ActiveCallManager({
|
|||
activeCall={activeCall}
|
||||
approveUser={approveUser}
|
||||
batchUserAction={batchUserAction}
|
||||
cancelPresenting={cancelPresenting}
|
||||
changeCallView={changeCallView}
|
||||
denyUser={denyUser}
|
||||
getPresentingSources={getPresentingSources}
|
||||
|
@ -473,7 +475,6 @@ function ActiveCallManager({
|
|||
setRendererCanvas={setRendererCanvas}
|
||||
setLocalAudio={setLocalAudio}
|
||||
setLocalVideo={setLocalVideo}
|
||||
setPresenting={setPresenting}
|
||||
stickyControls={showParticipantsList}
|
||||
switchToPresentationView={switchToPresentationView}
|
||||
switchFromPresentationView={switchFromPresentationView}
|
||||
|
@ -491,7 +492,8 @@ function ActiveCallManager({
|
|||
<CallingSelectPresentingSourcesModal
|
||||
i18n={i18n}
|
||||
presentingSourcesAvailable={presentingSourcesAvailable}
|
||||
setPresenting={setPresenting}
|
||||
selectPresentingSource={selectPresentingSource}
|
||||
cancelPresenting={cancelPresenting}
|
||||
/>
|
||||
) : null}
|
||||
{settingsDialogOpen && renderDeviceSelection()}
|
||||
|
@ -536,6 +538,7 @@ export function CallManager({
|
|||
bounceAppIconStop,
|
||||
callLink,
|
||||
cancelCall,
|
||||
cancelPresenting,
|
||||
changeCallView,
|
||||
closeNeedPermissionScreen,
|
||||
declineCall,
|
||||
|
@ -558,6 +561,7 @@ export function CallManager({
|
|||
renderDeviceSelection,
|
||||
renderEmojiPicker,
|
||||
renderReactionPicker,
|
||||
selectPresentingSource,
|
||||
sendGroupCallRaiseHand,
|
||||
sendGroupCallReaction,
|
||||
setGroupCallVideoRequest,
|
||||
|
@ -566,7 +570,6 @@ export function CallManager({
|
|||
setLocalPreview,
|
||||
setLocalVideo,
|
||||
setOutgoingRing,
|
||||
setPresenting,
|
||||
setRendererCanvas,
|
||||
showContactModal,
|
||||
showShareCallLinkViaSignal,
|
||||
|
@ -635,6 +638,7 @@ export function CallManager({
|
|||
blockClient={blockClient}
|
||||
callLink={callLink}
|
||||
cancelCall={cancelCall}
|
||||
cancelPresenting={cancelPresenting}
|
||||
changeCallView={changeCallView}
|
||||
closeNeedPermissionScreen={closeNeedPermissionScreen}
|
||||
denyUser={denyUser}
|
||||
|
@ -653,6 +657,7 @@ export function CallManager({
|
|||
renderDeviceSelection={renderDeviceSelection}
|
||||
renderEmojiPicker={renderEmojiPicker}
|
||||
renderReactionPicker={renderReactionPicker}
|
||||
selectPresentingSource={selectPresentingSource}
|
||||
sendGroupCallRaiseHand={sendGroupCallRaiseHand}
|
||||
sendGroupCallReaction={sendGroupCallReaction}
|
||||
setGroupCallVideoRequest={setGroupCallVideoRequest}
|
||||
|
@ -660,7 +665,6 @@ export function CallManager({
|
|||
setLocalPreview={setLocalPreview}
|
||||
setLocalVideo={setLocalVideo}
|
||||
setOutgoingRing={setOutgoingRing}
|
||||
setPresenting={setPresenting}
|
||||
setRendererCanvas={setRendererCanvas}
|
||||
showContactModal={showContactModal}
|
||||
showShareCallLinkViaSignal={showShareCallLinkViaSignal}
|
||||
|
|
|
@ -206,13 +206,13 @@ const createProps = (
|
|||
openSystemPreferencesAction: action('open-system-preferences-action'),
|
||||
renderEmojiPicker: () => <>EmojiPicker</>,
|
||||
renderReactionPicker: () => <div />,
|
||||
cancelPresenting: action('cancel-presenting'),
|
||||
sendGroupCallRaiseHand: action('send-group-call-raise-hand'),
|
||||
sendGroupCallReaction: action('send-group-call-reaction'),
|
||||
setGroupCallVideoRequest: action('set-group-call-video-request'),
|
||||
setLocalAudio: action('set-local-audio'),
|
||||
setLocalPreview: action('set-local-preview'),
|
||||
setLocalVideo: action('set-local-video'),
|
||||
setPresenting: action('toggle-presenting'),
|
||||
setRendererCanvas: action('set-renderer-canvas'),
|
||||
stickyControls: false,
|
||||
switchToPresentationView: action('switch-to-presentation-view'),
|
||||
|
|
|
@ -29,7 +29,6 @@ import type {
|
|||
ActiveCallReactionsType,
|
||||
ConversationsByDemuxIdType,
|
||||
GroupCallVideoRequest,
|
||||
PresentedSource,
|
||||
} from '../types/Calling';
|
||||
import {
|
||||
CALLING_REACTIONS_LIFETIME,
|
||||
|
@ -97,6 +96,7 @@ export type PropsType = {
|
|||
activeCall: ActiveCallType;
|
||||
approveUser: (payload: PendingUserActionPayloadType) => void;
|
||||
batchUserAction: (payload: BatchUserActionPayloadType) => void;
|
||||
cancelPresenting: () => void;
|
||||
denyUser: (payload: PendingUserActionPayloadType) => void;
|
||||
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
||||
getPresentingSources: () => void;
|
||||
|
@ -120,7 +120,6 @@ export type PropsType = {
|
|||
setLocalAudio: (_: SetLocalAudioType) => void;
|
||||
setLocalVideo: (_: SetLocalVideoType) => void;
|
||||
setLocalPreview: (_: SetLocalPreviewType) => void;
|
||||
setPresenting: (_?: PresentedSource) => void;
|
||||
setRendererCanvas: (_: SetRendererCanvasType) => void;
|
||||
stickyControls: boolean;
|
||||
switchToPresentationView: () => void;
|
||||
|
@ -190,6 +189,7 @@ export function CallScreen({
|
|||
activeCall,
|
||||
approveUser,
|
||||
batchUserAction,
|
||||
cancelPresenting,
|
||||
changeCallView,
|
||||
denyUser,
|
||||
getGroupCallVideoFrameSource,
|
||||
|
@ -210,7 +210,6 @@ export function CallScreen({
|
|||
setLocalAudio,
|
||||
setLocalVideo,
|
||||
setLocalPreview,
|
||||
setPresenting,
|
||||
setRendererCanvas,
|
||||
stickyControls,
|
||||
switchToPresentationView,
|
||||
|
@ -260,11 +259,11 @@ export function CallScreen({
|
|||
|
||||
const togglePresenting = useCallback(() => {
|
||||
if (presentingSource) {
|
||||
setPresenting();
|
||||
cancelPresenting();
|
||||
} else {
|
||||
getPresentingSources();
|
||||
}
|
||||
}, [getPresentingSources, presentingSource, setPresenting]);
|
||||
}, [getPresentingSources, presentingSource, cancelPresenting]);
|
||||
|
||||
const hangUp = useCallback(() => {
|
||||
hangUpActiveCall('button click');
|
||||
|
|
|
@ -11,7 +11,7 @@ export type PropsType = {
|
|||
onCloseController: () => unknown;
|
||||
onStopSharing: () => unknown;
|
||||
status: ScreenShareStatus;
|
||||
presentedSourceName: string;
|
||||
presentedSourceName: string | undefined;
|
||||
};
|
||||
|
||||
export function CallingScreenSharingController({
|
||||
|
@ -25,10 +25,12 @@ export function CallingScreenSharingController({
|
|||
|
||||
if (status === ScreenShareStatus.Reconnecting) {
|
||||
text = i18n('icu:calling__presenting--reconnecting');
|
||||
} else {
|
||||
} else if (presentedSourceName) {
|
||||
text = i18n('icu:calling__presenting--info', {
|
||||
window: presentedSourceName,
|
||||
});
|
||||
} else {
|
||||
text = i18n('icu:calling__presenting--info--unknown');
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -51,7 +51,8 @@ const createProps = (): PropsType => ({
|
|||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+O/wHwAEhgJAyqFnAgAAAABJRU5ErkJggg==',
|
||||
},
|
||||
],
|
||||
setPresenting: action('set-presenting'),
|
||||
selectPresentingSource: action('select-presenting-source'),
|
||||
cancelPresenting: action('cancel-presenting'),
|
||||
});
|
||||
|
||||
export default {
|
||||
|
|
|
@ -9,11 +9,13 @@ import type { LocalizerType } from '../types/Util';
|
|||
import { Modal } from './Modal';
|
||||
import type { PresentedSource, PresentableSource } from '../types/Calling';
|
||||
import { Theme } from '../util/theme';
|
||||
import { strictAssert } from '../util/assert';
|
||||
|
||||
export type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
presentingSourcesAvailable: Array<PresentableSource>;
|
||||
setPresenting: (_?: PresentedSource) => void;
|
||||
presentingSourcesAvailable: ReadonlyArray<PresentableSource>;
|
||||
selectPresentingSource: (id: string) => void;
|
||||
cancelPresenting: () => void;
|
||||
};
|
||||
|
||||
function Source({
|
||||
|
@ -67,7 +69,8 @@ function Source({
|
|||
export function CallingSelectPresentingSourcesModal({
|
||||
i18n,
|
||||
presentingSourcesAvailable,
|
||||
setPresenting,
|
||||
selectPresentingSource,
|
||||
cancelPresenting,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const [sourceToPresent, setSourceToPresent] = useState<
|
||||
PresentedSource | undefined
|
||||
|
@ -84,12 +87,15 @@ export function CallingSelectPresentingSourcesModal({
|
|||
|
||||
const footer = (
|
||||
<>
|
||||
<Button onClick={() => setPresenting()} variant={ButtonVariant.Secondary}>
|
||||
<Button onClick={cancelPresenting} variant={ButtonVariant.Secondary}>
|
||||
{i18n('icu:cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!sourceToPresent}
|
||||
onClick={() => setPresenting(sourceToPresent)}
|
||||
onClick={() => {
|
||||
strictAssert(sourceToPresent, 'No source to present');
|
||||
selectPresentingSource(sourceToPresent.id);
|
||||
}}
|
||||
>
|
||||
{i18n('icu:calling__SelectPresentingSourcesModal--confirm')}
|
||||
</Button>
|
||||
|
@ -102,9 +108,7 @@ export function CallingSelectPresentingSourcesModal({
|
|||
hasXButton
|
||||
i18n={i18n}
|
||||
moduleClassName="module-CallingSelectPresentingSourcesModal"
|
||||
onClose={() => {
|
||||
setPresenting();
|
||||
}}
|
||||
onClose={cancelPresenting}
|
||||
theme={Theme.Dark}
|
||||
title={i18n('icu:calling__SelectPresentingSourcesModal--title')}
|
||||
modalFooter={footer}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { DesktopCapturerSource } from 'electron';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import type {
|
||||
AudioDevice,
|
||||
|
@ -64,7 +63,6 @@ import type {
|
|||
AvailableIODevicesType,
|
||||
CallEndedReason,
|
||||
MediaDeviceSettings,
|
||||
PresentableSource,
|
||||
PresentedSource,
|
||||
} from '../types/Calling';
|
||||
import {
|
||||
|
@ -77,7 +75,6 @@ import {
|
|||
findBestMatchingAudioDeviceIndex,
|
||||
findBestMatchingCameraId,
|
||||
} from '../calling/findBestMatchingDevice';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { normalizeAci } from '../util/normalizeAci';
|
||||
import { isAciString } from '../util/isAciString';
|
||||
import * as Errors from '../types/errors';
|
||||
|
@ -102,7 +99,6 @@ import {
|
|||
} from '../calling/constants';
|
||||
import { callingMessageToProto } from '../util/callingMessageToProto';
|
||||
import { requestMicrophonePermissions } from '../util/requestMicrophonePermissions';
|
||||
import OS from '../util/os/osMain';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import { DataReader, DataWriter } from '../sql/Client';
|
||||
import {
|
||||
|
@ -192,6 +188,7 @@ type CallingReduxInterface = Pick<
|
|||
CallingReduxActionsType,
|
||||
| 'callStateChange'
|
||||
| 'cancelIncomingGroupCallRing'
|
||||
| 'cancelPresenting'
|
||||
| 'groupCallAudioLevelsChange'
|
||||
| 'groupCallEnded'
|
||||
| 'groupCallRaisedHandsChange'
|
||||
|
@ -204,7 +201,6 @@ type CallingReduxInterface = Pick<
|
|||
| 'refreshIODevices'
|
||||
| 'remoteSharingScreenChange'
|
||||
| 'remoteVideoChange'
|
||||
| 'setPresenting'
|
||||
| 'startCallingLobby'
|
||||
| 'startCallLinkLobby'
|
||||
| 'startCallLinkLobbyByRoomId'
|
||||
|
@ -213,9 +209,13 @@ type CallingReduxInterface = Pick<
|
|||
areAnyCallsActiveOrRinging(): boolean;
|
||||
};
|
||||
|
||||
function isScreenSource(source: PresentedSource): boolean {
|
||||
return source.id.startsWith('screen');
|
||||
}
|
||||
export type SetPresentingOptionsType = Readonly<{
|
||||
conversationId: string;
|
||||
hasLocalVideo: boolean;
|
||||
mediaStream?: MediaStream;
|
||||
source?: PresentedSource;
|
||||
callLinkRootKey?: string;
|
||||
}>;
|
||||
|
||||
function truncateForLogging(name: string | undefined): string | undefined {
|
||||
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({
|
||||
offer,
|
||||
answer,
|
||||
|
@ -416,7 +393,7 @@ export class CallingClass {
|
|||
});
|
||||
|
||||
ipcRenderer.on('stop-screen-share', () => {
|
||||
reduxInterface.setPresenting();
|
||||
reduxInterface.cancelPresenting();
|
||||
});
|
||||
ipcRenderer.on(
|
||||
'calling:set-rtc-stats-interval',
|
||||
|
@ -2046,51 +2023,13 @@ export class CallingClass {
|
|||
}
|
||||
}
|
||||
|
||||
async getPresentingSources(): Promise<Array<PresentableSource>> {
|
||||
// There's a Linux Wayland Electron bug where requesting desktopCapturer.
|
||||
// getSources() with types as ['screen', 'window'] (the default) pops 2
|
||||
// OS permissions dialogs in an unusable state (Dialog 1 for Share Window
|
||||
// is the foreground and ignores input; Dialog 2 for Share Screen is background
|
||||
// and requires input. As a workaround, request both sources sequentially.
|
||||
// https://github.com/signalapp/Signal-Desktop/issues/5350#issuecomment-1688614149
|
||||
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> {
|
||||
async setPresenting({
|
||||
conversationId,
|
||||
hasLocalVideo,
|
||||
mediaStream,
|
||||
source,
|
||||
callLinkRootKey,
|
||||
}: SetPresentingOptionsType): Promise<void> {
|
||||
const call = getOwn(this.callsLookup, conversationId);
|
||||
if (!call) {
|
||||
log.warn('Trying to set presenting for a non-existent call');
|
||||
|
@ -2098,7 +2037,8 @@ export class CallingClass {
|
|||
}
|
||||
|
||||
this.videoCapturer.disable();
|
||||
if (source) {
|
||||
const isPresenting = mediaStream != null;
|
||||
if (isPresenting) {
|
||||
this.hadLocalVideoBeforePresenting = hasLocalVideo;
|
||||
drop(
|
||||
this.enableCaptureAndSend(call, {
|
||||
|
@ -2106,7 +2046,7 @@ export class CallingClass {
|
|||
maxFramerate: 5,
|
||||
maxHeight: 1800,
|
||||
maxWidth: 2880,
|
||||
screenShareSourceId: source.id,
|
||||
mediaStream,
|
||||
})
|
||||
);
|
||||
this.setOutgoingVideo(conversationId, true);
|
||||
|
@ -2118,11 +2058,10 @@ export class CallingClass {
|
|||
this.hadLocalVideoBeforePresenting = undefined;
|
||||
}
|
||||
|
||||
const isPresenting = Boolean(source);
|
||||
this.setOutgoingVideoIsScreenShare(call, isPresenting);
|
||||
|
||||
if (source) {
|
||||
ipcRenderer.send('show-screen-share', source.name);
|
||||
if (isPresenting) {
|
||||
ipcRenderer.send('show-screen-share', source?.name);
|
||||
|
||||
let url: string;
|
||||
let absolutePath: string | undefined;
|
||||
|
|
|
@ -218,7 +218,7 @@ class NotificationService extends EventEmitter {
|
|||
isVideoCall: true,
|
||||
});
|
||||
} else if (type === NotificationType.IsPresenting) {
|
||||
window.reduxActions?.calling?.setPresenting();
|
||||
window.reduxActions?.calling?.cancelPresenting();
|
||||
} else if (type === NotificationType.IncomingCall) {
|
||||
window.IPC.showWindow();
|
||||
} else {
|
||||
|
|
|
@ -19,6 +19,10 @@ import { getIntl, getPlatform } from '../selectors/user';
|
|||
import { isConversationTooBigToRing } from '../../conversations/isConversationTooBigToRing';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { drop } from '../../util/drop';
|
||||
import {
|
||||
DesktopCapturer,
|
||||
type DesktopCapturerBaton,
|
||||
} from '../../util/desktopCapturer';
|
||||
import { calling } from '../../services/calling';
|
||||
import { truncateAudioLevel } from '../../calling/truncateAudioLevel';
|
||||
import type { StateType as RootStateType } from '../reducer';
|
||||
|
@ -179,7 +183,8 @@ export type ActiveCallStateType = {
|
|||
outgoingRing: boolean;
|
||||
pip: boolean;
|
||||
presentingSource?: PresentedSource;
|
||||
presentingSourcesAvailable?: Array<PresentableSource>;
|
||||
presentingSourcesAvailable?: ReadonlyArray<PresentableSource>;
|
||||
capturerBaton?: DesktopCapturerBaton;
|
||||
settingsDialogOpen: boolean;
|
||||
showNeedsScreenRecordingPermissionsWarning?: 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 REMOVE_CLIENT = 'calling/REMOVE_CLIENT';
|
||||
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 SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_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 SelectPresentingSourceActionType = ReadonlyDeep<{
|
||||
type: 'calling/SELECT_PRESENTING_SOURCE';
|
||||
payload: string;
|
||||
}>;
|
||||
|
||||
type SetLocalAudioActionType = ReadonlyDeep<{
|
||||
type: 'calling/SET_LOCAL_AUDIO_FULFILLED';
|
||||
payload: SetLocalAudioType;
|
||||
|
@ -881,11 +892,13 @@ type SetPresentingFulfilledActionType = ReadonlyDeep<{
|
|||
payload?: PresentedSource;
|
||||
}>;
|
||||
|
||||
// eslint-disable-next-line local-rules/type-alias-readonlydeep
|
||||
type SetPresentingSourcesActionType = {
|
||||
type SetPresentingSourcesActionType = ReadonlyDeep<{
|
||||
type: 'calling/SET_PRESENTING_SOURCES';
|
||||
payload: Array<PresentableSource>;
|
||||
};
|
||||
payload: {
|
||||
presentableSources: ReadonlyArray<PresentableSource>;
|
||||
capturerBaton: DesktopCapturerBaton;
|
||||
};
|
||||
}>;
|
||||
|
||||
type SetOutgoingRingActionType = ReadonlyDeep<{
|
||||
type: 'calling/SET_OUTGOING_RING';
|
||||
|
@ -962,6 +975,7 @@ export type CallingActionType =
|
|||
| RemoveClientActionType
|
||||
| ReturnToActiveCallActionType
|
||||
| SendGroupCallReactionActionType
|
||||
| SelectPresentingSourceActionType
|
||||
| SetLocalAudioActionType
|
||||
| SetLocalVideoFulfilledActionType
|
||||
| SetPresentingSourcesActionType
|
||||
|
@ -1275,6 +1289,8 @@ function declineCall(
|
|||
};
|
||||
}
|
||||
|
||||
const globalCapturers = new WeakMap<DesktopCapturerBaton, DesktopCapturer>();
|
||||
|
||||
function getPresentingSources(): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
|
@ -1283,6 +1299,8 @@ function getPresentingSources(): ThunkAction<
|
|||
| ToggleNeedsScreenRecordingPermissionsActionType
|
||||
> {
|
||||
return async (dispatch, getState) => {
|
||||
const i18n = getIntl(getState());
|
||||
|
||||
// We check if the user has permissions first before calling desktopCapturer
|
||||
// Next we call getPresentingSources so that one gets the prompt for permissions,
|
||||
// if necessary.
|
||||
|
@ -1294,19 +1312,51 @@ function getPresentingSources(): ThunkAction<
|
|||
const needsPermission =
|
||||
platform === 'darwin' && !hasScreenCapturePermission();
|
||||
|
||||
const sources = await calling.getPresentingSources();
|
||||
const capturer = new DesktopCapturer({
|
||||
i18n,
|
||||
onPresentableSources(presentableSources) {
|
||||
if (needsPermission) {
|
||||
// Abort
|
||||
capturer.selectSource(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: SET_PRESENTING_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,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: SET_PRESENTING_SOURCES,
|
||||
payload: sources,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1774,6 +1824,13 @@ function returnToActiveCall(): ReturnToActiveCallActionType {
|
|||
};
|
||||
}
|
||||
|
||||
function selectPresentingSource(id: string): SelectPresentingSourceActionType {
|
||||
return {
|
||||
type: SELECT_PRESENTING_SOURCE,
|
||||
payload: id,
|
||||
};
|
||||
}
|
||||
|
||||
function setIsCallActive(
|
||||
isCallActive: boolean
|
||||
): ThunkAction<void, RootStateType, unknown, never> {
|
||||
|
@ -1871,8 +1928,9 @@ function setGroupCallVideoRequest(
|
|||
};
|
||||
}
|
||||
|
||||
function setPresenting(
|
||||
sourceToPresent?: PresentedSource
|
||||
function _setPresenting(
|
||||
sourceToPresent?: PresentedSource,
|
||||
mediaStream?: MediaStream
|
||||
): ThunkAction<void, RootStateType, unknown, SetPresentingFulfilledActionType> {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
@ -1898,24 +1956,34 @@ function setPresenting(
|
|||
rootKey = callLink?.rootKey;
|
||||
}
|
||||
|
||||
await calling.setPresenting(
|
||||
activeCall.conversationId,
|
||||
activeCallState.hasLocalVideo,
|
||||
sourceToPresent,
|
||||
rootKey
|
||||
);
|
||||
await calling.setPresenting({
|
||||
conversationId: activeCall.conversationId,
|
||||
hasLocalVideo: activeCallState.hasLocalVideo,
|
||||
mediaStream,
|
||||
source: sourceToPresent,
|
||||
callLinkRootKey: rootKey,
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: SET_PRESENTING,
|
||||
payload: sourceToPresent,
|
||||
});
|
||||
|
||||
if (sourceToPresent) {
|
||||
if (mediaStream) {
|
||||
await callingTones.someonePresenting();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function cancelPresenting(): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
SetPresentingFulfilledActionType
|
||||
> {
|
||||
return _setPresenting(undefined, undefined);
|
||||
}
|
||||
|
||||
function setOutgoingRing(payload: boolean): SetOutgoingRingActionType {
|
||||
return {
|
||||
type: SET_OUTGOING_RING,
|
||||
|
@ -2557,6 +2625,7 @@ export const actions = {
|
|||
callStateChange,
|
||||
cancelCall,
|
||||
cancelIncomingGroupCallRing,
|
||||
cancelPresenting,
|
||||
changeCallView,
|
||||
changeIODevice,
|
||||
closeNeedPermissionScreen,
|
||||
|
@ -2592,13 +2661,13 @@ export const actions = {
|
|||
returnToActiveCall,
|
||||
sendGroupCallRaiseHand,
|
||||
sendGroupCallReaction,
|
||||
selectPresentingSource,
|
||||
setGroupCallVideoRequest,
|
||||
setIsCallActive,
|
||||
setLocalAudio,
|
||||
setLocalPreview,
|
||||
setLocalVideo,
|
||||
setOutgoingRing,
|
||||
setPresenting,
|
||||
setRendererCanvas,
|
||||
startCall,
|
||||
startCallLinkLobby,
|
||||
|
@ -2612,6 +2681,9 @@ export const actions = {
|
|||
toggleSettings,
|
||||
updateCallLinkName,
|
||||
updateCallLinkRestrictions,
|
||||
|
||||
// Exported only for tests
|
||||
_setPresenting,
|
||||
};
|
||||
|
||||
export const useCallingActions = (): BoundActionCreatorsMapObject<
|
||||
|
@ -3677,12 +3749,20 @@ export function reducer(
|
|||
return state;
|
||||
}
|
||||
|
||||
// Cancel source selection if running
|
||||
const { capturerBaton } = activeCallState;
|
||||
if (capturerBaton != null) {
|
||||
const capturer = globalCapturers.get(capturerBaton);
|
||||
capturer?.selectSource(undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
activeCallState: {
|
||||
...activeCallState,
|
||||
presentingSource: action.payload,
|
||||
presentingSourcesAvailable: undefined,
|
||||
capturerBaton: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -3698,7 +3778,43 @@ export function reducer(
|
|||
...state,
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -441,15 +441,16 @@ export const SmartCallManager = memo(function SmartCallManager() {
|
|||
openSystemPreferencesAction,
|
||||
removeClient,
|
||||
blockClient,
|
||||
cancelPresenting,
|
||||
sendGroupCallRaiseHand,
|
||||
sendGroupCallReaction,
|
||||
selectPresentingSource,
|
||||
setGroupCallVideoRequest,
|
||||
setIsCallActive,
|
||||
setLocalAudio,
|
||||
setLocalVideo,
|
||||
setLocalPreview,
|
||||
setOutgoingRing,
|
||||
setPresenting,
|
||||
setRendererCanvas,
|
||||
switchToPresentationView,
|
||||
switchFromPresentationView,
|
||||
|
@ -477,6 +478,7 @@ export const SmartCallManager = memo(function SmartCallManager() {
|
|||
bounceAppIconStop={bounceAppIconStop}
|
||||
callLink={callLink}
|
||||
cancelCall={cancelCall}
|
||||
cancelPresenting={cancelPresenting}
|
||||
changeCallView={changeCallView}
|
||||
closeNeedPermissionScreen={closeNeedPermissionScreen}
|
||||
declineCall={declineCall}
|
||||
|
@ -503,13 +505,13 @@ export const SmartCallManager = memo(function SmartCallManager() {
|
|||
renderReactionPicker={renderReactionPicker}
|
||||
sendGroupCallRaiseHand={sendGroupCallRaiseHand}
|
||||
sendGroupCallReaction={sendGroupCallReaction}
|
||||
selectPresentingSource={selectPresentingSource}
|
||||
setGroupCallVideoRequest={setGroupCallVideoRequest}
|
||||
setIsCallActive={setIsCallActive}
|
||||
setLocalAudio={setLocalAudio}
|
||||
setLocalPreview={setLocalPreview}
|
||||
setLocalVideo={setLocalVideo}
|
||||
setOutgoingRing={setOutgoingRing}
|
||||
setPresenting={setPresenting}
|
||||
setRendererCanvas={setRendererCanvas}
|
||||
showContactModal={showContactModal}
|
||||
showShareCallLinkViaSignal={showShareCallLinkViaSignal}
|
||||
|
|
|
@ -240,46 +240,6 @@ describe('calling duck', () => {
|
|||
});
|
||||
|
||||
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', () => {
|
||||
it("updates whether someone's screen is being shared", () => {
|
||||
const { remoteSharingScreenChange } = actions;
|
||||
|
@ -308,7 +268,7 @@ describe('calling duck', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('setPresenting', () => {
|
||||
describe('_setPresenting', () => {
|
||||
beforeEach(function (this: Mocha.Context) {
|
||||
this.callingServiceSetPresenting = this.sandbox.stub(
|
||||
callingService,
|
||||
|
@ -316,8 +276,8 @@ describe('calling duck', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('calls setPresenting on the calling service', async function (this: Mocha.Context) {
|
||||
const { setPresenting } = actions;
|
||||
it('calls _setPresenting on the calling service', async function (this: Mocha.Context) {
|
||||
const { _setPresenting } = actions;
|
||||
const dispatch = sinon.spy();
|
||||
const presentedSource = {
|
||||
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.calledWith(
|
||||
this.callingServiceSetPresenting,
|
||||
'fake-group-call-conversation-id',
|
||||
false,
|
||||
presentedSource
|
||||
);
|
||||
sinon.assert.calledWith(this.callingServiceSetPresenting, {
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
hasLocalVideo: false,
|
||||
mediaStream: undefined,
|
||||
source: presentedSource,
|
||||
callLinkRootKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches SET_PRESENTING', async () => {
|
||||
const { setPresenting } = actions;
|
||||
const { _setPresenting } = actions;
|
||||
const dispatch = sinon.spy();
|
||||
const presentedSource = {
|
||||
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.calledWith(dispatch, {
|
||||
|
@ -366,7 +327,7 @@ describe('calling duck', () => {
|
|||
|
||||
it('turns off presenting when no value is passed in', async () => {
|
||||
const dispatch = sinon.spy();
|
||||
const { setPresenting } = actions;
|
||||
const { _setPresenting } = actions;
|
||||
const presentedSource = {
|
||||
id: 'window:786',
|
||||
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];
|
||||
|
||||
|
@ -401,7 +362,7 @@ describe('calling duck', () => {
|
|||
|
||||
it('sets the presenting value when one is passed in', async () => {
|
||||
const dispatch = sinon.spy();
|
||||
const { setPresenting } = actions;
|
||||
const { _setPresenting } = actions;
|
||||
|
||||
const getState = (): RootStateType => ({
|
||||
...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];
|
||||
|
||||
|
|
|
@ -89,7 +89,7 @@ describe('renderWindowsToast', () => {
|
|||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
|
|
@ -21,13 +21,15 @@ describe('updateDefaultSession', () => {
|
|||
it('sets the spellcheck URL', () => {
|
||||
const sesh = session.fromPartition(uuid());
|
||||
const stub = sandbox.stub(sesh, 'setSpellCheckerDictionaryDownloadURL');
|
||||
const getLogger = sandbox.stub();
|
||||
|
||||
updateDefaultSession(sesh);
|
||||
updateDefaultSession(sesh, getLogger);
|
||||
|
||||
sinon.assert.calledOnce(stub);
|
||||
sinon.assert.calledWith(
|
||||
stub,
|
||||
`https://updates.signal.org/desktop/hunspell_dictionaries/${process.versions.electron}/`
|
||||
);
|
||||
sinon.assert.notCalled(getLogger);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -205,13 +205,13 @@ describe('signalRoutes', () => {
|
|||
check('sgnl://show-window', result);
|
||||
});
|
||||
|
||||
it('setIsPresenting', () => {
|
||||
it('cancelPresenting', () => {
|
||||
const result: ParsedSignalRoute = {
|
||||
key: 'setIsPresenting',
|
||||
key: 'cancelPresenting',
|
||||
args: {},
|
||||
};
|
||||
const check = createCheck({ isRoute: true, hasWebUrl: false });
|
||||
check('sgnl://set-is-presenting/', result);
|
||||
check('sgnl://set-is-presenting', result);
|
||||
check('sgnl://cancel-presenting/', result);
|
||||
check('sgnl://cancel-presenting', result);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReadonlyDeep } from 'type-fest';
|
||||
import type { AudioDevice, Reaction as CallReaction } from '@signalapp/ringrtc';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { AciString, ServiceIdString } from './ServiceId';
|
||||
|
@ -19,13 +20,13 @@ export enum CallViewMode {
|
|||
Presentation = 'Presentation',
|
||||
}
|
||||
|
||||
export type PresentableSource = {
|
||||
export type PresentableSource = ReadonlyDeep<{
|
||||
appIcon?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
isScreen: boolean;
|
||||
thumbnail: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type PresentedSource = {
|
||||
id: string;
|
||||
|
@ -54,7 +55,7 @@ export type ActiveCallBaseType = {
|
|||
outgoingRing: boolean;
|
||||
pip: boolean;
|
||||
presentingSource?: PresentedSource;
|
||||
presentingSourcesAvailable?: Array<PresentableSource>;
|
||||
presentingSourcesAvailable?: ReadonlyArray<PresentableSource>;
|
||||
settingsDialogOpen: boolean;
|
||||
showNeedsScreenRecordingPermissionsWarning?: boolean;
|
||||
showParticipantsList: boolean;
|
||||
|
|
229
ts/util/desktopCapturer.ts
Normal file
229
ts/util/desktopCapturer.ts
Normal 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');
|
||||
}
|
|
@ -49,7 +49,7 @@ type AllHostnamePatterns =
|
|||
| 'show-conversation'
|
||||
| 'start-call-lobby'
|
||||
| 'show-window'
|
||||
| 'set-is-presenting'
|
||||
| 'cancel-presenting'
|
||||
| ':captchaId(.+)'
|
||||
| '';
|
||||
|
||||
|
@ -535,18 +535,18 @@ export const showWindowRoute = _route('showWindow', {
|
|||
* Set is presenting
|
||||
* @example
|
||||
* ```ts
|
||||
* setIsPresentingRoute.toAppUrl({})
|
||||
* // URL { "sgnl://set-is-presenting" }
|
||||
* cancelPresentingRoute.toAppUrl({})
|
||||
* // URL { "sgnl://cancel-presenting" }
|
||||
* ```
|
||||
*/
|
||||
export const setIsPresentingRoute = _route('setIsPresenting', {
|
||||
patterns: [_pattern('sgnl:', 'set-is-presenting', '{/}?', {})],
|
||||
export const cancelPresentingRoute = _route('cancelPresenting', {
|
||||
patterns: [_pattern('sgnl:', 'cancel-presenting', '{/}?', {})],
|
||||
schema: z.object({}),
|
||||
parse() {
|
||||
return {};
|
||||
},
|
||||
toAppUrl() {
|
||||
return new URL('sgnl://set-is-presenting');
|
||||
return new URL('sgnl://cancel-presenting');
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -565,7 +565,7 @@ const _allSignalRoutes = [
|
|||
showConversationRoute,
|
||||
startCallLobbyRoute,
|
||||
showWindowRoute,
|
||||
setIsPresentingRoute,
|
||||
cancelPresentingRoute,
|
||||
] as const;
|
||||
|
||||
strictAssert(
|
||||
|
|
2
ts/window.d.ts
vendored
2
ts/window.d.ts
vendored
|
@ -121,7 +121,7 @@ type PermissionsWindowPropsType = {
|
|||
|
||||
type ScreenShareWindowPropsType = {
|
||||
onStopSharing: () => void;
|
||||
presentedSourceName: string;
|
||||
presentedSourceName: string | undefined;
|
||||
getStatus: () => ScreenShareStatus;
|
||||
setRenderCallback: (cb: () => void) => void;
|
||||
};
|
||||
|
|
|
@ -360,8 +360,8 @@ ipc.on('show-window', () => {
|
|||
window.IPC.showWindow();
|
||||
});
|
||||
|
||||
ipc.on('set-is-presenting', () => {
|
||||
window.reduxActions?.calling?.setPresenting();
|
||||
ipc.on('cancel-presenting', () => {
|
||||
window.reduxActions?.calling?.cancelPresenting();
|
||||
});
|
||||
|
||||
ipc.on(
|
||||
|
|
Loading…
Reference in a new issue