From 661099dd2823734fbfb806a7698e6dc526fec8e4 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Wed, 12 Apr 2023 07:50:34 -0400 Subject: [PATCH] [MM-51961] Migrate calls widget to singleton (#2667) * Migrate callsWidgetWindow to singleton * REVERT ME: removed references to ServerManager --- src/main/app/initialize.test.js | 3 + src/main/app/initialize.ts | 14 +- src/main/views/webContentEvents.test.js | 1 + src/main/views/webContentEvents.ts | 5 +- src/main/windows/callsWidgetWindow.test.js | 1384 ++++++++++++-------- src/main/windows/callsWidgetWindow.ts | 535 +++++--- src/main/windows/windowManager.test.js | 496 +------ src/main/windows/windowManager.ts | 183 +-- 8 files changed, 1249 insertions(+), 1372 deletions(-) diff --git a/src/main/app/initialize.test.js b/src/main/app/initialize.test.js index 2578ff13..2e2010bd 100644 --- a/src/main/app/initialize.test.js +++ b/src/main/app/initialize.test.js @@ -160,6 +160,9 @@ jest.mock('main/UserActivityMonitor', () => ({ on: jest.fn(), startMonitoring: jest.fn(), })); +jest.mock('main/windows/callsWidgetWindow', () => ({ + isCallsWidget: jest.fn(), +})); jest.mock('main/windows/windowManager', () => ({ showMainWindow: jest.fn(), sendToRenderer: jest.fn(), diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index c150957b..82b95d9b 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -62,6 +62,7 @@ import TrustedOriginsStore from 'main/trustedOrigins'; import {refreshTrayImages, setupTray} from 'main/tray/tray'; import UserActivityMonitor from 'main/UserActivityMonitor'; import ViewManager from 'main/views/viewManager'; +import CallsWidgetWindow from 'main/windows/callsWidgetWindow'; import WindowManager from 'main/windows/windowManager'; import MainWindow from 'main/windows/mainWindow'; @@ -435,16 +436,9 @@ function initializeAfterAppReady() { return; } - const callsWidgetWindow = WindowManager.callsWidgetWindow; - if (callsWidgetWindow) { - if (webContents.id === callsWidgetWindow.win.webContents.id) { - callback(true); - return; - } - if (callsWidgetWindow.popOut && webContents.id === callsWidgetWindow.popOut.webContents.id) { - callback(true); - return; - } + if (CallsWidgetWindow.isCallsWidget(webContents.id)) { + callback(true); + return; } const requestingURL = webContents.getURL(); diff --git a/src/main/views/webContentEvents.test.js b/src/main/views/webContentEvents.test.js index 375e843d..454c5100 100644 --- a/src/main/views/webContentEvents.test.js +++ b/src/main/views/webContentEvents.test.js @@ -25,6 +25,7 @@ jest.mock('electron', () => ({ jest.mock('main/contextMenu', () => jest.fn()); jest.mock('../allowProtocolDialog', () => ({})); +jest.mock('main/windows/callsWidgetWindow', () => ({})); jest.mock('main/views/viewManager', () => ({ getViewByWebContentsId: jest.fn(), getViewByURL: jest.fn(), diff --git a/src/main/views/webContentEvents.ts b/src/main/views/webContentEvents.ts index ce5f4c75..b8b6fd2a 100644 --- a/src/main/views/webContentEvents.ts +++ b/src/main/views/webContentEvents.ts @@ -10,7 +10,8 @@ import urlUtils from 'common/utils/url'; import {flushCookiesStore} from 'main/app/utils'; import ContextMenu from 'main/contextMenu'; -import WindowManager from '../windows/windowManager'; +import CallsWidgetWindow from 'main/windows/callsWidgetWindow'; +import WindowManager from 'main/windows/windowManager'; import {protocols} from '../../../electron-builder.json'; @@ -84,7 +85,7 @@ export class WebContentsEventManager { return; } - const callID = WindowManager.callsWidgetWindow?.getCallID(); + const callID = CallsWidgetWindow.callID; if (serverURL && callID && urlUtils.isCallsPopOutURL(serverURL, parsedURL, callID)) { return; } diff --git a/src/main/windows/callsWidgetWindow.test.js b/src/main/windows/callsWidgetWindow.test.js index 42c28747..b9475246 100644 --- a/src/main/windows/callsWidgetWindow.test.js +++ b/src/main/windows/callsWidgetWindow.test.js @@ -1,8 +1,9 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {EventEmitter} from 'events'; -import {BrowserWindow} from 'electron'; +/* eslint-disable max-lines */ + +import {BrowserWindow, desktopCapturer, systemPreferences} from 'electron'; import {CALLS_WIDGET_SHARE_SCREEN, CALLS_JOINED_CALL} from 'common/communication'; import { @@ -10,10 +11,17 @@ import { MINIMUM_CALLS_WIDGET_HEIGHT, CALLS_PLUGIN_ID, } from 'common/utils/constants'; +import urlUtils from 'common/utils/url'; +import MainWindow from 'main/windows/mainWindow'; +import ViewManager from 'main/views/viewManager'; +import WindowManager from 'main/windows/windowManager'; +import { + resetScreensharePermissionsMacOS, + openScreensharePermissionsSettingsMacOS, +} from 'main/utils'; +import WebContentsEventManager from 'main/views/webContentEvents'; -import WebContentsEventManager from '../views/webContentEvents'; - -import CallsWidgetWindow from './callsWidgetWindow'; +import {CallsWidgetWindow} from './callsWidgetWindow'; jest.mock('electron', () => ({ app: { @@ -25,103 +33,504 @@ jest.mock('electron', () => ({ off: jest.fn(), handle: jest.fn(), }, + desktopCapturer: { + getSources: jest.fn(), + }, + systemPreferences: { + getUserDefault: jest.fn(), + getMediaAccessStatus: jest.fn(() => 'granted'), + }, })); jest.mock('../views/webContentEvents', () => ({ addWebContentsEventListeners: jest.fn(), })); -jest.mock('common/utils/url', () => { - const originalModule = jest.requireActual('common/utils/url'); - return { - ...originalModule, - ...originalModule.default, - }; -}); +jest.mock('common/utils/url', () => ({ + isCallsPopOutURL: jest.fn(), + getFormattedPathName: jest.fn(), + parseURL: jest.fn(), +})); +jest.mock('main/windows/mainWindow', () => ({ + get: jest.fn(), + focus: jest.fn(), +})); +jest.mock('main/windows/windowManager', () => ({ + switchServer: jest.fn(), +})); +jest.mock('main/views/viewManager', () => ({ + getView: jest.fn(), +})); +jest.mock('../utils', () => ({ + openScreensharePermissionsSettingsMacOS: jest.fn(), + resetScreensharePermissionsMacOS: jest.fn(), + getLocalPreload: jest.fn(), +})); describe('main/windows/callsWidgetWindow', () => { - describe('create CallsWidgetWindow', () => { - const widgetConfig = { - callID: 'test-call-id', - title: '', - channelURL: '/team/channel_id', + describe('onShow', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.win = { + focus: jest.fn(), + setVisibleOnAllWorkspaces: jest.fn(), + setAlwaysOnTop: jest.fn(), + getBounds: jest.fn(), + setBounds: jest.fn(), + setMenuBarVisibility: jest.fn(), + webContents: { + openDevTools: jest.fn(), + }, }; - const mainWindow = { getBounds: jest.fn(), }; - const mainView = { - sendToRenderer: jest.fn(), - webContentsId: 'mainViewID', - serverInfo: { - server: { - name: 'test-server-name', - url: new URL('http://localhost:8065'), - }, - }, - getWebContents: () => ({ - id: 'mainViewID', - }), - }; + beforeEach(() => { + mainWindow.getBounds.mockReturnValue({ + x: 0, + y: 0, + width: 1280, + height: 720, + }); + callsWidgetWindow.win.getBounds.mockReturnValue({ + x: 0, + y: 0, + width: MINIMUM_CALLS_WIDGET_WIDTH, + height: MINIMUM_CALLS_WIDGET_HEIGHT, + }); + MainWindow.get.mockReturnValue(mainWindow); + }); - const baseWindow = new EventEmitter(); - baseWindow.loadURL = jest.fn(); - baseWindow.focus = jest.fn(); - baseWindow.setVisibleOnAllWorkspaces = jest.fn(); - baseWindow.setAlwaysOnTop = jest.fn(); - baseWindow.setBackgroundColor = jest.fn(); - baseWindow.setMenuBarVisibility = jest.fn(); - baseWindow.webContents = { - setWindowOpenHandler: jest.fn(), + it('should call certain functions upon showing the window', () => { + callsWidgetWindow.onShow(); + expect(callsWidgetWindow.win.setAlwaysOnTop).toHaveBeenCalled(); + expect(callsWidgetWindow.win.setBounds).toHaveBeenCalledWith({ + x: 12, + y: 618, + width: MINIMUM_CALLS_WIDGET_WIDTH, + height: MINIMUM_CALLS_WIDGET_HEIGHT, + }); + }); + + it('should open dev tools when environment variable is set', async () => { + const originalEnv = process.env; + Object.defineProperty(process, 'env', { + value: { + MM_DEBUG_CALLS_WIDGET: 'true', + }, + }); + callsWidgetWindow.onShow(); + expect(callsWidgetWindow.win.webContents.openDevTools).toHaveBeenCalled(); + Object.defineProperty(process, 'env', { + value: originalEnv, + }); + }); + }); + + describe('close', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.win = { on: jest.fn(), + close: jest.fn(), + isDestroyed: jest.fn(), }; - baseWindow.isDestroyed = jest.fn(() => false); beforeEach(() => { - baseWindow.setBounds = jest.fn(); - - mainWindow.getBounds.mockImplementation(() => { - return { - x: 0, - y: 0, - width: 1280, - height: 720, - }; + let closedListener; + callsWidgetWindow.win.on.mockImplementation((event, listener) => { + closedListener = listener; }); - - baseWindow.getBounds = jest.fn(() => { - return { - x: 0, - y: 0, - width: MINIMUM_CALLS_WIDGET_WIDTH, - height: MINIMUM_CALLS_WIDGET_HEIGHT, - }; - }); - - baseWindow.show = jest.fn(() => { - baseWindow.emit('show'); - }); - - baseWindow.close = jest.fn(() => { - baseWindow.emit('closed'); - }); - - baseWindow.loadURL.mockImplementation(() => ({ - catch: jest.fn(), - })); - BrowserWindow.mockImplementation(() => baseWindow); + callsWidgetWindow.win.close.mockImplementation(() => closedListener()); }); afterEach(() => { - jest.resetAllMocks(); - baseWindow.removeAllListeners('show'); - baseWindow.removeAllListeners('ready-to-show'); + jest.clearAllMocks(); }); - it('verify initial configuration', () => { - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - expect(widgetWindow).toBeDefined(); + it('should close window', async () => { + await callsWidgetWindow.close(); + expect(callsWidgetWindow.win.close).toHaveBeenCalled(); + }); + + it('should not close if already destroyed', async () => { + callsWidgetWindow.win.isDestroyed.mockReturnValue(true); + await callsWidgetWindow.close(); + expect(callsWidgetWindow.win.close).not.toHaveBeenCalled(); + }); + }); + + describe('handleResize', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.win = { + getBounds: jest.fn(), + webContents: { + id: 'windowID', + getZoomFactor: jest.fn(), + }, + }; + callsWidgetWindow.setBounds = jest.fn(); + const bounds = { + x: 12, + y: 720, + width: MINIMUM_CALLS_WIDGET_WIDTH, + height: MINIMUM_CALLS_WIDGET_HEIGHT, + }; + + beforeEach(() => { + callsWidgetWindow.win.getBounds.mockReturnValue(bounds); + callsWidgetWindow.win.webContents.getZoomFactor.mockReturnValue(1.0); + }); + + it('should resize correctly', () => { + callsWidgetWindow.handleResize({ + sender: {id: 'windowID'}, + }, 'widget', { + element: 'calls-widget', + width: 300, + height: 100, + }); + expect(callsWidgetWindow.setBounds).toHaveBeenCalledWith({ + x: 12, + y: 720 - (100 - MINIMUM_CALLS_WIDGET_HEIGHT), + width: 300, + height: 100, + }); + }); + + it('should resize correctly at 2x zoom', () => { + callsWidgetWindow.win.webContents.getZoomFactor.mockReturnValue(2.0); + callsWidgetWindow.handleResize({ + sender: {id: 'windowID'}, + }, 'widget', { + element: 'calls-widget', + width: 300, + height: 100, + }); + expect(callsWidgetWindow.setBounds).toHaveBeenCalledWith({ + x: 12, + y: 720 - (200 - MINIMUM_CALLS_WIDGET_HEIGHT), + width: 600, + height: 200, + }); + }); + + it('should resize correctly at 0.5x zoom', () => { + callsWidgetWindow.win.webContents.getZoomFactor.mockReturnValue(0.5); + callsWidgetWindow.handleResize({ + sender: {id: 'windowID'}, + }, 'widget', { + element: 'calls-widget', + width: 300, + height: 100, + }); + expect(callsWidgetWindow.setBounds).toHaveBeenCalledWith({ + x: 12, + y: 720 - (50 - MINIMUM_CALLS_WIDGET_HEIGHT), + width: 150, + height: 50, + }); + }); + }); + + describe('getWidgetURL', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + + beforeEach(() => { + urlUtils.parseURL.mockImplementation((url) => new URL(url)); + urlUtils.getFormattedPathName.mockImplementation((pn) => { + return pn.endsWith('/') ? pn.toLowerCase() : `${pn.toLowerCase()}/`; + }); + callsWidgetWindow.options = { + callID: 'test-call-id', + channelURL: '/team/channel_id', + title: 'call test title #/&', + }; + callsWidgetWindow.mainView = { + tab: { + server: { + url: new URL('http://localhost:8065'), + }, + }, + }; + }); + + it('getWidgetURL', () => { + const expected = `http://localhost:8065/plugins/${CALLS_PLUGIN_ID}/standalone/widget.html?call_id=test-call-id&title=call+test+title+%23%2F%26`; + expect(callsWidgetWindow.getWidgetURL()).toBe(expected); + }); + + it('getWidgetURL - under subpath', () => { + callsWidgetWindow.mainView = { + tab: { + server: { + url: new URL('http://localhost:8065/subpath'), + }, + }, + }; + + const expected = `http://localhost:8065/subpath/plugins/${CALLS_PLUGIN_ID}/standalone/widget.html?call_id=test-call-id&title=call+test+title+%23%2F%26`; + expect(callsWidgetWindow.getWidgetURL()).toBe(expected); + }); + + it('getWidgetURL - with rootID', () => { + callsWidgetWindow.options = { + ...callsWidgetWindow.options, + rootID: 'call_thread_id', + }; + const expected = `http://localhost:8065/plugins/${CALLS_PLUGIN_ID}/standalone/widget.html?call_id=test-call-id&title=call+test+title+%23%2F%26&root_id=call_thread_id`; + expect(callsWidgetWindow.getWidgetURL()).toBe(expected); + }); + }); + + it('handleShareScreen', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.isAllowedEvent = jest.fn(); + callsWidgetWindow.win = { + webContents: { + id: 'goodID', + send: jest.fn(), + }, + }; + const message = { + callID: 'test-call-id', + }; + + callsWidgetWindow.isAllowedEvent.mockReturnValue(false); + callsWidgetWindow.handleShareScreen({ + sender: {id: 'badID'}, + }, message); + expect(callsWidgetWindow.win.webContents.send).not.toHaveBeenCalled(); + + callsWidgetWindow.isAllowedEvent.mockReturnValue(true); + callsWidgetWindow.handleShareScreen({ + sender: {id: 'goodID'}, + }, 'widget', message); + expect(callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith(CALLS_WIDGET_SHARE_SCREEN, message); + }); + + it('handleJoinedCall', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.isAllowedEvent = jest.fn(); + callsWidgetWindow.mainView = { + webContentsId: 'goodID', + sendToRenderer: jest.fn(), + }; + const message = { + callID: 'test-call-id', + }; + + callsWidgetWindow.isAllowedEvent.mockReturnValue(false); + callsWidgetWindow.handleJoinedCall({ + sender: {id: 'badID'}, + }, 'widget', message); + expect(callsWidgetWindow.mainView.sendToRenderer).not.toHaveBeenCalled(); + + callsWidgetWindow.isAllowedEvent.mockReturnValue(true); + callsWidgetWindow.handleJoinedCall({ + sender: {id: 'goodID'}, + }, 'widget', message); + expect(callsWidgetWindow.mainView.sendToRenderer).toHaveBeenCalledWith(CALLS_JOINED_CALL, message); + }); + + describe('onPopOutOpen', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + + beforeEach(() => { + callsWidgetWindow.options = {callID: 'id'}; + callsWidgetWindow.mainView = { + tab: { + server: { + url: new URL('http://localhost:8065'), + }, + }, + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + delete callsWidgetWindow.options; + delete callsWidgetWindow.mainView; + }); + + it('should deny opening if there is no call attached', () => { + delete callsWidgetWindow.options; + delete callsWidgetWindow.mainView; + expect(callsWidgetWindow.onPopOutOpen({url: 'http://localhost:8065/popouturl'})).toHaveProperty('action', 'deny'); + }); + + it('should pop out and make sure menu bar is disabled', () => { + urlUtils.isCallsPopOutURL.mockReturnValue(true); + expect(callsWidgetWindow.onPopOutOpen({url: 'http://localhost:8065/popouturl'})).toHaveProperty('action', 'allow'); + expect(callsWidgetWindow.onPopOutOpen({url: 'http://localhost:8065/popouturl'}).overrideBrowserWindowOptions).toHaveProperty('autoHideMenuBar', true); + }); + + it('should not pop out when the URL does not match the calls popout URL', () => { + urlUtils.isCallsPopOutURL.mockReturnValue(false); + expect(callsWidgetWindow.onPopOutOpen({url: 'http://localhost:8065/notpopouturl'})).toHaveProperty('action', 'deny'); + }); + }); + + describe('handlePopOutFocus', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.popOut = { + isMinimized: jest.fn(), + restore: jest.fn(), + focus: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should focus only if not minimized', () => { + callsWidgetWindow.popOut.isMinimized.mockReturnValue(false); + callsWidgetWindow.handlePopOutFocus(); + expect(callsWidgetWindow.popOut.restore).not.toBeCalled(); + expect(callsWidgetWindow.popOut.focus).toBeCalled(); + }); + + it('should focus only if not minimized', () => { + callsWidgetWindow.popOut.isMinimized.mockReturnValue(true); + callsWidgetWindow.handlePopOutFocus(); + expect(callsWidgetWindow.popOut.restore).toBeCalled(); + expect(callsWidgetWindow.popOut.focus).toBeCalled(); + }); + }); + + it('onPopOutCreate - should attach correct listeners and should prevent redirects', () => { + let redirectListener; + let closedListener; + const popOut = { + on: (event, listener) => { + closedListener = listener; + }, + webContents: { + on: (event, listener) => { + redirectListener = listener; + }, + id: 'webContentsId', + }, + }; + + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.onPopOutCreate(popOut); + expect(callsWidgetWindow.popOut).toBe(popOut); + expect(WebContentsEventManager.addWebContentsEventListeners).toHaveBeenCalledWith(popOut.webContents); + expect(redirectListener).toBeDefined(); + + const event = {preventDefault: jest.fn()}; + redirectListener(event); + expect(event.preventDefault).toHaveBeenCalled(); + + closedListener(); + expect(callsWidgetWindow.popOut).not.toBeDefined(); + }); + + it('getURL', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.win = { + webContents: { + getURL: () => 'http://localhost:8065/', + }, + }; + urlUtils.parseURL.mockImplementation((url) => new URL(url)); + expect(callsWidgetWindow.getURL().toString()).toBe('http://localhost:8065/'); + }); + + describe('isAllowedEvent', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.mainView = { + webContentsId: 'mainViewID', + }; + callsWidgetWindow.win = { + webContents: { + id: 'windowID', + }, + }; + + it('should not allow on unknown sender id', () => { + expect(callsWidgetWindow.isAllowedEvent({ + sender: { + id: 'senderID', + }, + })).toEqual(false); + }); + + it('should allow on attached browser view', () => { + expect(callsWidgetWindow.isAllowedEvent({ + sender: { + id: 'mainViewID', + }, + })).toEqual(true); + }); + + it('should allow on widget window', () => { + expect(callsWidgetWindow.isAllowedEvent({ + sender: { + id: 'windowID', + }, + })).toEqual(true); + }); + }); + + it('onNavigate', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.getWidgetURL = () => 'http://localhost:8065'; + const ev = {preventDefault: jest.fn()}; + + callsWidgetWindow.onNavigate(ev, 'http://localhost:8065'); + expect(ev.preventDefault).not.toHaveBeenCalled(); + + callsWidgetWindow.onNavigate(ev, 'http://localhost:8065/invalid/url'); + expect(ev.preventDefault).toHaveBeenCalledTimes(1); + }); + + describe('handleCreateCallsWidgetWindow', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.close = jest.fn(); + callsWidgetWindow.getWidgetURL = jest.fn(); + const view = { + name: 'server-1_tab-messaging', + serverInfo: { + server: { + url: new URL('http://server-1.com'), + }, + }, + }; + const browserWindow = { + on: jest.fn(), + once: jest.fn(), + loadURL: jest.fn().mockReturnValue(Promise.resolve()), + webContents: { + setWindowOpenHandler: jest.fn(), + on: jest.fn(), + id: 1, + openDevTools: jest.fn(), + }, + }; + + beforeEach(() => { + BrowserWindow.mockReturnValue(browserWindow); + callsWidgetWindow.close.mockReturnValue(Promise.resolve()); + ViewManager.getView.mockReturnValue(view); + }); + + afterEach(() => { + delete callsWidgetWindow.win; + delete callsWidgetWindow.mainView; + delete callsWidgetWindow.options; + + jest.resetAllMocks(); + }); + + it('should create calls widget window', async () => { + expect(callsWidgetWindow.win).toBeUndefined(); + await callsWidgetWindow.handleCreateCallsWidgetWindow('server-1_tab-messaging', {callID: 'test'}); + expect(callsWidgetWindow.win).toBeDefined(); + }); + + it('should create with correct initial configuration', async () => { + await callsWidgetWindow.handleCreateCallsWidgetWindow('server-1_tab-messaging', {callID: 'test'}); expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ width: MINIMUM_CALLS_WIDGET_WIDTH, height: MINIMUM_CALLS_WIDGET_HEIGHT, @@ -135,484 +544,405 @@ describe('main/windows/callsWidgetWindow', () => { })); }); - it('showing window', () => { - baseWindow.show = jest.fn(() => { - baseWindow.emit('show'); + it('should catch error when failing to load the URL', async () => { + const error = new Error('failed to load URL'); + const promise = Promise.reject(error); + BrowserWindow.mockReturnValue({ + ...browserWindow, + loadURL: jest.fn().mockReturnValue(promise), }); - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - widgetWindow.win.emit('ready-to-show'); - - expect(widgetWindow.win.show).toHaveBeenCalled(); - expect(widgetWindow.win.setAlwaysOnTop).toHaveBeenCalled(); - expect(widgetWindow.win.setBounds).toHaveBeenCalledWith({ - x: 12, - y: 618, - width: MINIMUM_CALLS_WIDGET_WIDTH, - height: MINIMUM_CALLS_WIDGET_HEIGHT, - }); + await expect(promise).rejects.toThrow(error); }); - it('loadURL error', () => { - baseWindow.show = jest.fn(() => { - baseWindow.emit('show'); - }); - - baseWindow.loadURL = jest.fn(() => { - return Promise.reject(new Error('failed to load URL')); - }); - - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - expect(widgetWindow.win.loadURL).toHaveBeenCalled(); + it('should not create a new window if call is the same', async () => { + const window = {webContents: {id: 2}}; + callsWidgetWindow.win = window; + callsWidgetWindow.options = {callID: 'test'}; + await callsWidgetWindow.handleCreateCallsWidgetWindow('server-1_tab-messaging', {callID: 'test'}); + expect(callsWidgetWindow.win).toEqual(window); }); - it('open devTools', () => { - process.env.MM_DEBUG_CALLS_WIDGET = 'true'; - - baseWindow.show = jest.fn(() => { - baseWindow.emit('show'); - }); - - baseWindow.webContents = { - ...baseWindow.webContents, - openDevTools: jest.fn(), - }; - - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - widgetWindow.win.emit('ready-to-show'); - - expect(widgetWindow.win.webContents.openDevTools).toHaveBeenCalled(); + it('should create a new window if switching calls', async () => { + const window = {webContents: {id: 2}}; + callsWidgetWindow.win = window; + callsWidgetWindow.getCallID = jest.fn(() => 'test'); + await callsWidgetWindow.handleCreateCallsWidgetWindow('server-1_tab-messaging', {callID: 'test2'}); + expect(callsWidgetWindow.win).not.toEqual(window); }); + }); - it('closing window', async () => { - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - - await widgetWindow.close(); - - expect(widgetWindow.win.close).toHaveBeenCalled(); - expect(widgetWindow.win.isDestroyed).toHaveBeenCalled(); - }); - - it('closing window - already closed', async () => { - baseWindow.isDestroyed = jest.fn().mockReturnValue(true); - - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - - await widgetWindow.close(); - - expect(widgetWindow.win.isDestroyed).toHaveBeenCalled(); - expect(widgetWindow.win.close).not.toHaveBeenCalled(); - }); - - it('resize', () => { - baseWindow.webContents = { - ...baseWindow.webContents, - id: 'windowID', - }; - - let winBounds = { - x: 0, - y: 0, - width: MINIMUM_CALLS_WIDGET_WIDTH, - height: MINIMUM_CALLS_WIDGET_HEIGHT, - }; - baseWindow.getBounds = jest.fn(() => { - return winBounds; - }); - - baseWindow.setBounds = jest.fn((bounds) => { - winBounds = bounds; - }); - - baseWindow.show = jest.fn(() => { - baseWindow.emit('show'); - }); - - expect(baseWindow.setBounds).not.toHaveBeenCalled(); - - baseWindow.webContents.getZoomFactor = jest.fn(() => 1.0); - - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - widgetWindow.win.emit('ready-to-show'); - - expect(baseWindow.setBounds).toHaveBeenCalledTimes(1); - - expect(baseWindow.setBounds).toHaveBeenCalledWith({ - x: 12, - y: 720 - MINIMUM_CALLS_WIDGET_HEIGHT - 12, - width: MINIMUM_CALLS_WIDGET_WIDTH, - height: MINIMUM_CALLS_WIDGET_HEIGHT, - }); - - widgetWindow.onResize({ - sender: { - id: 'badID', - }, + describe('handleGetDesktopSources', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.win = { + webContents: { + send: jest.fn(), }, - 'widget', + }; + const teams = [ { - element: 'calls-widget', - width: 300, - height: 100, - }); - expect(baseWindow.webContents.getZoomFactor).not.toHaveBeenCalled(); - - widgetWindow.onResize({ - sender: baseWindow.webContents, + name: 'server-1', + order: 1, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + ], + }, { + name: 'server-2', + order: 0, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + ], + lastActiveTab: 2, }, - 'widget', - { - element: 'calls-widget', - width: 300, - height: 100, + ]; + const map = teams.reduce((arr, item) => { + item.tabs.forEach((tab) => { + arr.push([`${item.name}_${tab.name}`, { + sendToRenderer: jest.fn(), + }]); }); + return arr; + }, []); + const views = new Map(map); - expect(baseWindow.setBounds).toHaveBeenCalledWith({ - x: 12, - y: 720 - 100 - 12, - width: 300, - height: 100, - }); + beforeEach(() => { + ViewManager.getView.mockImplementation((viewId) => views.get(viewId)); }); - it('zoom', () => { - baseWindow.webContents = { - ...baseWindow.webContents, - id: 'windowID', - }; - - let winBounds = { - x: 0, - y: 0, - width: MINIMUM_CALLS_WIDGET_WIDTH, - height: MINIMUM_CALLS_WIDGET_HEIGHT, - }; - baseWindow.getBounds = jest.fn(() => { - return winBounds; - }); - - baseWindow.setBounds = jest.fn((bounds) => { - winBounds = bounds; - }); - - baseWindow.webContents.getZoomFactor = jest.fn(() => 1.0); - - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - widgetWindow.win.emit('ready-to-show'); - - expect(baseWindow.setBounds).toHaveBeenCalledTimes(1); - expect(baseWindow.webContents.getZoomFactor).toHaveBeenCalledTimes(0); - - baseWindow.webContents.getZoomFactor = jest.fn(() => 2.0); - widgetWindow.onResize({ - sender: baseWindow.webContents, - }, 'widget', { - element: 'calls-widget', - width: 300, - height: 100, - }); - expect(baseWindow.webContents.getZoomFactor).toHaveBeenCalledTimes(1); - expect(baseWindow.setBounds).toHaveBeenCalledWith({ - x: 12, - y: 720 - 200 - 12, - width: 600, - height: 200, - }); - - baseWindow.webContents.getZoomFactor = jest.fn(() => 0.5); - - widgetWindow.onResize({ - sender: baseWindow.webContents, - }, 'widget', { - element: 'calls-widget', - width: 300, - height: 100, - }); - expect(baseWindow.webContents.getZoomFactor).toHaveBeenCalledTimes(1); - expect(baseWindow.setBounds).toHaveBeenCalledWith({ - x: 12, - y: 720 - 50 - 12, - width: 150, - height: 50, - }); + afterEach(() => { + jest.resetAllMocks(); + callsWidgetWindow.missingScreensharePermissions = undefined; }); - it('getServerName', () => { - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - expect(widgetWindow.getServerName()).toBe('test-server-name'); - }); - - it('getChannelURL', () => { - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - expect(widgetWindow.getChannelURL()).toBe('/team/channel_id'); - }); - - it('getChannelURL', () => { - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - expect(widgetWindow.getCallID()).toBe('test-call-id'); - }); - - it('getWidgetURL', () => { - const config = { - ...widgetConfig, - title: 'call test title #/&', - }; - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, config); - const expected = `${mainView.serverInfo.server.url}plugins/${CALLS_PLUGIN_ID}/standalone/widget.html?call_id=${config.callID}&title=call+test+title+%23%2F%26`; - expect(widgetWindow.getWidgetURL()).toBe(expected); - }); - - it('getWidgetURL - under subpath', () => { - const config = { - ...widgetConfig, - title: 'call test title #/&', - }; - - const view = { - serverInfo: { - server: { - url: new URL('http://localhost:8065/subpath'), + it('should send sources back', async () => { + jest.spyOn(desktopCapturer, 'getSources').mockResolvedValue([ + { + id: 'screen0', + thumbnail: { + toDataURL: jest.fn(), }, }, - }; + { + id: 'window0', + thumbnail: { + toDataURL: jest.fn(), + }, - const widgetWindow = new CallsWidgetWindow(mainWindow, view, config); - const expected = `${view.serverInfo.server.url}/plugins/${CALLS_PLUGIN_ID}/standalone/widget.html?call_id=${config.callID}&title=call+test+title+%23%2F%26`; - expect(widgetWindow.getWidgetURL()).toBe(expected); - }); - - it('getWidgetURL - with rootID', () => { - const config = { - ...widgetConfig, - rootID: 'call_thread_id', - }; - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, config); - const expected = `${mainView.serverInfo.server.url}plugins/${CALLS_PLUGIN_ID}/standalone/widget.html?call_id=${config.callID}&root_id=call_thread_id`; - expect(widgetWindow.getWidgetURL()).toBe(expected); - }); - - it('onShareScreen', () => { - baseWindow.webContents = { - ...baseWindow.webContents, - send: jest.fn(), - id: 'windowID', - }; - - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - const message = { - sourceID: 'test-source-id', - withAudio: false, - }; - - widgetWindow.onShareScreen({ - sender: {id: 'badID'}, - }, '', message); - expect(widgetWindow.win.webContents.send).not.toHaveBeenCalled(); - - widgetWindow.onShareScreen({ - sender: baseWindow.webContents, - }, '', message); - expect(widgetWindow.win.webContents.send).toHaveBeenCalledWith(CALLS_WIDGET_SHARE_SCREEN, message); - }); - - it('onJoinedCall', () => { - baseWindow.webContents = { - ...baseWindow.webContents, - send: jest.fn(), - id: 'windowID', - }; - - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - const message = { - callID: 'test-call-id', - }; - - widgetWindow.onJoinedCall({ - sender: {id: 'badID'}, - }, message); - expect(widgetWindow.mainView.sendToRenderer).not.toHaveBeenCalled(); - - widgetWindow.onJoinedCall({ - sender: baseWindow.webContents, - }, 'widget', message); - expect(widgetWindow.mainView.sendToRenderer).toHaveBeenCalledWith(CALLS_JOINED_CALL, message); - }); - - it('menubar disabled on popout', () => { - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - const popOutURL = 'http://localhost:8065/team/com.mattermost.calls/expanded/test-call-id'; - expect(widgetWindow.onPopOutOpen({url: popOutURL})).toHaveProperty('action', 'allow'); - expect(widgetWindow.onPopOutOpen({url: popOutURL}).overrideBrowserWindowOptions).toHaveProperty('autoHideMenuBar', true); - }); - - it('wrong popout url disabled', () => { - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - const popOutURL = 'http://localhost/team/com.mattermost.calls/expanded/test-call-id'; - expect(widgetWindow.onPopOutOpen({url: popOutURL})).toHaveProperty('action', 'deny'); - }); - - it('popout redirects are disabled', () => { - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - - const ev = {preventDefault: jest.fn()}; - - const redirectURL = 'http://localhost:8065/login/sso/saml?redirect_to=https://google.com'; - widgetWindow.onWillRedirect(ev, redirectURL); - expect(ev.preventDefault).toHaveBeenCalled(); - }); - - it('onPopOutFocus', () => { - baseWindow.webContents = { - ...baseWindow.webContents, - send: jest.fn(), - }; - - baseWindow.restore = jest.fn(); - - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - - expect(baseWindow.webContents.setWindowOpenHandler).toHaveBeenCalledWith(widgetWindow.onPopOutOpen); - expect(baseWindow.webContents.on).toHaveBeenCalledWith('did-create-window', widgetWindow.onPopOutCreate); - - expect(widgetWindow.popOut).toBeNull(); - - const popOut = new EventEmitter(); - popOut.webContents = { - on: jest.fn(), - id: 'webContentsId', - }; - popOut.focus = jest.fn(); - popOut.restore = jest.fn(); - popOut.isMinimized = jest.fn().mockReturnValue(false); - - widgetWindow.onPopOutFocus(); - expect(popOut.focus).not.toHaveBeenCalled(); - expect(popOut.restore).not.toHaveBeenCalled(); - - widgetWindow.onPopOutCreate(popOut); - expect(widgetWindow.popOut).toBe(popOut); - - widgetWindow.onPopOutFocus(); - expect(popOut.focus).toHaveBeenCalled(); - expect(popOut.restore).not.toHaveBeenCalled(); - - popOut.isMinimized = jest.fn().mockReturnValue(true); - widgetWindow.onPopOutFocus(); - expect(popOut.focus).toHaveBeenCalled(); - expect(popOut.restore).toHaveBeenCalled(); - }); - - it('onPopOutCreate', () => { - baseWindow.webContents = { - ...baseWindow.webContents, - send: jest.fn(), - }; - - baseWindow.restore = jest.fn(); - - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - - expect(baseWindow.webContents.setWindowOpenHandler).toHaveBeenCalledWith(widgetWindow.onPopOutOpen); - expect(baseWindow.webContents.on).toHaveBeenCalledWith('did-create-window', widgetWindow.onPopOutCreate); - expect(widgetWindow.popOut).toBeNull(); - - const popOut = new EventEmitter(); - popOut.webContents = { - on: jest.fn(), - id: 'webContentsId', - }; - - widgetWindow.onPopOutCreate(popOut); - expect(widgetWindow.popOut).toBe(popOut); - expect(WebContentsEventManager.addWebContentsEventListeners).toHaveBeenCalledWith(popOut.webContents); - expect(popOut.webContents.on).toHaveBeenCalledWith('will-redirect', widgetWindow.onWillRedirect); - }); - - it('onPopOutClosed', () => { - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - expect(widgetWindow.popOut).toBeNull(); - - const popOut = new EventEmitter(); - popOut.webContents = { - on: jest.fn(), - id: 'webContentsId', - }; - - widgetWindow.onPopOutCreate(popOut); - expect(widgetWindow.popOut).toBe(popOut); - - popOut.emit('closed'); - expect(widgetWindow.popOut).toBeNull(); - }); - - it('getWebContentsId', () => { - baseWindow.webContents = { - ...baseWindow.webContents, - id: 'testID', - }; - - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - expect(widgetWindow.getWebContentsId()).toBe('testID'); - }); - - it('getURL', () => { - baseWindow.webContents = { - ...baseWindow.webContents, - id: 'testID', - getURL: jest.fn(() => 'http://localhost:8065/'), - }; - - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - expect(widgetWindow.getURL().toString()).toBe('http://localhost:8065/'); - }); - - it('getMainView', () => { - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - expect(widgetWindow.getMainView()).toEqual(mainView); - }); - - it('isAllowedEvent', () => { - baseWindow.webContents = { - ...baseWindow.webContents, - id: 'windowID', - }; - - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - expect(widgetWindow.isAllowedEvent({ - sender: { - id: 'senderID', }, - })).toEqual(false); + ]); - expect(widgetWindow.isAllowedEvent({ - sender: {id: 'mainViewID'}, - })).toEqual(true); + await callsWidgetWindow.handleGetDesktopSources('server-1_tab-1', null); - expect(widgetWindow.isAllowedEvent({ - sender: baseWindow.webContents, - })).toEqual(true); - }); - - it('getPopOutWebContentsId', () => { - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); - widgetWindow.popOut = { - webContents: { - id: 'popOutID', + expect(views.get('server-1_tab-1').sendToRenderer).toHaveBeenCalledWith('desktop-sources-result', [ + { + id: 'screen0', }, - }; - expect(widgetWindow.getPopOutWebContentsId()).toBe('popOutID'); + { + id: 'window0', + }, + ]); }); - it('onNavigate', () => { - const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig); + it('should send error with no sources', async () => { + jest.spyOn(desktopCapturer, 'getSources').mockResolvedValue([]); + await callsWidgetWindow.handleGetDesktopSources('server-2_tab-1', null); + expect(callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith('calls-error', { + err: 'screen-permissions', + }); + expect(views.get('server-2_tab-1').sendToRenderer).toHaveBeenCalledWith('calls-error', { + err: 'screen-permissions', + }); + expect(callsWidgetWindow.win.webContents.send).toHaveBeenCalledTimes(1); + }); - const ev = {preventDefault: jest.fn()}; + it('should send error with no permissions', async () => { + jest.spyOn(desktopCapturer, 'getSources').mockResolvedValue([ + { + id: 'screen0', + thumbnail: { + toDataURL: jest.fn(), + }, + }, + ]); + jest.spyOn(systemPreferences, 'getMediaAccessStatus').mockReturnValue('denied'); - widgetWindow.onNavigate(ev, widgetWindow.getWidgetURL()); - expect(ev.preventDefault).not.toHaveBeenCalled(); + await callsWidgetWindow.handleGetDesktopSources('server-1_tab-1', null); - widgetWindow.onNavigate(ev, 'http://localhost:8065/invalid/url'); - expect(ev.preventDefault).toHaveBeenCalledTimes(1); + expect(systemPreferences.getMediaAccessStatus).toHaveBeenCalledWith('screen'); + expect(callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith('calls-error', { + err: 'screen-permissions', + }); + expect(views.get('server-1_tab-1').sendToRenderer).toHaveBeenCalledWith('calls-error', { + err: 'screen-permissions', + }); + expect(views.get('server-1_tab-1').sendToRenderer).toHaveBeenCalledTimes(1); + expect(callsWidgetWindow.win.webContents.send).toHaveBeenCalledTimes(1); + }); + + it('macos - no permissions', async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + + jest.spyOn(desktopCapturer, 'getSources').mockResolvedValue([ + { + id: 'screen0', + thumbnail: { + toDataURL: jest.fn(), + }, + }, + ]); + jest.spyOn(systemPreferences, 'getMediaAccessStatus').mockReturnValue('denied'); + + await callsWidgetWindow.handleGetDesktopSources('server-1_tab-1', null); + + expect(callsWidgetWindow.missingScreensharePermissions).toBe(true); + expect(resetScreensharePermissionsMacOS).toHaveBeenCalledTimes(1); + expect(openScreensharePermissionsSettingsMacOS).toHaveBeenCalledTimes(0); + expect(callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith('calls-error', { + err: 'screen-permissions', + }); + expect(views.get('server-1_tab-1').sendToRenderer).toHaveBeenCalledWith('calls-error', { + err: 'screen-permissions', + }); + + await callsWidgetWindow.handleGetDesktopSources('server-1_tab-1', null); + + expect(resetScreensharePermissionsMacOS).toHaveBeenCalledTimes(2); + expect(openScreensharePermissionsSettingsMacOS).toHaveBeenCalledTimes(1); + + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + }); + }); + + describe('handleDesktopSourcesModalRequest', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.mainView = { + tab: { + server: { + name: 'server-1', + }, + }, + sendToRenderer: jest.fn(), + }; + const teams = [ + { + name: 'server-1', + order: 1, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + ], + }, { + name: 'server-2', + order: 0, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + ], + lastActiveTab: 2, + }, + ]; + const map = teams.reduce((arr, item) => { + item.tabs.forEach((tab) => { + arr.push([`${item.name}_${tab.name}`, {}]); + }); + return arr; + }, []); + const views = new Map(map); + + beforeEach(() => { + ViewManager.getView.mockImplementation((viewId) => views.get(viewId)); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should switch server', () => { + callsWidgetWindow.handleDesktopSourcesModalRequest(); + expect(WindowManager.switchServer).toHaveBeenCalledWith('server-1'); + }); + }); + + describe('handleCallsWidgetChannelLinkClick', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.mainView = { + tab: { + server: { + name: 'server-2', + }, + }, + sendToRenderer: jest.fn(), + }; + callsWidgetWindow.getChannelURL = jest.fn(); + const teams = [ + { + name: 'server-1', + order: 1, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + ], + }, { + name: 'server-2', + order: 0, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + ], + lastActiveTab: 2, + }, + ]; + const map = teams.reduce((arr, item) => { + item.tabs.forEach((tab) => { + arr.push([`${item.name}_${tab.name}`, {}]); + }); + return arr; + }, []); + const views = new Map(map); + + beforeEach(() => { + ViewManager.getView.mockImplementation((viewId) => views.get(viewId)); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should switch server', () => { + callsWidgetWindow.handleCallsWidgetChannelLinkClick(); + expect(WindowManager.switchServer).toHaveBeenCalledWith('server-2'); + }); + }); + + describe('handleCallsError', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.mainView = { + tab: { + server: { + name: 'server-2', + }, + }, + sendToRenderer: jest.fn(), + }; + const focus = jest.fn(); + + beforeEach(() => { + MainWindow.get.mockReturnValue({focus}); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should focus view and propagate error to main view', () => { + callsWidgetWindow.handleCallsError('', {err: 'client-error'}); + expect(WindowManager.switchServer).toHaveBeenCalledWith('server-2'); + expect(focus).toHaveBeenCalled(); + expect(callsWidgetWindow.mainView.sendToRenderer).toHaveBeenCalledWith('calls-error', {err: 'client-error'}); + }); + }); + + describe('handleCallsLinkClick', () => { + const view = { + tab: { + server: { + name: 'server-1', + }, + }, + sendToRenderer: jest.fn(), + }; + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.mainView = view; + + beforeEach(() => { + ViewManager.getView.mockReturnValue(view); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should pass through the click link to browser history push', () => { + callsWidgetWindow.handleCallsLinkClick('', {link: '/other/subpath'}); + expect(WindowManager.switchServer).toHaveBeenCalledWith('server-1'); + expect(view.sendToRenderer).toBeCalledWith('browser-history-push', '/other/subpath'); + }); + }); + + describe('genCallsEventHandler', () => { + const handler = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not call handler if source is not allowed', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.isAllowedEvent = () => false; + callsWidgetWindow.genCallsEventHandler(handler)(); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should call handler if source is allowed', () => { + const callsWidgetWindow = new CallsWidgetWindow(); + callsWidgetWindow.isAllowedEvent = () => true; + callsWidgetWindow.genCallsEventHandler(handler)(); + expect(handler).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/main/windows/callsWidgetWindow.ts b/src/main/windows/callsWidgetWindow.ts index cb87790f..f388eded 100644 --- a/src/main/windows/callsWidgetWindow.ts +++ b/src/main/windows/callsWidgetWindow.ts @@ -1,11 +1,14 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {EventEmitter} from 'events'; -import {BrowserWindow, ipcMain, IpcMainEvent, Rectangle} from 'electron'; +import {BrowserWindow, desktopCapturer, ipcMain, IpcMainEvent, Rectangle, systemPreferences} from 'electron'; import { + CallsErrorMessage, + CallsEventHandler, + CallsJoinCallMessage, CallsJoinedCallMessage, + CallsLinkClickMessage, CallsWidgetResizeMessage, CallsWidgetShareScreenMessage, CallsWidgetWindowConfig, @@ -13,32 +16,41 @@ import { import {MattermostView} from 'main/views/MattermostView'; -import {getLocalPreload} from 'main/utils'; +import {getLocalPreload, openScreensharePermissionsSettingsMacOS, resetScreensharePermissionsMacOS} from 'main/utils'; import {Logger} from 'common/log'; import {CALLS_PLUGIN_ID, MINIMUM_CALLS_WIDGET_HEIGHT, MINIMUM_CALLS_WIDGET_WIDTH} from 'common/utils/constants'; import Utils from 'common/utils/util'; import urlUtils, {getFormattedPathName} from 'common/utils/url'; import { + BROWSER_HISTORY_PUSH, + CALLS_ERROR, + CALLS_JOIN_CALL, CALLS_JOINED_CALL, + CALLS_LEAVE_CALL, + CALLS_LINK_CLICK, CALLS_POPOUT_FOCUS, + CALLS_WIDGET_CHANNEL_LINK_CLICK, CALLS_WIDGET_RESIZE, CALLS_WIDGET_SHARE_SCREEN, + DESKTOP_SOURCES_MODAL_REQUEST, + DESKTOP_SOURCES_RESULT, + DISPATCH_GET_DESKTOP_SOURCES, } from 'common/communication'; import webContentsEventManager from 'main/views/webContentEvents'; - -type LoadURLOpts = { - extraHeaders: string; -} +import MainWindow from 'main/windows/mainWindow'; +import WindowManager from 'main/windows/windowManager'; +import ViewManager from 'main/views/viewManager'; const log = new Logger('CallsWidgetWindow'); -export default class CallsWidgetWindow extends EventEmitter { - public win: BrowserWindow; - private main: BrowserWindow; - public popOut: BrowserWindow | null = null; - private mainView: MattermostView; - private config: CallsWidgetWindowConfig; +export class CallsWidgetWindow { + private win?: BrowserWindow; + private mainView?: MattermostView; + private options?: CallsWidgetWindowConfig; + private missingScreensharePermissions?: boolean; + + private popOut?: BrowserWindow; private boundsErr: Rectangle = { x: 0, y: 0, @@ -46,12 +58,67 @@ export default class CallsWidgetWindow extends EventEmitter { height: 0, }; - constructor(mainWindow: BrowserWindow, mainView: MattermostView, config: CallsWidgetWindowConfig) { - super(); + constructor() { + ipcMain.on(CALLS_WIDGET_RESIZE, this.handleResize); + ipcMain.on(CALLS_WIDGET_SHARE_SCREEN, this.handleShareScreen); + ipcMain.on(CALLS_JOINED_CALL, this.handleJoinedCall); + ipcMain.on(CALLS_POPOUT_FOCUS, this.handlePopOutFocus); + ipcMain.on(DISPATCH_GET_DESKTOP_SOURCES, this.genCallsEventHandler(this.handleGetDesktopSources)); + ipcMain.on(DESKTOP_SOURCES_MODAL_REQUEST, this.genCallsEventHandler(this.handleDesktopSourcesModalRequest)); + ipcMain.on(CALLS_JOIN_CALL, this.genCallsEventHandler(this.handleCreateCallsWidgetWindow)); + ipcMain.on(CALLS_LEAVE_CALL, this.genCallsEventHandler(this.handleCallsLeave)); + ipcMain.on(CALLS_WIDGET_CHANNEL_LINK_CLICK, this.genCallsEventHandler(this.handleCallsWidgetChannelLinkClick)); + ipcMain.on(CALLS_ERROR, this.genCallsEventHandler(this.handleCallsError)); + ipcMain.on(CALLS_LINK_CLICK, this.genCallsEventHandler(this.handleCallsLinkClick)); + } - this.config = config; - this.main = mainWindow; - this.mainView = mainView; + /** + * Getters + */ + + get callID() { + return this.options?.callID; + } + + private get serverName() { + return this.mainView?.tab.server.name; + } + + /** + * Helper functions + */ + + getURL = () => { + return this.win && urlUtils.parseURL(this.win?.webContents.getURL()); + } + + isCallsWidget = (webContentsId: number) => { + return webContentsId === this.win?.webContents.id || webContentsId === this.popOut?.webContents.id; + } + + private getWidgetURL = () => { + if (!this.mainView) { + return undefined; + } + const u = urlUtils.parseURL(this.mainView.tab.server.url.toString()) as URL; + + u.pathname = getFormattedPathName(u.pathname); + u.pathname += `plugins/${CALLS_PLUGIN_ID}/standalone/widget.html`; + + if (this.options?.callID) { + u.searchParams.append('call_id', this.options.callID); + } + if (this.options?.title) { + u.searchParams.append('title', this.options.title); + } + if (this.options?.rootID) { + u.searchParams.append('root_id', this.options.rootID); + } + + return u.toString(); + } + + private init = (view: MattermostView, options: CallsWidgetWindowConfig) => { this.win = new BrowserWindow({ width: MINIMUM_CALLS_WIDGET_WIDTH, height: MINIMUM_CALLS_WIDGET_HEIGHT, @@ -67,15 +134,12 @@ export default class CallsWidgetWindow extends EventEmitter { preload: getLocalPreload('callsWidget.js'), }, }); + this.mainView = view; + this.options = options; - this.win.once('ready-to-show', () => this.win.show()); + this.win.once('ready-to-show', () => this.win?.show()); this.win.once('show', this.onShow); - this.win.on('closed', this.onClosed); - ipcMain.on(CALLS_WIDGET_RESIZE, this.onResize); - ipcMain.on(CALLS_WIDGET_SHARE_SCREEN, this.onShareScreen); - ipcMain.on(CALLS_JOINED_CALL, this.onJoinedCall); - ipcMain.on(CALLS_POPOUT_FOCUS, this.onPopOutFocus); this.win.webContents.setWindowOpenHandler(this.onPopOutOpen); this.win.webContents.on('did-create-window', this.onPopOutCreate); @@ -84,31 +148,80 @@ export default class CallsWidgetWindow extends EventEmitter { this.win.webContents.on('will-navigate', this.onNavigate); this.win.webContents.on('did-start-navigation', this.onNavigate); - this.load(); - } - - public async close() { - log.debug('close'); - return new Promise((resolve) => { - if (this.win.isDestroyed()) { - resolve(); - return; - } - this.once('closed', resolve); - this.win.close(); + const widgetURL = this.getWidgetURL(); + if (!widgetURL) { + return; + } + this.win?.loadURL(widgetURL).catch((reason) => { + log.error(`failed to load: ${reason}`); }); } - public getServerName() { - return this.mainView.serverInfo.server.name; + private close = async () => { + log.debug('close'); + if (!this.win) { + return Promise.resolve(); + } + if (this.win.isDestroyed()) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + if (!this.win) { + resolve(); + return; + } + this.win?.on('closed', resolve); + this.win?.close(); + }); } - public getChannelURL() { - return this.config.channelURL; + private setBounds(bounds: Rectangle) { + if (!this.win) { + return; + } + + // NOTE: this hack is needed to fix positioning on certain systems where + // BrowserWindow.setBounds() is not consistent. + bounds.x += this.boundsErr.x; + bounds.y += this.boundsErr.y; + bounds.height += this.boundsErr.height; + bounds.width += this.boundsErr.width; + + this.win.setBounds(bounds); + this.boundsErr = Utils.boundsDiff(bounds, this.win.getBounds()); } - public getCallID() { - return this.config.callID; + private isAllowedEvent = (event: IpcMainEvent) => { + // Allow events when a call isn't in progress + if (!(this.win && this.mainView)) { + return true; + } + + // Only allow events coming from either the widget window or the + // original Mattermost view that initiated it. + return event.sender.id === this.win?.webContents.id || + event.sender.id === this.mainView?.webContentsId; + } + + private genCallsEventHandler = (handler: CallsEventHandler) => { + return (event: IpcMainEvent, viewId: string, msg?: any) => { + if (!this.isAllowedEvent(event)) { + log.warn('genCallsEventHandler', 'Disallowed calls event'); + return; + } + handler(viewId, msg); + }; + } + + /** + * BrowserWindow/WebContents handlers + */ + + private onClosed = () => { + delete this.win; + delete this.mainView; + delete this.options; } private onNavigate = (ev: Event, url: string) => { @@ -119,41 +232,81 @@ export default class CallsWidgetWindow extends EventEmitter { ev.preventDefault(); } - private load() { - const opts = {} as LoadURLOpts; - this.win.loadURL(this.getWidgetURL(), opts).catch((reason) => { - log.error(`Calls widget window failed to load: ${reason}`); + private onShow = () => { + log.debug('onShow'); + const mainWindow = MainWindow.get(); + if (!(this.win && mainWindow)) { + return; + } + + this.win.focus(); + this.win.setVisibleOnAllWorkspaces(true, {visibleOnFullScreen: true, skipTransformProcessType: true}); + this.win.setAlwaysOnTop(true, 'screen-saver'); + + const bounds = this.win.getBounds(); + const mainBounds = mainWindow.getBounds(); + const initialBounds = { + x: mainBounds.x + 12, + y: (mainBounds.y + mainBounds.height) - bounds.height - 12, + width: MINIMUM_CALLS_WIDGET_WIDTH, + height: MINIMUM_CALLS_WIDGET_HEIGHT, + }; + this.win.setMenuBarVisibility(false); + + if (process.env.MM_DEBUG_CALLS_WIDGET) { + this.win.webContents.openDevTools({mode: 'detach'}); + } + + this.setBounds(initialBounds); + } + + private onPopOutOpen = ({url}: {url: string}) => { + if (!(this.mainView && this.options)) { + return {action: 'deny' as const}; + } + + if (urlUtils.isCallsPopOutURL(this.mainView?.tab.server.url, url, this.options?.callID)) { + return { + action: 'allow' as const, + overrideBrowserWindowOptions: { + autoHideMenuBar: true, + }, + }; + } + + log.warn(`onPopOutOpen: prevented window open to ${url}`); + return {action: 'deny' as const}; + } + + private onPopOutCreate = (win: BrowserWindow) => { + this.popOut = win; + + // Let the webContentsEventManager handle links that try to open a new window. + webContentsEventManager.addWebContentsEventListeners(this.popOut.webContents); + + // Need to capture and handle redirects for security. + this.popOut.webContents.on('will-redirect', (event: Event) => { + // There's no reason we would allow a redirect from the call's popout. Eventually we may, so revise then. + // Note for the future: the code from https://github.com/mattermost/desktop/pull/2580 will not work for us. + event.preventDefault(); + }); + + this.popOut.on('closed', () => { + delete this.popOut; }); } - private onClosed = () => { - log.debug('onClosed'); - this.emit('closed'); - this.removeAllListeners('closed'); - ipcMain.off(CALLS_WIDGET_RESIZE, this.onResize); - ipcMain.off(CALLS_WIDGET_SHARE_SCREEN, this.onShareScreen); - ipcMain.off(CALLS_JOINED_CALL, this.onJoinedCall); - ipcMain.off(CALLS_POPOUT_FOCUS, this.onPopOutFocus); - } + /************************ + * IPC HANDLERS + ************************/ - private getWidgetURL() { - const u = urlUtils.parseURL(this.mainView.serverInfo.server.url.toString()) as URL; - u.pathname = getFormattedPathName(u.pathname); - u.pathname += `plugins/${CALLS_PLUGIN_ID}/standalone/widget.html`; - u.searchParams.append('call_id', this.config.callID); - if (this.config.title) { - u.searchParams.append('title', this.config.title); - } - if (this.config.rootID) { - u.searchParams.append('root_id', this.config.rootID); - } - - return u.toString(); - } - - private onResize = (ev: IpcMainEvent, _: string, msg: CallsWidgetResizeMessage) => { + private handleResize = (ev: IpcMainEvent, _: string, msg: CallsWidgetResizeMessage) => { log.debug('onResize', msg); + if (!this.win) { + return; + } + if (!this.isAllowedEvent(ev)) { log.warn('onResize', 'Disallowed calls event'); return; @@ -171,103 +324,29 @@ export default class CallsWidgetWindow extends EventEmitter { this.setBounds(newBounds); } - private onShareScreen = (ev: IpcMainEvent, _: string, message: CallsWidgetShareScreenMessage) => { - log.debug('onShareScreen'); + private handleShareScreen = (ev: IpcMainEvent, _: string, message: CallsWidgetShareScreenMessage) => { + log.debug('handleShareScreen'); if (!this.isAllowedEvent(ev)) { log.warn('Disallowed calls event'); return; } - this.win.webContents.send(CALLS_WIDGET_SHARE_SCREEN, message); + this.win?.webContents.send(CALLS_WIDGET_SHARE_SCREEN, message); } - private onJoinedCall = (ev: IpcMainEvent, _: string, message: CallsJoinedCallMessage) => { - log.debug('onJoinedCall'); + private handleJoinedCall = (ev: IpcMainEvent, _: string, message: CallsJoinedCallMessage) => { + log.debug('handleJoinedCall'); if (!this.isAllowedEvent(ev)) { - log.warn('onJoinedCall', 'Disallowed calls event'); + log.warn('handleJoinedCall', 'Disallowed calls event'); return; } - this.mainView.sendToRenderer(CALLS_JOINED_CALL, message); + this.mainView?.sendToRenderer(CALLS_JOINED_CALL, message); } - private setBounds(bounds: Rectangle) { - // NOTE: this hack is needed to fix positioning on certain systems where - // BrowserWindow.setBounds() is not consistent. - bounds.x += this.boundsErr.x; - bounds.y += this.boundsErr.y; - bounds.height += this.boundsErr.height; - bounds.width += this.boundsErr.width; - - this.win.setBounds(bounds); - this.boundsErr = Utils.boundsDiff(bounds, this.win.getBounds()); - } - - private onShow = () => { - log.debug('onShow'); - - this.win.focus(); - this.win.setVisibleOnAllWorkspaces(true, {visibleOnFullScreen: true, skipTransformProcessType: true}); - this.win.setAlwaysOnTop(true, 'screen-saver'); - - const bounds = this.win.getBounds(); - const mainBounds = this.main.getBounds(); - const initialBounds = { - x: mainBounds.x + 12, - y: (mainBounds.y + mainBounds.height) - bounds.height - 12, - width: MINIMUM_CALLS_WIDGET_WIDTH, - height: MINIMUM_CALLS_WIDGET_HEIGHT, - }; - this.win.setMenuBarVisibility(false); - - if (process.env.MM_DEBUG_CALLS_WIDGET) { - this.win.webContents.openDevTools({mode: 'detach'}); - } - - this.setBounds(initialBounds); - } - - private onPopOutOpen = ({url}: { url: string }) => { - if (urlUtils.isCallsPopOutURL(this.mainView.serverInfo.server.url, url, this.config.callID)) { - return { - action: 'allow' as const, - overrideBrowserWindowOptions: { - autoHideMenuBar: true, - }, - }; - } - - log.warn(`onPopOutOpen: prevented window open to ${url}`); - return {action: 'deny' as const}; - } - - private onPopOutCreate = (win: BrowserWindow) => { - this.popOut = win; - this.popOut.on('closed', this.onPopOutClosed); - - // Let the webContentsEventManager handle links that try to open a new window. - webContentsEventManager.addWebContentsEventListeners(this.popOut.webContents); - - // Need to capture and handle redirects for security. - this.popOut.webContents.on('will-redirect', this.onWillRedirect); - } - - private onPopOutClosed = () => { - log.debug('onPopOutClosed'); - this.popOut?.removeAllListeners('closed'); - this.popOut = null; - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - private onWillRedirect = (event: Event, url: string) => { - // There's no reason we would allow a redirect from the call's popout. Eventually we may, so revise then. - // Note for the future: the code from https://github.com/mattermost/desktop/pull/2580 will not work for us. - event.preventDefault(); - } - - private onPopOutFocus = () => { + private handlePopOutFocus = () => { if (!this.popOut) { return; } @@ -277,27 +356,151 @@ export default class CallsWidgetWindow extends EventEmitter { this.popOut.focus(); } - public getWebContentsId() { - return this.win.webContents.id; + private handleGetDesktopSources = async (viewId: string, opts: Electron.SourcesOptions) => { + log.debug('handleGetDesktopSources', opts); + + const view = ViewManager.getView(viewId); + if (!view) { + log.error('handleGetDesktopSources: view not found'); + return Promise.resolve(); + } + + if (process.platform === 'darwin' && systemPreferences.getMediaAccessStatus('screen') === 'denied') { + try { + // If permissions are missing we reset them so that the system + // prompt can be showed. + await resetScreensharePermissionsMacOS(); + + // We only open the system settings if permissions were already missing since + // on the first attempt to get the sources the OS will correctly show a prompt. + if (this.missingScreensharePermissions) { + await openScreensharePermissionsSettingsMacOS(); + } + this.missingScreensharePermissions = true; + } catch (err) { + log.error('failed to reset screen sharing permissions', err); + } + } + + const screenPermissionsErrMsg = {err: 'screen-permissions'}; + + return desktopCapturer.getSources(opts).then((sources) => { + let hasScreenPermissions = true; + if (systemPreferences.getMediaAccessStatus) { + const screenPermissions = systemPreferences.getMediaAccessStatus('screen'); + log.debug('screenPermissions', screenPermissions); + if (screenPermissions === 'denied') { + log.info('no screen sharing permissions'); + hasScreenPermissions = false; + } + } + + if (!hasScreenPermissions || !sources.length) { + log.info('missing screen permissions'); + view.sendToRenderer(CALLS_ERROR, screenPermissionsErrMsg); + this.win?.webContents.send(CALLS_ERROR, screenPermissionsErrMsg); + return; + } + + const message = sources.map((source) => { + return { + id: source.id, + name: source.name, + thumbnailURL: source.thumbnail.toDataURL(), + }; + }); + + if (message.length > 0) { + view.sendToRenderer(DESKTOP_SOURCES_RESULT, message); + } + }).catch((err) => { + log.error('desktopCapturer.getSources failed', err); + + view.sendToRenderer(CALLS_ERROR, screenPermissionsErrMsg); + this.win?.webContents.send(CALLS_ERROR, screenPermissionsErrMsg); + }); } - public getPopOutWebContentsId() { - return this.popOut?.webContents?.id; + private handleCreateCallsWidgetWindow = async (viewId: string, msg: CallsJoinCallMessage) => { + log.debug('createCallsWidgetWindow'); + + // trying to join again the call we are already in should not be allowed. + if (this.options?.callID === msg.callID) { + return; + } + + // to switch from one call to another we need to wait for the existing + // window to be fully closed. + await this.close(); + + const currentView = ViewManager.getView(viewId); + if (!currentView) { + log.error('unable to create calls widget window: currentView is missing'); + return; + } + + this.init(currentView, { + callID: msg.callID, + title: msg.title, + rootID: msg.rootID, + channelURL: msg.channelURL, + }); } - public getURL() { - return urlUtils.parseURL(this.win.webContents.getURL()); + private handleDesktopSourcesModalRequest = () => { + log.debug('handleDesktopSourcesModalRequest'); + + if (!this.serverName) { + return; + } + + WindowManager.switchServer(this.serverName); + MainWindow.get()?.focus(); + this.mainView?.sendToRenderer(DESKTOP_SOURCES_MODAL_REQUEST); } - public getMainView() { - return this.mainView; + private handleCallsLeave = () => { + log.debug('handleCallsLeave'); + + this.close(); } - public isAllowedEvent(event: IpcMainEvent) { - // Only allow events coming from either the widget window or the - // original Mattermost view that initiated it. - return event.sender.id === this.getWebContentsId() || - event.sender.id === this.getMainView().webContentsId; + private handleCallsWidgetChannelLinkClick = () => { + log.debug('handleCallsWidgetChannelLinkClick'); + + if (!this.serverName) { + return; + } + + WindowManager.switchServer(this.serverName); + MainWindow.get()?.focus(); + this.mainView?.sendToRenderer(BROWSER_HISTORY_PUSH, this.options?.channelURL); + } + + private handleCallsError = (_: string, msg: CallsErrorMessage) => { + log.debug('handleCallsError', msg); + + if (!this.serverName) { + return; + } + + WindowManager.switchServer(this.serverName); + MainWindow.get()?.focus(); + this.mainView?.sendToRenderer(CALLS_ERROR, msg); + } + + private handleCallsLinkClick = (_: string, msg: CallsLinkClickMessage) => { + log.debug('handleCallsLinkClick with linkURL', msg.link); + + if (!this.serverName) { + return; + } + + WindowManager.switchServer(this.serverName); + MainWindow.get()?.focus(); + this.mainView?.sendToRenderer(BROWSER_HISTORY_PUSH, msg.link); } } +const callsWidgetWindow = new CallsWidgetWindow(); +export default callsWidgetWindow; diff --git a/src/main/windows/windowManager.test.js b/src/main/windows/windowManager.test.js index 2251cddf..f7fc13d6 100644 --- a/src/main/windows/windowManager.test.js +++ b/src/main/windows/windowManager.test.js @@ -4,16 +4,12 @@ /* eslint-disable max-lines */ 'use strict'; -import {systemPreferences, desktopCapturer} from 'electron'; +import {systemPreferences} from 'electron'; import Config from 'common/config'; import {getTabViewName} from 'common/tabs/TabView'; -import { - getAdjustedWindowBoundaries, - resetScreensharePermissionsMacOS, - openScreensharePermissionsSettingsMacOS, -} from 'main/utils'; +import {getAdjustedWindowBoundaries} from 'main/utils'; import LoadingScreen from '../views/loadingScreen'; import ViewManager from 'main/views/viewManager'; @@ -44,10 +40,6 @@ jest.mock('electron', () => ({ }, systemPreferences: { getUserDefault: jest.fn(), - getMediaAccessStatus: jest.fn(() => 'granted'), - }, - desktopCapturer: { - getSources: jest.fn(), }, })); @@ -95,7 +87,10 @@ jest.mock('../downloadsManager', () => ({ getDownloads: () => {}, })); -jest.mock('./callsWidgetWindow'); +jest.mock('./callsWidgetWindow', () => ({ + isCallsWidget: jest.fn(), + getURL: jest.fn(), +})); jest.mock('main/views/webContentEvents', () => ({})); describe('main/windows/windowManager', () => { @@ -640,486 +635,13 @@ describe('main/windows/windowManager', () => { }); }); - describe('createCallsWidgetWindow', () => { - const windowManager = new WindowManager(); - const view = { - name: 'server-1_tab-messaging', - serverInfo: { - server: { - url: new URL('http://server-1.com'), - }, - }, - }; - - beforeEach(() => { - CallsWidgetWindow.mockImplementation(() => { - return { - win: { - isDestroyed: jest.fn(() => true), - }, - on: jest.fn(), - close: jest.fn(), - }; - }); - ViewManager.getView.mockReturnValue(view); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should create calls widget window', async () => { - expect(windowManager.callsWidgetWindow).toBeUndefined(); - await windowManager.createCallsWidgetWindow('server-1_tab-messaging', {callID: 'test'}); - expect(windowManager.callsWidgetWindow).toBeDefined(); - }); - - it('should not create a new window if call is the same', async () => { - const widgetWindow = windowManager.callsWidgetWindow; - expect(widgetWindow).toBeDefined(); - widgetWindow.getCallID = jest.fn(() => 'test'); - await windowManager.createCallsWidgetWindow('server-1_tab-messaging', {callID: 'test'}); - expect(windowManager.callsWidgetWindow).toEqual(widgetWindow); - }); - - it('should create a new window if switching calls', async () => { - const widgetWindow = windowManager.callsWidgetWindow; - expect(widgetWindow).toBeDefined(); - widgetWindow.getCallID = jest.fn(() => 'test'); - await windowManager.createCallsWidgetWindow('server-1_tab-messaging', {callID: 'test2'}); - expect(windowManager.callsWidgetWindow).not.toEqual(widgetWindow); - }); - }); - - describe('handleGetDesktopSources', () => { - const windowManager = new WindowManager(); - - beforeEach(() => { - CallsWidgetWindow.mockImplementation(() => { - return { - isAllowedEvent: jest.fn().mockReturnValue(true), - win: { - webContents: { - send: jest.fn(), - }, - }, - }; - }); - - windowManager.callsWidgetWindow = new CallsWidgetWindow(); - - Config.teams = [ - { - name: 'server-1', - order: 1, - tabs: [ - { - name: 'tab-1', - order: 0, - isOpen: false, - }, - { - name: 'tab-2', - order: 2, - isOpen: true, - }, - ], - }, { - name: 'server-2', - order: 0, - tabs: [ - { - name: 'tab-1', - order: 0, - isOpen: false, - }, - { - name: 'tab-2', - order: 2, - isOpen: true, - }, - ], - lastActiveTab: 2, - }, - ]; - - const map = Config.teams.reduce((arr, item) => { - item.tabs.forEach((tab) => { - arr.push([`${item.name}_${tab.name}`, { - sendToRenderer: jest.fn(), - }]); - }); - return arr; - }, []); - const views = new Map(map); - ViewManager.getView.mockImplementation((name) => views.get(name)); - }); - - afterEach(() => { - jest.resetAllMocks(); - Config.teams = []; - windowManager.missingScreensharePermissions = undefined; - }); - - it('should send sources back', async () => { - jest.spyOn(desktopCapturer, 'getSources').mockResolvedValue([ - { - id: 'screen0', - thumbnail: { - toDataURL: jest.fn(), - }, - }, - { - id: 'window0', - thumbnail: { - toDataURL: jest.fn(), - }, - }, - ]); - - await windowManager.handleGetDesktopSources('server-1_tab-1', null); - - expect(ViewManager.getView('server-1_tab-1').sendToRenderer).toHaveBeenCalledWith('desktop-sources-result', [ - { - id: 'screen0', - }, - { - id: 'window0', - }, - ]); - }); - - it('should send error with no sources', async () => { - jest.spyOn(desktopCapturer, 'getSources').mockResolvedValue([]); - await windowManager.handleGetDesktopSources('server-2_tab-1', null); - expect(windowManager.callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith('calls-error', { - err: 'screen-permissions', - }); - expect(ViewManager.getView('server-2_tab-1').sendToRenderer).toHaveBeenCalledWith('calls-error', { - err: 'screen-permissions', - }); - expect(windowManager.callsWidgetWindow.win.webContents.send).toHaveBeenCalledTimes(1); - }); - - it('should send error with no permissions', async () => { - jest.spyOn(desktopCapturer, 'getSources').mockResolvedValue([ - { - id: 'screen0', - thumbnail: { - toDataURL: jest.fn(), - }, - }, - ]); - jest.spyOn(systemPreferences, 'getMediaAccessStatus').mockReturnValue('denied'); - - await windowManager.handleGetDesktopSources('server-1_tab-1', null); - - expect(systemPreferences.getMediaAccessStatus).toHaveBeenCalledWith('screen'); - expect(windowManager.callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith('calls-error', { - err: 'screen-permissions', - }); - expect(ViewManager.getView('server-1_tab-1').sendToRenderer).toHaveBeenCalledWith('calls-error', { - err: 'screen-permissions', - }); - expect(ViewManager.getView('server-1_tab-1').sendToRenderer).toHaveBeenCalledTimes(1); - expect(windowManager.callsWidgetWindow.win.webContents.send).toHaveBeenCalledTimes(1); - }); - - it('macos - no permissions', async () => { - const originalPlatform = process.platform; - Object.defineProperty(process, 'platform', { - value: 'darwin', - }); - - jest.spyOn(desktopCapturer, 'getSources').mockResolvedValue([ - { - id: 'screen0', - thumbnail: { - toDataURL: jest.fn(), - }, - }, - ]); - jest.spyOn(systemPreferences, 'getMediaAccessStatus').mockReturnValue('denied'); - - await windowManager.handleGetDesktopSources('server-1_tab-1', null); - - expect(windowManager.missingScreensharePermissions).toBe(true); - expect(resetScreensharePermissionsMacOS).toHaveBeenCalledTimes(1); - expect(openScreensharePermissionsSettingsMacOS).toHaveBeenCalledTimes(0); - expect(windowManager.callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith('calls-error', { - err: 'screen-permissions', - }); - expect(ViewManager.getView('server-1_tab-1').sendToRenderer).toHaveBeenCalledWith('calls-error', { - err: 'screen-permissions', - }); - - await windowManager.handleGetDesktopSources('server-1_tab-1', null); - - expect(resetScreensharePermissionsMacOS).toHaveBeenCalledTimes(2); - expect(openScreensharePermissionsSettingsMacOS).toHaveBeenCalledTimes(1); - - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }); - }); - }); - - describe('handleDesktopSourcesModalRequest', () => { - const windowManager = new WindowManager(); - windowManager.switchServer = jest.fn(); - - beforeEach(() => { - CallsWidgetWindow.mockImplementation(() => { - return { - getServerName: () => 'server-1', - getMainView: jest.fn().mockReturnValue({ - sendToRenderer: jest.fn(), - }), - }; - }); - - Config.teams = [ - { - name: 'server-1', - order: 1, - tabs: [ - { - name: 'tab-1', - order: 0, - isOpen: false, - }, - { - name: 'tab-2', - order: 2, - isOpen: true, - }, - ], - }, { - name: 'server-2', - order: 0, - tabs: [ - { - name: 'tab-1', - order: 0, - isOpen: false, - }, - { - name: 'tab-2', - order: 2, - isOpen: true, - }, - ], - lastActiveTab: 2, - }, - ]; - - const map = Config.teams.reduce((arr, item) => { - item.tabs.forEach((tab) => { - arr.push([`${item.name}_${tab.name}`, {}]); - }); - return arr; - }, []); - const views = new Map(map); - ViewManager.getView.mockImplementation((name) => views.get(name)); - }); - - afterEach(() => { - jest.resetAllMocks(); - Config.teams = []; - }); - - it('should switch server', () => { - windowManager.callsWidgetWindow = new CallsWidgetWindow(); - windowManager.handleDesktopSourcesModalRequest(); - expect(windowManager.switchServer).toHaveBeenCalledWith('server-1'); - }); - }); - - describe('handleCallsWidgetChannelLinkClick', () => { - const windowManager = new WindowManager(); - windowManager.switchServer = jest.fn(); - - beforeEach(() => { - CallsWidgetWindow.mockImplementation(() => { - return { - getServerName: () => 'server-2', - getMainView: jest.fn().mockReturnValue({ - sendToRenderer: jest.fn(), - }), - getChannelURL: jest.fn(), - }; - }); - - Config.teams = [ - { - name: 'server-1', - order: 1, - tabs: [ - { - name: 'tab-1', - order: 0, - isOpen: false, - }, - { - name: 'tab-2', - order: 2, - isOpen: true, - }, - ], - }, { - name: 'server-2', - order: 0, - tabs: [ - { - name: 'tab-1', - order: 0, - isOpen: false, - }, - { - name: 'tab-2', - order: 2, - isOpen: true, - }, - ], - lastActiveTab: 2, - }, - ]; - - const map = Config.teams.reduce((arr, item) => { - item.tabs.forEach((tab) => { - arr.push([`${item.name}_${tab.name}`, {}]); - }); - return arr; - }, []); - const views = new Map(map); - ViewManager.getView.mockImplementation((name) => views.get(name)); - }); - - afterEach(() => { - jest.resetAllMocks(); - Config.teams = []; - }); - - it('should switch server', () => { - windowManager.callsWidgetWindow = new CallsWidgetWindow(); - windowManager.handleCallsWidgetChannelLinkClick(); - expect(windowManager.switchServer).toHaveBeenCalledWith('server-2'); - }); - }); - - describe('handleCallsError', () => { - const windowManager = new WindowManager(); - const mainWindow = { - focus: jest.fn(), - }; - windowManager.switchServer = jest.fn(); - - beforeEach(() => { - CallsWidgetWindow.mockImplementation(() => { - return { - getServerName: () => 'server-2', - getMainView: jest.fn().mockReturnValue({ - sendToRenderer: jest.fn(), - }), - }; - }); - MainWindow.get.mockReturnValue(mainWindow); - }); - - afterEach(() => { - jest.resetAllMocks(); - Config.teams = []; - }); - - it('should focus view and propagate error to main view', () => { - windowManager.callsWidgetWindow = new CallsWidgetWindow(); - windowManager.handleCallsError('', {err: 'client-error'}); - expect(windowManager.switchServer).toHaveBeenCalledWith('server-2'); - expect(mainWindow.focus).toHaveBeenCalled(); - expect(windowManager.callsWidgetWindow.getMainView().sendToRenderer).toHaveBeenCalledWith('calls-error', {err: 'client-error'}); - }); - }); - - describe('handleCallsLinkClick', () => { - const windowManager = new WindowManager(); - windowManager.switchServer = jest.fn(); - const view1 = { - sendToRenderer: jest.fn(), - }; - - beforeEach(() => { - CallsWidgetWindow.mockImplementation(() => { - return { - getServerName: () => 'server-1', - getMainView: jest.fn().mockReturnValue(view1), - }; - }); - ViewManager.getView.mockReturnValue(view1); - }); - - afterEach(() => { - jest.resetAllMocks(); - Config.teams = []; - }); - - it('should pass through the click link to browser history push', () => { - windowManager.callsWidgetWindow = new CallsWidgetWindow(); - windowManager.handleCallsLinkClick('', {link: '/other/subpath'}); - expect(windowManager.switchServer).toHaveBeenCalledWith('server-1'); - expect(view1.sendToRenderer).toBeCalledWith('browser-history-push', '/other/subpath'); - }); - }); - describe('getServerURLFromWebContentsId', () => { const windowManager = new WindowManager(); it('should return calls widget URL', () => { - ViewManager.getView.mockReturnValue({name: 'server-1_tab-messaging'}); - CallsWidgetWindow.mockImplementation(() => { - return { - on: jest.fn(), - getURL: jest.fn(() => 'http://localhost:8065'), - getWebContentsId: jest.fn(() => 'callsID'), - }; - }); - - windowManager.createCallsWidgetWindow('server-1_tab-messaging', 'http://localhost:8065', {callID: 'test'}); - expect(windowManager.getServerURLFromWebContentsId('callsID')).toBe(windowManager.callsWidgetWindow.getURL()); - }); - }); - - describe('genCallsEventHandler', () => { - const windowManager = new WindowManager(); - - const handler = jest.fn(); - - it('should call handler if callsWidgetWindow is not defined', () => { - windowManager.genCallsEventHandler(handler)(); - expect(handler).toHaveBeenCalledTimes(1); - }); - - it('should not call handler if source is not allowed', () => { - CallsWidgetWindow.mockImplementation(() => { - return { - isAllowedEvent: jest.fn().mockReturnValue(false), - }; - }); - - windowManager.callsWidgetWindow = new CallsWidgetWindow(); - windowManager.genCallsEventHandler(handler)(); - expect(handler).not.toHaveBeenCalled(); - }); - - it('should call handler if source is allowed', () => { - CallsWidgetWindow.mockImplementation(() => { - return { - isAllowedEvent: jest.fn().mockReturnValue(true), - }; - }); - - windowManager.callsWidgetWindow = new CallsWidgetWindow(); - windowManager.genCallsEventHandler(handler)(); - expect(handler).toHaveBeenCalledTimes(1); + CallsWidgetWindow.getURL.mockReturnValue('http://server-1.com'); + CallsWidgetWindow.isCallsWidget.mockReturnValue(true); + expect(windowManager.getServerURLFromWebContentsId('callsID')).toBe('http://server-1.com'); }); }); }); diff --git a/src/main/windows/windowManager.ts b/src/main/windows/windowManager.ts index ef2311c1..e96a2d1f 100644 --- a/src/main/windows/windowManager.ts +++ b/src/main/windows/windowManager.ts @@ -4,32 +4,16 @@ /* eslint-disable max-lines */ import path from 'path'; -import {app, BrowserWindow, systemPreferences, ipcMain, IpcMainEvent, IpcMainInvokeEvent, desktopCapturer} from 'electron'; - -import { - CallsJoinCallMessage, - CallsErrorMessage, - CallsLinkClickMessage, - CallsEventHandler, -} from 'types/calls'; +import {app, BrowserWindow, systemPreferences, ipcMain, IpcMainEvent, IpcMainInvokeEvent} from 'electron'; import { MAXIMIZE_CHANGE, FOCUS_THREE_DOT_MENU, GET_DARK_MODE, UPDATE_SHORTCUT_MENU, - BROWSER_HISTORY_PUSH, GET_VIEW_WEBCONTENTS_ID, RESIZE_MODAL, - DISPATCH_GET_DESKTOP_SOURCES, - DESKTOP_SOURCES_RESULT, VIEW_FINISHED_RESIZING, - CALLS_JOIN_CALL, - CALLS_LEAVE_CALL, - DESKTOP_SOURCES_MODAL_REQUEST, - CALLS_WIDGET_CHANNEL_LINK_CLICK, - CALLS_ERROR, - CALLS_LINK_CLICK, } from 'common/communication'; import {Logger} from 'common/log'; import {SECOND} from 'common/utils/constants'; @@ -41,8 +25,6 @@ import {MattermostView} from 'main/views/MattermostView'; import { getAdjustedWindowBoundaries, shouldHaveBackBar, - resetScreensharePermissionsMacOS, - openScreensharePermissionsSettingsMacOS, } from '../utils'; import ViewManager from '../views/viewManager'; @@ -62,7 +44,6 @@ const log = new Logger('WindowManager'); export class WindowManager { assetsDir: string; - callsWidgetWindow?: CallsWidgetWindow; teamDropdown?: TeamDropdownView; downloadsDropdown?: DownloadsDropdownView; downloadsDropdownMenu?: DownloadsDropdownMenuView; @@ -75,99 +56,6 @@ export class WindowManager { ipcMain.handle(GET_DARK_MODE, this.handleGetDarkMode); ipcMain.handle(GET_VIEW_WEBCONTENTS_ID, this.handleGetWebContentsId); ipcMain.on(VIEW_FINISHED_RESIZING, this.handleViewFinishedResizing); - - // Calls handlers - ipcMain.on(DISPATCH_GET_DESKTOP_SOURCES, this.genCallsEventHandler(this.handleGetDesktopSources)); - ipcMain.on(DESKTOP_SOURCES_MODAL_REQUEST, this.genCallsEventHandler(this.handleDesktopSourcesModalRequest)); - ipcMain.on(CALLS_JOIN_CALL, this.genCallsEventHandler(this.createCallsWidgetWindow)); - ipcMain.on(CALLS_LEAVE_CALL, this.genCallsEventHandler(this.handleCallsLeave)); - ipcMain.on(CALLS_WIDGET_CHANNEL_LINK_CLICK, this.genCallsEventHandler(this.handleCallsWidgetChannelLinkClick)); - ipcMain.on(CALLS_ERROR, this.genCallsEventHandler(this.handleCallsError)); - ipcMain.on(CALLS_LINK_CLICK, this.genCallsEventHandler(this.handleCallsLinkClick)); - } - - genCallsEventHandler = (handler: CallsEventHandler) => { - return (event: IpcMainEvent, viewName: string, msg?: any) => { - if (this.callsWidgetWindow && !this.callsWidgetWindow.isAllowedEvent(event)) { - log.warn('genCallsEventHandler', 'Disallowed calls event'); - return; - } - handler(viewName, msg); - }; - } - - createCallsWidgetWindow = async (viewName: string, msg: CallsJoinCallMessage) => { - log.debug('createCallsWidgetWindow'); - if (this.callsWidgetWindow) { - // trying to join again the call we are already in should not be allowed. - if (this.callsWidgetWindow.getCallID() === msg.callID) { - return; - } - - // to switch from one call to another we need to wait for the existing - // window to be fully closed. - await this.callsWidgetWindow.close(); - } - const currentView = ViewManager.getView(viewName); - if (!currentView) { - log.error('unable to create calls widget window: currentView is missing'); - return; - } - - this.callsWidgetWindow = new CallsWidgetWindow(MainWindow.get()!, currentView, { - callID: msg.callID, - title: msg.title, - rootID: msg.rootID, - channelURL: msg.channelURL, - }); - - this.callsWidgetWindow.on('closed', () => delete this.callsWidgetWindow); - } - - handleDesktopSourcesModalRequest = () => { - log.debug('handleDesktopSourcesModalRequest'); - - if (this.callsWidgetWindow) { - this.switchServer(this.callsWidgetWindow.getServerName()); - MainWindow.get()?.focus(); - this.callsWidgetWindow.getMainView().sendToRenderer(DESKTOP_SOURCES_MODAL_REQUEST); - } - } - - handleCallsWidgetChannelLinkClick = () => { - log.debug('handleCallsWidgetChannelLinkClick'); - - if (this.callsWidgetWindow) { - this.switchServer(this.callsWidgetWindow.getServerName()); - MainWindow.get()?.focus(); - this.callsWidgetWindow.getMainView().sendToRenderer(BROWSER_HISTORY_PUSH, this.callsWidgetWindow.getChannelURL()); - } - } - - handleCallsError = (_: string, msg: CallsErrorMessage) => { - log.debug('handleCallsError', msg); - - if (this.callsWidgetWindow) { - this.switchServer(this.callsWidgetWindow.getServerName()); - MainWindow.get()?.focus(); - this.callsWidgetWindow.getMainView().sendToRenderer(CALLS_ERROR, msg); - } - } - - handleCallsLinkClick = (_: string, msg: CallsLinkClickMessage) => { - log.debug('handleCallsLinkClick with linkURL', msg.link); - - if (this.callsWidgetWindow) { - this.switchServer(this.callsWidgetWindow.getServerName()); - MainWindow.get()?.focus(); - this.callsWidgetWindow.getMainView().sendToRenderer(BROWSER_HISTORY_PUSH, msg.link); - } - } - - handleCallsLeave = () => { - log.debug('handleCallsLeave'); - - this.callsWidgetWindow?.close(); } showMainWindow = (deeplinkingURL?: string | URL) => { @@ -562,74 +450,9 @@ export class WindowManager { return event.sender.id; } - handleGetDesktopSources = async (viewName: string, opts: Electron.SourcesOptions) => { - log.debug('handleGetDesktopSources', {viewName, opts}); - - const view = ViewManager.getView(viewName); - if (!view) { - log.error('handleGetDesktopSources: view not found'); - return Promise.resolve(); - } - - if (process.platform === 'darwin' && systemPreferences.getMediaAccessStatus('screen') === 'denied') { - try { - // If permissions are missing we reset them so that the system - // prompt can be showed. - await resetScreensharePermissionsMacOS(); - - // We only open the system settings if permissions were already missing since - // on the first attempt to get the sources the OS will correctly show a prompt. - if (this.missingScreensharePermissions) { - await openScreensharePermissionsSettingsMacOS(); - } - this.missingScreensharePermissions = true; - } catch (err) { - log.error('failed to reset screen sharing permissions', err); - } - } - - const screenPermissionsErrMsg = {err: 'screen-permissions'}; - - return desktopCapturer.getSources(opts).then((sources) => { - let hasScreenPermissions = true; - if (systemPreferences.getMediaAccessStatus) { - const screenPermissions = systemPreferences.getMediaAccessStatus('screen'); - log.debug('screenPermissions', screenPermissions); - if (screenPermissions === 'denied') { - log.info('no screen sharing permissions'); - hasScreenPermissions = false; - } - } - - if (!hasScreenPermissions || !sources.length) { - log.info('missing screen permissions'); - view.sendToRenderer(CALLS_ERROR, screenPermissionsErrMsg); - this.callsWidgetWindow?.win.webContents.send(CALLS_ERROR, screenPermissionsErrMsg); - return; - } - - const message = sources.map((source) => { - return { - id: source.id, - name: source.name, - thumbnailURL: source.thumbnail.toDataURL(), - }; - }); - - if (message.length > 0) { - view.sendToRenderer(DESKTOP_SOURCES_RESULT, message); - } - }).catch((err) => { - log.error('desktopCapturer.getSources failed', err); - - view.sendToRenderer(CALLS_ERROR, screenPermissionsErrMsg); - this.callsWidgetWindow?.win.webContents.send(CALLS_ERROR, screenPermissionsErrMsg); - }); - } - getServerURLFromWebContentsId = (id: number) => { - if (this.callsWidgetWindow && (id === this.callsWidgetWindow.getWebContentsId() || id === this.callsWidgetWindow.getPopOutWebContentsId())) { - return this.callsWidgetWindow.getURL(); + if (CallsWidgetWindow.isCallsWidget(id)) { + return CallsWidgetWindow.getURL(); } return ViewManager.getViewByWebContentsId(id)?.tab.server.url;