diff --git a/src/main/authManager.ts b/src/main/authManager.ts index 6d9dbf49..8068d9d5 100644 --- a/src/main/authManager.ts +++ b/src/main/authManager.ts @@ -11,8 +11,9 @@ import {BASIC_AUTH_PERMISSION} from 'common/permissions'; import urlUtils from 'common/utils/url'; import * as WindowManager from 'main/windows/windowManager'; -import {addModal} from 'main/views/modalManager'; -import {getLocalURLString, getLocalPreload} from 'main/utils'; + +import modalManager from './views/modalManager'; +import {getLocalURLString, getLocalPreload} from './utils'; import TrustedOriginsStore from './trustedOrigins'; @@ -64,7 +65,7 @@ export class AuthManager { if (!mainWindow) { return; } - const modalPromise = addModal(authInfo.isProxy ? `proxy-${authInfo.host}` : `login-${request.url}`, loginModalHtml, modalPreload, {request, authInfo}, mainWindow); + const modalPromise = modalManager.addModal(authInfo.isProxy ? `proxy-${authInfo.host}` : `login-${request.url}`, loginModalHtml, modalPreload, {request, authInfo}, mainWindow); if (modalPromise) { modalPromise.then((data) => { const {username, password} = data; @@ -83,7 +84,7 @@ export class AuthManager { if (!mainWindow) { return; } - const modalPromise = addModal(`permission-${request.url}`, permissionModalHtml, modalPreload, {url: request.url, permission}, mainWindow); + const modalPromise = modalManager.addModal(`permission-${request.url}`, permissionModalHtml, modalPreload, {url: request.url, permission}, mainWindow); if (modalPromise) { modalPromise.then(() => { this.handlePermissionGranted(request.url, permission); diff --git a/src/main/certificateManager.ts b/src/main/certificateManager.ts index 4d8cab1d..32a980cd 100644 --- a/src/main/certificateManager.ts +++ b/src/main/certificateManager.ts @@ -7,7 +7,7 @@ import {CertificateModalData} from 'types/certificate'; import * as WindowManager from './windows/windowManager'; -import {addModal} from './views/modalManager'; +import modalManager from './views/modalManager'; import {getLocalURLString, getLocalPreload} from './utils'; const modalPreload = getLocalPreload('modalPreload.js'); @@ -41,7 +41,7 @@ export class CertificateManager { if (!mainWindow) { return; } - const modalPromise = addModal(`certificate-${url}`, html, modalPreload, {url, list}, mainWindow); + const modalPromise = modalManager.addModal(`certificate-${url}`, html, modalPreload, {url, list}, mainWindow); if (modalPromise) { modalPromise.then((data) => { const {cert} = data; diff --git a/src/main/main.ts b/src/main/main.ts index 27417909..fb354bed 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -67,7 +67,7 @@ import * as WindowManager from './windows/windowManager'; import {displayMention, displayDownloadCompleted} from './notifications'; import parseArgs from './ParseArgs'; -import {addModal} from './views/modalManager'; +import modalManager from './views/modalManager'; import {getLocalURLString, getLocalPreload} from './utils'; import {destroyTray, refreshTrayImages, setTrayMenu, setupTray} from './tray/tray'; import {AuthManager} from './authManager'; @@ -553,7 +553,7 @@ function handleNewServerModal() { if (!mainWindow) { return; } - const modalPromise = addModal('newServer', html, modalPreload, {}, mainWindow, config.teams.length === 0); + const modalPromise = modalManager.addModal('newServer', html, modalPreload, {}, mainWindow, config.teams.length === 0); if (modalPromise) { modalPromise.then((data) => { const teams = config.teams; @@ -587,7 +587,7 @@ function handleEditServerModal(e: IpcMainEvent, name: string) { if (serverIndex < 0) { return; } - const modalPromise = addModal('editServer', html, modalPreload, config.teams[serverIndex], mainWindow); + const modalPromise = modalManager.addModal('editServer', html, modalPreload, config.teams[serverIndex], mainWindow); if (modalPromise) { modalPromise.then((data) => { const teams = config.teams; @@ -614,7 +614,7 @@ function handleRemoveServerModal(e: IpcMainEvent, name: string) { if (!mainWindow) { return; } - const modalPromise = addModal('removeServer', html, modalPreload, name, mainWindow); + const modalPromise = modalManager.addModal('removeServer', html, modalPreload, name, mainWindow); if (modalPromise) { modalPromise.then((remove) => { if (remove) { diff --git a/src/main/views/MattermostView.test.js b/src/main/views/MattermostView.test.js new file mode 100644 index 00000000..0d8168cb --- /dev/null +++ b/src/main/views/MattermostView.test.js @@ -0,0 +1,319 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +'use strict'; + +import {LOAD_FAILED, TOGGLE_BACK_BUTTON, UPDATE_TARGET_URL} from 'common/communication'; +import {MattermostServer} from 'common/servers/MattermostServer'; +import MessagingTabView from 'common/tabs/MessagingTabView'; + +import * as WindowManager from '../windows/windowManager'; +import * as appState from '../appState'; + +import {MattermostView} from './MattermostView'; + +jest.mock('electron', () => ({ + app: { + getVersion: () => '5.0.0', + }, + BrowserView: jest.fn().mockImplementation(() => ({ + webContents: { + loadURL: jest.fn(), + on: jest.fn(), + getTitle: () => 'title', + getURL: () => 'http://server-1.com', + }, + })), + ipcMain: { + on: jest.fn(), + }, +})); + +jest.mock('electron-log', () => ({ + info: jest.fn(), + error: jest.fn(), +})); + +jest.mock('../windows/windowManager', () => ({ + sendToRenderer: jest.fn(), + focusThreeDotMenu: jest.fn(), +})); +jest.mock('../appState', () => ({ + updateMentions: jest.fn(), +})); +jest.mock('./webContentEvents', () => ({ + removeWebContentsListeners: jest.fn(), +})); +jest.mock('../contextMenu', () => jest.fn()); +jest.mock('../utils', () => ({ + getWindowBoundaries: jest.fn(), + getLocalPreload: (file) => file, + composeUserAgent: () => 'Mattermost/5.0.0', +})); + +const server = new MattermostServer('server_name', 'http://server-1.com'); +const tabView = new MessagingTabView(server); + +describe('main/views/MattermostView', () => { + describe('load', () => { + const mattermostView = new MattermostView(tabView, {}, {}, {}); + + beforeEach(() => { + mattermostView.loadSuccess = jest.fn(); + mattermostView.loadRetry = jest.fn(); + }); + + it('should load provided URL when provided', async () => { + const promise = Promise.resolve(); + mattermostView.view.webContents.loadURL.mockImplementation(() => promise); + mattermostView.load('http://server-2.com'); + await promise; + expect(mattermostView.view.webContents.loadURL).toBeCalledWith('http://server-2.com/', expect.any(Object)); + expect(mattermostView.loadSuccess).toBeCalledWith('http://server-2.com/'); + }); + + it('should load server URL when not provided', async () => { + const promise = Promise.resolve(); + mattermostView.view.webContents.loadURL.mockImplementation(() => promise); + mattermostView.load(); + await promise; + expect(mattermostView.view.webContents.loadURL).toBeCalledWith('http://server-1.com/', expect.any(Object)); + expect(mattermostView.loadSuccess).toBeCalledWith('http://server-1.com/'); + }); + + it('should load server URL when bad url provided', async () => { + const promise = Promise.resolve(); + mattermostView.view.webContents.loadURL.mockImplementation(() => promise); + mattermostView.load('a-bad { + const error = new Error('test'); + const promise = Promise.reject(error); + mattermostView.view.webContents.loadURL.mockImplementation(() => promise); + mattermostView.load('a-bad { + const mattermostView = new MattermostView(tabView, {}, {}, {}); + + beforeEach(() => { + mattermostView.view.webContents.loadURL.mockImplementation(() => Promise.resolve()); + mattermostView.loadSuccess = jest.fn(); + mattermostView.loadRetry = jest.fn(); + mattermostView.emit = jest.fn(); + }); + + it('should do nothing when webcontents are destroyed', () => { + const webContents = mattermostView.view.webContents; + mattermostView.view.webContents = null; + mattermostView.retry('http://server-1.com')(); + expect(mattermostView.loadSuccess).not.toBeCalled(); + mattermostView.view.webContents = webContents; + }); + + it('should call loadSuccess on successful load', async () => { + const promise = Promise.resolve(); + mattermostView.view.webContents.loadURL.mockImplementation(() => promise); + mattermostView.retry('http://server-1.com')(); + await promise; + expect(mattermostView.loadSuccess).toBeCalledWith('http://server-1.com'); + }); + + it('should call loadRetry if maxRetries are still remaining', async () => { + mattermostView.maxRetries = 10; + const error = new Error('test'); + const promise = Promise.reject(error); + mattermostView.view.webContents.loadURL.mockImplementation(() => promise); + mattermostView.retry('http://server-1.com')(); + await expect(promise).rejects.toThrow(error); + expect(mattermostView.view.webContents.loadURL).toBeCalledWith('http://server-1.com', expect.any(Object)); + expect(mattermostView.loadRetry).toBeCalledWith('http://server-1.com', error); + }); + + it('should set to error status when max retries are reached', async () => { + mattermostView.maxRetries = 0; + const error = new Error('test'); + const promise = Promise.reject(error); + mattermostView.view.webContents.loadURL.mockImplementation(() => promise); + mattermostView.retry('http://server-1.com')(); + await expect(promise).rejects.toThrow(error); + expect(mattermostView.view.webContents.loadURL).toBeCalledWith('http://server-1.com', expect.any(Object)); + expect(mattermostView.loadRetry).not.toBeCalled(); + expect(WindowManager.sendToRenderer).toBeCalledWith(LOAD_FAILED, mattermostView.tab.name, expect.any(String), expect.any(String)); + expect(mattermostView.status).toBe(-1); + }); + }); + + describe('loadSuccess', () => { + const mattermostView = new MattermostView(tabView, {}, {}, {}); + + beforeEach(() => { + jest.useFakeTimers(); + mattermostView.emit = jest.fn(); + mattermostView.setBounds = jest.fn(); + mattermostView.setInitialized = jest.fn(); + mattermostView.updateMentionsFromTitle = jest.fn(); + mattermostView.findUnreadState = jest.fn(); + }); + + it('should reset max retries', () => { + mattermostView.maxRetries = 1; + mattermostView.loadSuccess('http://server-1.com')(); + jest.runAllTimers(); + expect(mattermostView.maxRetries).toBe(3); + }); + }); + + describe('show', () => { + const window = {addBrowserView: jest.fn(), removeBrowserView: jest.fn()}; + const mattermostView = new MattermostView(tabView, {}, window, {}); + + beforeEach(() => { + jest.useFakeTimers(); + mattermostView.setBounds = jest.fn(); + mattermostView.focus = jest.fn(); + }); + + it('should add browser view to window and set bounds when request is true and view not currently visible', () => { + mattermostView.isVisible = false; + mattermostView.show(true); + expect(window.addBrowserView).toBeCalledWith(mattermostView.view); + expect(mattermostView.setBounds).toBeCalled(); + expect(mattermostView.isVisible).toBe(true); + }); + + it('should remove browser view when request is false', () => { + mattermostView.isVisible = true; + mattermostView.show(false); + expect(window.removeBrowserView).toBeCalledWith(mattermostView.view); + expect(mattermostView.isVisible).toBe(false); + }); + + it('should do nothing when not toggling', () => { + mattermostView.isVisible = true; + mattermostView.show(true); + expect(window.addBrowserView).not.toBeCalled(); + expect(window.removeBrowserView).not.toBeCalled(); + + mattermostView.isVisible = false; + mattermostView.show(false); + expect(window.addBrowserView).not.toBeCalled(); + expect(window.removeBrowserView).not.toBeCalled(); + }); + + it('should focus view if view is ready', () => { + mattermostView.status = 1; + mattermostView.isVisible = false; + mattermostView.show(true); + expect(mattermostView.focus).toBeCalled(); + }); + }); + + describe('destroy', () => { + const window = {removeBrowserView: jest.fn()}; + const mattermostView = new MattermostView(tabView, {}, window, {}); + + beforeEach(() => { + mattermostView.view.webContents.destroy = jest.fn(); + }); + + it('should remove browser view from window', () => { + mattermostView.destroy(); + expect(window.removeBrowserView).toBeCalledWith(mattermostView.view); + }); + + it('should clear mentions', () => { + mattermostView.destroy(); + expect(appState.updateMentions).toBeCalledWith(mattermostView.tab.name, 0, false); + }); + + it('should clear outstanding timeouts', () => { + const spy = jest.spyOn(global, 'clearTimeout'); + mattermostView.retryLoad = 999; + mattermostView.removeLoading = 1000; + mattermostView.destroy(); + expect(spy).toHaveBeenCalledTimes(2); + }); + }); + + describe('handleInputEvents', () => { + const mattermostView = new MattermostView(tabView, {}, {}, {}); + + it('should open three dot menu on pressing Alt', () => { + mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyDown'}); + mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyUp'}); + expect(WindowManager.focusThreeDotMenu).toHaveBeenCalled(); + }); + + it('should not open three dot menu on holding Alt', () => { + mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyDown'}); + expect(WindowManager.focusThreeDotMenu).not.toHaveBeenCalled(); + }); + + it('should not open three dot menu on Alt as key combp', () => { + mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyDown'}); + mattermostView.handleInputEvents(null, {key: 'F', type: 'keyDown'}); + mattermostView.handleInputEvents(null, {key: 'F', type: 'keyUp'}); + mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyUp'}); + expect(WindowManager.focusThreeDotMenu).not.toHaveBeenCalled(); + }); + }); + + describe('handleDidNavigate', () => { + const mattermostView = new MattermostView(tabView, {}, {}, {}); + + beforeEach(() => { + mattermostView.setBounds = jest.fn(); + }); + + it('should hide back button on internal url', () => { + mattermostView.handleDidNavigate(null, 'http://server-1.com/path/to/channels'); + expect(WindowManager.sendToRenderer).toHaveBeenCalledWith(TOGGLE_BACK_BUTTON, false); + }); + + it('should show back button on external url', () => { + mattermostView.handleDidNavigate(null, 'http://server-2.com/some/other/path'); + expect(WindowManager.sendToRenderer).toHaveBeenCalledWith(TOGGLE_BACK_BUTTON, true); + }); + }); + + describe('handleUpdateTarget', () => { + const mattermostView = new MattermostView(tabView, {}, {}, {}); + + beforeEach(() => { + mattermostView.emit = jest.fn(); + }); + + it('should emit tooltip URL if not internal', () => { + mattermostView.handleUpdateTarget(null, 'http://server-2.com/some/other/path'); + expect(mattermostView.emit).toHaveBeenCalledWith(UPDATE_TARGET_URL, 'http://server-2.com/some/other/path'); + }); + + it('should not emit tooltip URL if internal', () => { + mattermostView.handleUpdateTarget(null, 'http://server-1.com/path/to/channels'); + expect(mattermostView.emit).not.toHaveBeenCalled(); + }); + }); + + describe('updateMentionsFromTitle', () => { + const mattermostView = new MattermostView(tabView, {}, {}, {}); + + it('should parse mentions from title', () => { + mattermostView.updateMentionsFromTitle('(7) Mattermost'); + expect(appState.updateMentions).toHaveBeenCalledWith(mattermostView.tab.name, 7, undefined); + }); + + it('should parse unreads from title', () => { + mattermostView.updateMentionsFromTitle('* Mattermost'); + expect(appState.updateMentions).toHaveBeenCalledWith(mattermostView.tab.name, 0, true); + }); + }); +}); diff --git a/src/main/views/MattermostView.ts b/src/main/views/MattermostView.ts index cab693dc..04fdcb24 100644 --- a/src/main/views/MattermostView.ts +++ b/src/main/views/MattermostView.ts @@ -30,7 +30,7 @@ import {getWindowBoundaries, getLocalPreload, composeUserAgent} from '../utils'; import * as WindowManager from '../windows/windowManager'; import * as appState from '../appState'; -import {removeWebContentsListeners} from './webContentEvents'; +import WebContentsEventManager from './webContentEvents'; export enum Status { LOADING, @@ -215,7 +215,7 @@ export class MattermostView extends EventEmitter { } destroy = () => { - removeWebContentsListeners(this.view.webContents.id); + WebContentsEventManager.removeWebContentsListeners(this.view.webContents.id); appState.updateMentions(this.tab.name, 0, false); if (this.window) { this.window.removeBrowserView(this.view); diff --git a/src/main/views/modalManager.test.js b/src/main/views/modalManager.test.js new file mode 100644 index 00000000..1261c11d --- /dev/null +++ b/src/main/views/modalManager.test.js @@ -0,0 +1,161 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +'use strict'; + +import * as WindowManager from '../windows/windowManager'; + +import {ModalManager} from './modalManager'; + +jest.mock('electron', () => ({ + app: {}, + ipcMain: { + handle: jest.fn(), + on: jest.fn(), + }, +})); + +jest.mock('electron-log', () => ({})); + +jest.mock('./modalView', () => ({ + ModalView: jest.fn(), +})); +jest.mock('../windows/windowManager', () => ({ + sendToRenderer: jest.fn(), + focusBrowserView: jest.fn(), +})); +jest.mock('process', () => ({ + env: {}, +})); + +describe('main/views/modalManager', () => { + describe('addModal', () => { + const modalManager = new ModalManager(); + + beforeEach(() => { + modalManager.modalQueue = []; + modalManager.modalPromises = new Map(); + modalManager.showModal = jest.fn(); + }); + + it('should not add modal with the same key, should return the existing promise', () => { + modalManager.modalQueue.push({key: 'existing_key'}); + const promise = Promise.resolve(); + modalManager.modalPromises.set('existing_key', promise); + expect(modalManager.addModal('existing_key', 'some_html', 'preload', {}, {})).toBe(promise); + }); + + it('should add modal to queue and add the promise, but dont show', () => { + modalManager.modalQueue.push({key: 'existing_key'}); + const promise = Promise.resolve(); + modalManager.modalPromises.set('existing_key', promise); + modalManager.addModal('new_key', 'some_html', 'preload', {}, {}); + expect(modalManager.modalPromises.has('new_key')).toBe(true); + expect(modalManager.modalQueue.length).toBe(2); + expect(modalManager.showModal).not.toBeCalled(); + }); + + it('should add modal to queue and add the promise, but dont show', () => { + modalManager.addModal('new_key', 'some_html', 'preload', {}, {}); + expect(modalManager.modalPromises.has('new_key')).toBe(true); + expect(modalManager.modalQueue.length).toBe(1); + expect(modalManager.showModal).toBeCalled(); + }); + }); + + describe('findModalByCaller', () => { + const modalManager = new ModalManager(); + const modalView = {key: 'test', view: {webContents: {id: 1}}}; + const promise = Promise.resolve(); + + beforeEach(() => { + modalManager.modalQueue = [modalView]; + modalManager.modalPromises = new Map([['test', promise]]); + }); + + it('should return modal by webContentsId', () => { + expect(modalManager.findModalByCaller({sender: {id: 1}})).toBe(modalView); + }); + }); + + describe('showModal', () => { + const oldEnv = process.env; + const modalManager = new ModalManager(); + const modalView = {key: 'test', view: {webContents: {id: 1}}, show: jest.fn(), hide: jest.fn()}; + const modalView2 = {key: 'test2', view: {webContents: {id: 2}}, show: jest.fn(), hide: jest.fn()}; + const promise = Promise.resolve(); + + beforeEach(() => { + jest.resetModules(); + modalManager.modalQueue = [modalView, modalView2]; + modalManager.modalPromises = new Map([['test', promise], ['test2', promise]]); + process.env = {...oldEnv}; + }); + + afterEach(() => { + process.env = oldEnv; + }); + + it('should show first modal and hide second one', () => { + modalManager.showModal(); + expect(modalView.show).toBeCalled(); + expect(modalView.hide).not.toBeCalled(); + }); + + it('should include dev tools when env variable is enabled', () => { + process.env.MM_DEBUG_MODALS = true; + modalManager.showModal(); + expect(modalView.show).toBeCalledWith(undefined, true); + }); + }); + + describe('handleModalFinished', () => { + const modalManager = new ModalManager(); + const modalView = {key: 'test', view: {webContents: {id: 1}}, resolve: jest.fn(), reject: jest.fn()}; + const modalView2 = {key: 'test2', view: {webContents: {id: 2}}, resolve: jest.fn(), reject: jest.fn()}; + const promise = Promise.resolve(); + + beforeEach(() => { + modalManager.modalQueue = [modalView, modalView2]; + modalManager.modalPromises = new Map([['test', promise], ['test2', promise]]); + modalManager.showModal = jest.fn(); + modalManager.filterActive = () => { + modalManager.modalQueue.pop(); + }; + modalManager.findModalByCaller = (event) => { + switch (event.sender.id) { + case 1: + return modalView; + case 2: + return modalView2; + } + return null; + }; + }); + + it('should handle results for specified modal and go to next modal', () => { + modalManager.handleModalFinished('resolve', {sender: {id: 1}}, 'something'); + expect(modalView.resolve).toBeCalledWith('something'); + expect(modalView.reject).not.toBeCalled(); + expect(modalManager.modalPromises.has('test')).toBe(false); + expect(modalManager.modalQueue.length).toBe(1); + expect(modalManager.showModal).toBeCalled(); + }); + + it('should handle cancel for specified modal and go to next modal', () => { + modalManager.handleModalFinished('reject', {sender: {id: 1}}, 'something'); + expect(modalView.reject).toBeCalledWith('something'); + expect(modalView.resolve).not.toBeCalled(); + expect(modalManager.modalPromises.has('test')).toBe(false); + expect(modalManager.modalQueue.length).toBe(1); + expect(modalManager.showModal).toBeCalled(); + }); + + it('should focus main browser view when all modals are gone', () => { + modalManager.modalQueue.pop(); + modalManager.modalPromises.delete('test2'); + modalManager.handleModalFinished('resolve', {sender: {id: 1}}, 'something'); + expect(WindowManager.focusBrowserView).toBeCalled(); + }); + }); +}); diff --git a/src/main/views/modalManager.ts b/src/main/views/modalManager.ts index 34d8c2c3..e7784735 100644 --- a/src/main/views/modalManager.ts +++ b/src/main/views/modalManager.ts @@ -21,116 +21,120 @@ import * as WindowManager from '../windows/windowManager'; import {ModalView} from './modalView'; -let modalQueue: Array> = []; -const modalPromises: Map> = new Map(); +export class ModalManager { + modalQueue: Array>; + modalPromises: Map>; -// TODO: add a queue/add differentiation, in case we need to put a modal first in line -export function addModal(key: string, html: string, preload: string, data: T, win: BrowserWindow, uncloseable = false) { - const foundModal = modalQueue.find((modal) => modal.key === key); - if (!foundModal) { - const modalPromise = new Promise((resolve: (value: T2) => void, reject) => { - const mv = new ModalView(key, html, preload, data, resolve, reject, win, uncloseable); - modalQueue.push(mv); - }); + constructor() { + this.modalQueue = []; + this.modalPromises = new Map(); - if (modalQueue.length === 1) { - showModal(); + ipcMain.handle(GET_MODAL_UNCLOSEABLE, this.handleGetModalUncloseable); + ipcMain.handle(RETRIEVE_MODAL_INFO, this.handleInfoRequest); + ipcMain.on(MODAL_RESULT, this.handleModalResult); + ipcMain.on(MODAL_CANCEL, this.handleModalCancel); + + ipcMain.on(EMIT_CONFIGURATION, this.handleEmitConfiguration); + } + + // TODO: add a queue/add differentiation, in case we need to put a modal first in line + addModal = (key: string, html: string, preload: string, data: T, win: BrowserWindow, uncloseable = false) => { + const foundModal = this.modalQueue.find((modal) => modal.key === key); + if (!foundModal) { + const modalPromise = new Promise((resolve: (value: T2) => void, reject) => { + const mv = new ModalView(key, html, preload, data, resolve, reject, win, uncloseable); + this.modalQueue.push(mv); + }); + + if (this.modalQueue.length === 1) { + this.showModal(); + } + + this.modalPromises.set(key, modalPromise); + return modalPromise; } - - modalPromises.set(key, modalPromise); - return modalPromise; + return this.modalPromises.get(key) as Promise; } - return modalPromises.get(key) as Promise; -} -ipcMain.handle(GET_MODAL_UNCLOSEABLE, handleGetModalUncloseable); -ipcMain.handle(RETRIEVE_MODAL_INFO, handleInfoRequest); -ipcMain.on(MODAL_RESULT, handleModalResult); -ipcMain.on(MODAL_CANCEL, handleModalCancel); + findModalByCaller = (event: IpcMainInvokeEvent) => { + if (this.modalQueue.length) { + const requestModal = this.modalQueue.find((modal) => { + return (modal.view && modal.view.webContents && modal.view.webContents.id === event.sender.id); + }); + return requestModal; + } + return null; + } -function findModalByCaller(event: IpcMainInvokeEvent) { - if (modalQueue.length) { - const requestModal = modalQueue.find((modal) => { - return (modal.view && modal.view.webContents && modal.view.webContents.id === event.sender.id); + handleInfoRequest = (event: IpcMainInvokeEvent) => { + const requestModal = this.findModalByCaller(event); + if (requestModal) { + return requestModal.handleInfoRequest(); + } + return null; + } + + showModal = () => { + const withDevTools = process.env.MM_DEBUG_MODALS || false; + this.modalQueue.forEach((modal, index) => { + if (index === 0) { + WindowManager.sendToRenderer(MODAL_OPEN); + modal.show(undefined, Boolean(withDevTools)); + } else { + WindowManager.sendToRenderer(MODAL_CLOSE); + modal.hide(); + } }); - return requestModal; } - return null; -} -function handleInfoRequest(event: IpcMainInvokeEvent) { - const requestModal = findModalByCaller(event); - if (requestModal) { - return requestModal.handleInfoRequest(); - } - return null; -} - -export function showModal() { - const withDevTools = process.env.MM_DEBUG_MODALS || false; - modalQueue.forEach((modal, index) => { - if (index === 0) { - WindowManager.sendToRenderer(MODAL_OPEN); - modal.show(undefined, Boolean(withDevTools)); + handleModalFinished = (mode: 'resolve' | 'reject', event: IpcMainEvent, data: unknown) => { + const requestModal = this.findModalByCaller(event); + if (requestModal) { + if (mode === 'resolve') { + requestModal.resolve(data); + } else { + requestModal.reject(data); + } + this.modalPromises.delete(requestModal.key); + } + this.filterActive(); + if (this.modalQueue.length) { + this.showModal(); } else { WindowManager.sendToRenderer(MODAL_CLOSE); - modal.hide(); + WindowManager.focusBrowserView(); } - }); -} - -function handleModalResult(event: IpcMainEvent, data: unknown) { - const requestModal = findModalByCaller(event); - if (requestModal) { - requestModal.resolve(data); - modalPromises.delete(requestModal.key); } - filterActive(); - if (modalQueue.length) { - showModal(); - } else { - WindowManager.sendToRenderer(MODAL_CLOSE); - WindowManager.focusBrowserView(); + + handleModalResult = (event: IpcMainEvent, data: unknown) => this.handleModalFinished('resolve', event, data); + + handleModalCancel = (event: IpcMainEvent, data: unknown) => this.handleModalFinished('reject', event, data); + + filterActive = () => { + this.modalQueue = this.modalQueue.filter((modal) => modal.isActive()); + } + + isModalDisplayed = () => { + return this.modalQueue.some((modal) => modal.isActive()); + } + + focusCurrentModal = () => { + if (this.isModalDisplayed()) { + this.modalQueue[0].view.webContents.focus(); + } + } + + handleEmitConfiguration = (event: IpcMainEvent, config: CombinedConfig) => { + this.modalQueue.forEach((modal) => { + modal.view.webContents.send(DARK_MODE_CHANGE, config.darkMode); + }); + } + + handleGetModalUncloseable = (event: IpcMainInvokeEvent) => { + const modalView = this.modalQueue.find((modal) => modal.view.webContents.id === event.sender.id); + return modalView?.uncloseable; } } -function handleModalCancel(event: IpcMainEvent, data: unknown) { - const requestModal = findModalByCaller(event); - if (requestModal) { - requestModal.reject(data); - modalPromises.delete(requestModal.key); - } - filterActive(); - if (modalQueue.length) { - showModal(); - } else { - WindowManager.sendToRenderer(MODAL_CLOSE); - WindowManager.focusBrowserView(); - } -} - -function filterActive() { - modalQueue = modalQueue.filter((modal) => modal.isActive()); -} - -export function isModalDisplayed() { - return modalQueue.some((modal) => modal.isActive()); -} - -export function focusCurrentModal() { - if (isModalDisplayed()) { - modalQueue[0].view.webContents.focus(); - } -} - -ipcMain.on(EMIT_CONFIGURATION, (event: IpcMainEvent, config: CombinedConfig) => { - modalQueue.forEach((modal) => { - modal.view.webContents.send(DARK_MODE_CHANGE, config.darkMode); - }); -}); - -function handleGetModalUncloseable(event: IpcMainInvokeEvent) { - const modalView = modalQueue.find((modal) => modal.view.webContents.id === event.sender.id); - return modalView?.uncloseable; -} - +const modalManager = new ModalManager(); +export default modalManager; diff --git a/src/main/views/modalView.test.js b/src/main/views/modalView.test.js new file mode 100644 index 00000000..6670ac33 --- /dev/null +++ b/src/main/views/modalView.test.js @@ -0,0 +1,123 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +'use strict'; + +import {ModalView} from './modalView'; + +jest.mock('electron', () => ({ + BrowserView: jest.fn().mockImplementation(() => ({ + webContents: { + loadURL: jest.fn(), + once: jest.fn(), + isLoading: jest.fn(), + focus: jest.fn(), + openDevTools: jest.fn(), + isDevToolsOpened: jest.fn(), + closeDevTools: jest.fn(), + destroy: jest.fn(), + }, + setBounds: jest.fn(), + setAutoResize: jest.fn(), + })), +})); + +jest.mock('electron-log', () => ({ + info: jest.fn(), +})); + +jest.mock('../contextMenu', () => jest.fn()); + +jest.mock('../utils', () => ({ + getWindowBoundaries: jest.fn(), +})); + +describe('main/views/modalView', () => { + describe('show', () => { + const window = {addBrowserView: jest.fn(), removeBrowserView: jest.fn()}; + const onResolve = jest.fn(); + const onReject = jest.fn(); + let modalView; + + beforeEach(() => { + modalView = new ModalView( + 'test_modal', + 'some_html', + 'preload', + {value1: 'value-1', value2: 'value-2'}, + onResolve, + onReject, + window, + false, + ); + + modalView.view.webContents.isLoading = jest.fn().mockReturnValue(false); + }); + + it('should add to window', () => { + modalView.show(); + expect(window.addBrowserView).toBeCalledWith(modalView.view); + expect(modalView.status).toBe(1); + }); + + it('should reattach if already attached', () => { + modalView.windowAttached = window; + modalView.show(); + expect(window.removeBrowserView).toBeCalledWith(modalView.view); + expect(window.addBrowserView).toBeCalledWith(modalView.view); + }); + + it('should delay call to focus when the modal is loading', () => { + let callback; + modalView.view.webContents.isLoading = jest.fn().mockReturnValue(true); + modalView.view.webContents.once = jest.fn().mockImplementation((event, cb) => { + callback = cb; + }); + modalView.show(); + expect(modalView.view.webContents.once).toHaveBeenCalled(); + expect(modalView.view.webContents.focus).not.toHaveBeenCalled(); + callback(); + expect(modalView.view.webContents.focus).toHaveBeenCalled(); + }); + + it('should open dev tools when specified', () => { + modalView.show(undefined, true); + expect(modalView.view.webContents.openDevTools).toHaveBeenCalled(); + }); + }); + + describe('hide', () => { + const window = {addBrowserView: jest.fn(), removeBrowserView: jest.fn()}; + const onResolve = jest.fn(); + const onReject = jest.fn(); + let modalView; + + beforeEach(() => { + modalView = new ModalView( + 'test_modal', + 'some_html', + 'preload', + {value1: 'value-1', value2: 'value-2'}, + onResolve, + onReject, + window, + false, + ); + + modalView.view.webContents.isLoading = jest.fn().mockReturnValue(false); + modalView.windowAttached = window; + }); + + it('should remove browser view and destroy web contents on hide', () => { + modalView.hide(); + expect(modalView.view.webContents.destroy).toBeCalled(); + expect(window.removeBrowserView).toBeCalledWith(modalView.view); + }); + + it('should close dev tools when open', () => { + modalView.view.webContents.isDevToolsOpened = jest.fn().mockReturnValue(true); + modalView.hide(); + expect(modalView.view.webContents.closeDevTools).toBeCalled(); + }); + }); +}); diff --git a/src/main/views/teamDropdownView.test.js b/src/main/views/teamDropdownView.test.js new file mode 100644 index 00000000..2f238a86 --- /dev/null +++ b/src/main/views/teamDropdownView.test.js @@ -0,0 +1,60 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +'use strict'; + +import {TAB_BAR_HEIGHT, THREE_DOT_MENU_WIDTH, THREE_DOT_MENU_WIDTH_MAC, MENU_SHADOW_WIDTH} from 'common/utils/constants'; + +import TeamDropdownView from './teamDropdownView'; + +jest.mock('main/utils', () => ({ + getLocalPreload: (file) => file, + getLocalURLString: (file) => file, +})); + +jest.mock('electron', () => ({ + BrowserView: jest.fn().mockImplementation(() => ({ + webContents: { + loadURL: jest.fn(), + focus: jest.fn(), + }, + setBounds: jest.fn(), + })), + ipcMain: { + on: jest.fn(), + }, +})); + +jest.mock('../windows/windowManager', () => ({ + sendToRenderer: jest.fn(), +})); + +describe('main/views/teamDropdownView', () => { + const window = { + getContentBounds: () => ({width: 500, height: 400, x: 0, y: 0}), + addBrowserView: jest.fn(), + setTopBrowserView: jest.fn(), + }; + + describe('getBounds', () => { + const teamDropdownView = new TeamDropdownView(window, [], false, true); + if (process.platform === 'darwin') { + it('should account for three dot menu, tab bar and shadow', () => { + expect(teamDropdownView.getBounds(400, 300)).toStrictEqual({x: THREE_DOT_MENU_WIDTH_MAC - MENU_SHADOW_WIDTH, y: TAB_BAR_HEIGHT - MENU_SHADOW_WIDTH, width: 400, height: 300}); + }); + } else { + it('should account for three dot menu, tab bar and shadow', () => { + expect(teamDropdownView.getBounds(400, 300)).toStrictEqual({x: THREE_DOT_MENU_WIDTH - MENU_SHADOW_WIDTH, y: TAB_BAR_HEIGHT - MENU_SHADOW_WIDTH, width: 400, height: 300}); + }); + } + }); + + it('should change the view bounds based on open/closed state', () => { + const teamDropdownView = new TeamDropdownView(window, [], false, true); + teamDropdownView.bounds = {width: 400, height: 300}; + teamDropdownView.handleOpen(); + expect(teamDropdownView.view.setBounds).toBeCalledWith(teamDropdownView.bounds); + teamDropdownView.handleClose(); + expect(teamDropdownView.view.setBounds).toBeCalledWith({width: 0, height: 0, x: expect.any(Number), y: expect.any(Number)}); + }); +}); diff --git a/src/main/views/viewManager.test.js b/src/main/views/viewManager.test.js new file mode 100644 index 00000000..91f91852 --- /dev/null +++ b/src/main/views/viewManager.test.js @@ -0,0 +1,704 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable max-lines */ +'use strict'; + +import {dialog} from 'electron'; + +import {BROWSER_HISTORY_PUSH, LOAD_SUCCESS} from 'common/communication'; +import {MattermostServer} from 'common/servers/MattermostServer'; +import {getServerView, getTabViewName} from 'common/tabs/TabView'; +import urlUtils from 'common/utils/url'; + +import {MattermostView} from './MattermostView'; +import {ViewManager} from './viewManager'; + +jest.mock('electron', () => ({ + dialog: { + showErrorBox: jest.fn(), + }, + ipcMain: { + emit: jest.fn(), + }, +})); + +jest.mock('electron-log', () => ({ + warn: jest.fn(), + error: jest.fn(), +})); + +jest.mock('common/tabs/TabView', () => ({ + getServerView: jest.fn(), + getTabViewName: jest.fn(), +})); + +jest.mock('common/servers/MattermostServer', () => ({ + MattermostServer: jest.fn(), +})); + +jest.mock('common/utils/url', () => ({ + parseURL: (url) => { + try { + return new URL(url); + } catch (e) { + return null; + } + }, + getView: jest.fn(), +})); + +jest.mock('main/server/serverInfo', () => ({ + ServerInfo: jest.fn(), +})); + +jest.mock('./MattermostView', () => ({ + MattermostView: jest.fn(), +})); + +jest.mock('./modalManager', () => ({ + showModal: jest.fn(), +})); +jest.mock('./webContentEvents', () => ({})); + +describe('main/views/viewManager', () => { + describe('loadView', () => { + const viewManager = new ViewManager({}, {}); + const onceFn = jest.fn(); + const loadFn = jest.fn(); + + beforeEach(() => { + viewManager.createLoadingScreen = jest.fn(); + viewManager.showByName = jest.fn(); + getServerView.mockImplementation((srv, tab) => ({name: `${srv.name}-${tab.name}`})); + MattermostView.mockImplementation(() => ({ + on: jest.fn(), + load: loadFn, + once: onceFn, + })); + }); + + afterEach(() => { + jest.resetAllMocks(); + viewManager.loadingScreen = undefined; + viewManager.closedViews = new Map(); + viewManager.views = new Map(); + }); + + it('should add closed tabs to closedViews', () => { + viewManager.loadView({name: 'server1'}, {}, {name: 'tab1', isOpen: false}); + expect(viewManager.closedViews.has('server1-tab1')).toBe(true); + }); + + it('should remove from remove from closedViews when the tab is open', () => { + viewManager.closedViews.set('server1-tab1', {}); + expect(viewManager.closedViews.has('server1-tab1')).toBe(true); + viewManager.loadView({name: 'server1'}, {}, {name: 'tab1', isOpen: true}); + expect(viewManager.closedViews.has('server1-tab1')).toBe(false); + }); + + it('should add view to views map, add listeners and show the view', () => { + viewManager.loadView({name: 'server1'}, {}, {name: 'tab1', isOpen: true}, 'http://server-1.com/subpath'); + expect(viewManager.views.has('server1-tab1')).toBe(true); + expect(viewManager.createLoadingScreen).toHaveBeenCalled(); + expect(onceFn).toHaveBeenCalledWith(LOAD_SUCCESS, viewManager.activateView); + expect(loadFn).toHaveBeenCalledWith('http://server-1.com/subpath'); + expect(viewManager.showByName).toHaveBeenCalledWith('server1-tab1'); + }); + }); + + describe('reloadViewIfNeeded', () => { + const viewManager = new ViewManager({}, {}); + + afterEach(() => { + jest.resetAllMocks(); + viewManager.views = new Map(); + }); + + it('should reload view when URL is not on subpath of original server URL', () => { + const view = { + load: jest.fn(), + view: { + webContents: { + getURL: () => 'http://server-2.com/subpath', + }, + }, + tab: { + url: new URL('http://server-1.com/'), + }, + }; + viewManager.views.set('view1', view); + viewManager.reloadViewIfNeeded('view1'); + expect(view.load).toHaveBeenCalledWith(new URL('http://server-1.com/')); + }); + + it('should not reload if URLs are matching', () => { + const view = { + load: jest.fn(), + view: { + webContents: { + getURL: () => 'http://server-1.com/', + }, + }, + tab: { + url: new URL('http://server-1.com/'), + }, + }; + viewManager.views.set('view1', view); + viewManager.reloadViewIfNeeded('view1'); + expect(view.load).not.toHaveBeenCalled(); + }); + + it('should not reload if URL is subpath of server URL', () => { + const view = { + load: jest.fn(), + view: { + webContents: { + getURL: () => 'http://server-1.com/subpath', + }, + }, + tab: { + url: new URL('http://server-1.com/'), + }, + }; + viewManager.views.set('view1', view); + viewManager.reloadViewIfNeeded('view1'); + expect(view.load).not.toHaveBeenCalled(); + }); + }); + + describe('reloadConfiguration', () => { + const viewManager = new ViewManager({}, {}); + + beforeEach(() => { + viewManager.loadView = jest.fn(); + viewManager.showByName = jest.fn(); + viewManager.showInitial = jest.fn(); + getServerView.mockImplementation((srv, tab) => ({name: `${srv.name}-${tab.name}`, url: new URL(`http://${srv.name}.com`)})); + MattermostServer.mockImplementation((name, url) => ({ + name, + url: new URL(url), + })); + }); + + afterEach(() => { + jest.resetAllMocks(); + delete viewManager.loadingScreen; + delete viewManager.currentView; + viewManager.closedViews = new Map(); + viewManager.views = new Map(); + }); + + it('should recycle existing views', () => { + const view = { + name: 'server1-tab1', + tab: { + name: 'server1-tab1', + url: new URL('http://server1.com'), + }, + }; + viewManager.views.set('server1-tab1', view); + viewManager.reloadConfiguration([ + { + name: 'server1', + url: 'http://server1.com', + order: 1, + tabs: [ + { + name: 'tab1', + isOpen: true, + }, + ], + }, + ]); + expect(viewManager.views.get('server1-tab1')).toBe(view); + expect(viewManager.loadView).not.toHaveBeenCalled(); + }); + + it('should close tabs that arent open', () => { + viewManager.reloadConfiguration([ + { + name: 'server1', + url: 'http://server1.com', + order: 1, + tabs: [ + { + name: 'tab1', + isOpen: false, + }, + ], + }, + ]); + expect(viewManager.closedViews.has('server1-tab1')).toBe(true); + }); + + it('should create new views for new tabs', () => { + viewManager.reloadConfiguration([ + { + name: 'server1', + url: 'http://server1.com', + order: 1, + tabs: [ + { + name: 'tab1', + isOpen: true, + }, + ], + }, + ]); + expect(viewManager.loadView).toHaveBeenCalledWith({ + name: 'server1', + url: new URL('http://server1.com'), + }, expect.any(Object), { + name: 'tab1', + isOpen: true, + }); + }); + + it('should set focus to current view on reload', () => { + const view = { + name: 'server1-tab1', + tab: { + name: 'server1-tab1', + url: new URL('http://server1.com'), + }, + }; + viewManager.currentView = 'server1-tab1'; + viewManager.views.set('server1-tab1', view); + viewManager.reloadConfiguration([ + { + name: 'server1', + url: 'http://server1.com', + order: 1, + tabs: [ + { + name: 'tab1', + isOpen: true, + }, + ], + }, + ]); + expect(viewManager.showByName).toHaveBeenCalledWith('server1-tab1'); + }); + + it('should show initial if currentView has been removed', () => { + const view = { + name: 'server1-tab1', + tab: { + name: 'server1-tab1', + url: new URL('http://server1.com'), + }, + destroy: jest.fn(), + }; + viewManager.currentView = 'server1-tab1'; + viewManager.views.set('server1-tab1', view); + viewManager.reloadConfiguration([ + { + name: 'server2', + url: 'http://server2.com', + order: 1, + tabs: [ + { + name: 'tab1', + isOpen: true, + }, + ], + }, + ]); + expect(viewManager.currentView).toBeUndefined(); + expect(viewManager.showInitial).toBeCalled(); + }); + + it('should remove unused views', () => { + const view = { + name: 'server1-tab1', + tab: { + name: 'server1-tab1', + url: new URL('http://server1.com'), + }, + destroy: jest.fn(), + }; + viewManager.views.set('server1-tab1', view); + viewManager.reloadConfiguration([ + { + name: 'server2', + url: 'http://server2.com', + order: 1, + tabs: [ + { + name: 'tab1', + isOpen: true, + }, + ], + }, + ]); + expect(view.destroy).toBeCalled(); + expect(viewManager.showInitial).toBeCalled(); + }); + }); + + describe('showInitial', () => { + const teams = [{ + name: 'server-1', + order: 1, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + { + name: 'tab-3', + order: 1, + isOpen: true, + }, + ], + }, { + name: 'server-2', + order: 0, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + { + name: 'tab-3', + order: 1, + isOpen: true, + }, + ], + }]; + const viewManager = new ViewManager({teams}, {}); + + beforeEach(() => { + viewManager.showByName = jest.fn(); + getTabViewName.mockImplementation((server, tab) => `${server}_${tab}`); + }); + + afterEach(() => { + jest.resetAllMocks(); + viewManager.configServers = teams; + delete viewManager.lastActiveServer; + }); + + it('should show first server and first open tab in order when last active not defined', () => { + viewManager.showInitial(); + expect(viewManager.showByName).toHaveBeenCalledWith('server-2_tab-3'); + }); + + it('should show first tab in order of last active server', () => { + viewManager.lastActiveServer = 1; + viewManager.showInitial(); + expect(viewManager.showByName).toHaveBeenCalledWith('server-1_tab-3'); + }); + + it('should show last active tab of first server', () => { + viewManager.configServers = [{ + name: 'server-1', + order: 1, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + { + name: 'tab-3', + order: 1, + isOpen: true, + }, + ], + }, { + name: 'server-2', + order: 0, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + { + name: 'tab-3', + order: 1, + isOpen: true, + }, + ], + lastActiveTab: 2, + }]; + viewManager.showInitial(); + expect(viewManager.showByName).toHaveBeenCalledWith('server-2_tab-2'); + }); + + it('should show next tab when last active tab is closed', () => { + viewManager.configServers = [{ + name: 'server-1', + order: 1, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + { + name: 'tab-3', + order: 1, + isOpen: true, + }, + ], + }, { + name: 'server-2', + order: 0, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: true, + }, + { + name: 'tab-2', + order: 2, + isOpen: false, + }, + { + name: 'tab-3', + order: 1, + isOpen: true, + }, + ], + lastActiveTab: 2, + }]; + viewManager.showInitial(); + expect(viewManager.showByName).toHaveBeenCalledWith('server-2_tab-1'); + }); + }); + + describe('showByName', () => { + const viewManager = new ViewManager({}, {}); + const baseView = { + isReady: jest.fn(), + show: jest.fn(), + hide: jest.fn(), + needsLoadingScreen: jest.fn(), + window: { + webContents: { + send: jest.fn(), + }, + }, + tab: { + server: { + name: 'server-1', + }, + type: 'tab-1', + }, + }; + + beforeEach(() => { + viewManager.getCurrentView = jest.fn(); + viewManager.showLoadingScreen = jest.fn(); + viewManager.fadeLoadingScreen = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + viewManager.views = new Map(); + delete viewManager.currentView; + }); + + it('should do nothing when view is already visible or if view doesnt exist', () => { + const view = { + ...baseView, + isVisible: true, + }; + viewManager.views.set('server1-tab1', view); + + viewManager.showByName('server1-tab1'); + expect(viewManager.currentView).toBeUndefined(); + expect(view.isReady).not.toBeCalled(); + expect(view.show).not.toBeCalled(); + + viewManager.showByName('some-view-name'); + expect(viewManager.currentView).toBeUndefined(); + expect(view.isReady).not.toBeCalled(); + expect(view.show).not.toBeCalled(); + }); + + it('should hide current view when new view is shown', () => { + const oldView = { + ...baseView, + isVisible: true, + }; + const newView = { + ...baseView, + isVisible: false, + }; + viewManager.getCurrentView.mockImplementation(() => oldView); + viewManager.views.set('oldView', oldView); + viewManager.views.set('newView', newView); + viewManager.currentView = 'oldView'; + viewManager.showByName('newView'); + expect(oldView.hide).toHaveBeenCalled(); + }); + + it('should show loading screen when the view needs it', () => { + const view = {...baseView}; + view.needsLoadingScreen.mockImplementation(() => true); + viewManager.views.set('view1', view); + viewManager.showByName('view1'); + expect(viewManager.showLoadingScreen).toHaveBeenCalled(); + }); + + it('should show the view when ready', () => { + const view = {...baseView}; + view.needsLoadingScreen.mockImplementation(() => false); + view.isReady.mockImplementation(() => true); + viewManager.views.set('view1', view); + viewManager.showByName('view1'); + expect(viewManager.currentView).toBe('view1'); + expect(view.show).toHaveBeenCalled(); + expect(viewManager.fadeLoadingScreen).toHaveBeenCalled(); + }); + }); + + describe('showLoadingScreen', () => { + const window = { + getBrowserViews: jest.fn(), + setTopBrowserView: jest.fn(), + addBrowserView: jest.fn(), + }; + const viewManager = new ViewManager({}, window); + const loadingScreen = {webContents: {send: jest.fn()}}; + + beforeEach(() => { + viewManager.createLoadingScreen = jest.fn(); + viewManager.setLoadingScreenBounds = jest.fn(); + window.getBrowserViews.mockImplementation(() => []); + }); + + afterEach(() => { + jest.resetAllMocks(); + delete viewManager.loadingScreen; + }); + + it('should create new loading screen if one doesnt exist and add it to the window', () => { + viewManager.createLoadingScreen.mockImplementation(() => { + viewManager.loadingScreen = loadingScreen; + }); + viewManager.showLoadingScreen(); + expect(viewManager.createLoadingScreen).toHaveBeenCalled(); + expect(window.addBrowserView).toHaveBeenCalled(); + }); + + it('should set the browser view as top if already exists and needs to be shown', () => { + viewManager.loadingScreen = loadingScreen; + window.getBrowserViews.mockImplementation(() => [loadingScreen]); + viewManager.showLoadingScreen(); + expect(window.setTopBrowserView).toHaveBeenCalled(); + }); + }); + + describe('handleDeepLink', () => { + const viewManager = new ViewManager({}, {}); + const baseView = { + resetLoadingStatus: jest.fn(), + load: jest.fn(), + once: jest.fn(), + isInitialized: jest.fn(), + view: { + webContents: { + send: jest.fn(), + }, + }, + serverInfo: { + remoteInfo: { + serverVersion: '1.0.0', + }, + }, + }; + + beforeEach(() => { + viewManager.openClosedTab = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + viewManager.views = new Map(); + viewManager.closedViews = new Map(); + }); + + it('should load URL into matching view', () => { + urlUtils.getView.mockImplementation(() => ({name: 'view1', url: 'http://server-1.com/'})); + const view = {...baseView}; + viewManager.views.set('view1', view); + viewManager.handleDeepLink('mattermost://server-1.com/deep/link?thing=yes'); + expect(view.load).toHaveBeenCalledWith('http://server-1.com/deep/link?thing=yes'); + }); + + it('should send the URL to the view if its already loaded on a 6.0 server', () => { + urlUtils.getView.mockImplementation(() => ({name: 'view1', url: 'http://server-1.com/'})); + const view = { + ...baseView, + serverInfo: { + remoteInfo: { + serverVersion: '6.0.0', + }, + }, + tab: { + server: { + url: new URL('http://server-1.com'), + }, + }, + }; + view.isInitialized.mockImplementation(() => true); + viewManager.views.set('view1', view); + viewManager.handleDeepLink('mattermost://server-1.com/deep/link?thing=yes'); + expect(view.view.webContents.send).toHaveBeenCalledWith(BROWSER_HISTORY_PUSH, '/deep/link?thing=yes'); + }); + + it('should throw error if view is missing', () => { + urlUtils.getView.mockImplementation(() => ({name: 'view1', url: 'http://server-1.com/'})); + const view = {...baseView}; + viewManager.handleDeepLink('mattermost://server-1.com/deep/link?thing=yes'); + expect(view.load).not.toHaveBeenCalled(); + }); + + it('should throw dialog when cannot find the view', () => { + const view = {...baseView}; + viewManager.handleDeepLink('mattermost://server-1.com/deep/link?thing=yes'); + expect(view.load).not.toHaveBeenCalled(); + expect(dialog.showErrorBox).toHaveBeenCalled(); + }); + + it('should reopen closed tab if called upon', () => { + urlUtils.getView.mockImplementation(() => ({name: 'view1', url: 'https://server-1.com/'})); + viewManager.closedViews.set('view1', {}); + viewManager.handleDeepLink('mattermost://server-1.com/deep/link?thing=yes'); + expect(viewManager.openClosedTab).toHaveBeenCalledWith('view1', 'https://server-1.com/deep/link?thing=yes'); + }); + }); +}); diff --git a/src/main/views/viewManager.ts b/src/main/views/viewManager.ts index 2ae3e800..80c3e228 100644 --- a/src/main/views/viewManager.ts +++ b/src/main/views/viewManager.ts @@ -21,16 +21,16 @@ import { } from 'common/communication'; import urlUtils from 'common/utils/url'; import Utils from 'common/utils/util'; - +import {MattermostServer} from 'common/servers/MattermostServer'; import {getServerView, getTabViewName} from 'common/tabs/TabView'; import {ServerInfo} from 'main/server/serverInfo'; -import {MattermostServer} from '../../common/servers/MattermostServer'; + import {getLocalURLString, getLocalPreload, getWindowBoundaries} from '../utils'; -import {MattermostView, Status} from './MattermostView'; -import {showModal, isModalDisplayed, focusCurrentModal} from './modalManager'; -import {addWebContentsEventListeners} from './webContentEvents'; +import {MattermostView} from './MattermostView'; +import modalManager from './modalManager'; +import WebContentsEventManager from './webContentEvents'; const URL_VIEW_DURATION = 10 * SECOND; const URL_VIEW_HEIGHT = 36; @@ -94,7 +94,7 @@ export class ViewManager { reloadViewIfNeeded = (viewName: string) => { const view = this.views.get(viewName); - if (view && !view.view.webContents.getURL().startsWith(view.tab.url.toString())) { + if (view && view.view.webContents.getURL() !== view.tab.url.toString() && !view.view.webContents.getURL().startsWith(view.tab.url.toString())) { view.load(view.tab.url); } } @@ -153,7 +153,7 @@ export class ViewManager { let tab = element.tabs.find((tab) => tab.order === element.lastActiveTab) || element.tabs.find((tab) => tab.order === 0); if (!tab?.isOpen) { const openTabs = element.tabs.filter((tab) => tab.isOpen); - tab = openTabs.find((e) => e.order === 0) || openTabs[0]; + tab = openTabs.find((e) => e.order === 0) || openTabs.concat().sort((a, b) => a.order - b.order)[0]; } if (tab) { const tabView = getTabViewName(element.name, tab.name); @@ -186,26 +186,21 @@ export class ViewManager { // if view is not ready, the renderer will have something to display instead. newView.show(); ipcMain.emit(UPDATE_LAST_ACTIVE, true, newView.tab.server.name, newView.tab.type); - if (newView.needsLoadingScreen()) { - this.showLoadingScreen(); - } else { + if (!newView.needsLoadingScreen()) { this.fadeLoadingScreen(); } } else { log.warn(`couldn't show ${name}, not ready`); - if (newView.needsLoadingScreen()) { - this.showLoadingScreen(); - } } } else { log.warn(`Couldn't find a view with name: ${name}`); } - showModal(); + modalManager.showModal(); } focus = () => { - if (isModalDisplayed()) { - focusCurrentModal(); + if (modalManager.isModalDisplayed()) { + modalManager.focusCurrentModal(); return; } @@ -214,6 +209,7 @@ export class ViewManager { view.focus(); } } + activateView = (viewName: string) => { if (this.currentView === viewName) { this.showByName(this.currentView); @@ -223,7 +219,7 @@ export class ViewManager { log.error(`Couldn't find a view with the name ${viewName}`); return; } - addWebContentsEventListeners(view, this.getServers); + WebContentsEventManager.addWebContentsEventListeners(view, this.getServers); } finishLoading = (server: string) => { @@ -428,7 +424,7 @@ export class ViewManager { return; } - if (view.status === Status.READY && view.serverInfo.remoteInfo.serverVersion && Utils.isVersionGreaterThanOrEqualTo(view.serverInfo.remoteInfo.serverVersion, '6.0.0')) { + if (view.isInitialized() && view.serverInfo.remoteInfo.serverVersion && Utils.isVersionGreaterThanOrEqualTo(view.serverInfo.remoteInfo.serverVersion, '6.0.0')) { const pathName = `/${urlWithSchema.replace(view.tab.server.url.toString(), '')}`; view.view.webContents.send(BROWSER_HISTORY_PUSH, pathName); this.deeplinkSuccess(view.name); diff --git a/src/main/views/webContentEvents.test.js b/src/main/views/webContentEvents.test.js new file mode 100644 index 00000000..08565194 --- /dev/null +++ b/src/main/views/webContentEvents.test.js @@ -0,0 +1,236 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +'use strict'; + +import {shell} from 'electron'; + +import urlUtils from 'common/utils/url'; + +import * as WindowManager from '../windows/windowManager'; +import allowProtocolDialog from '../allowProtocolDialog'; + +import {WebContentsEventManager} from './webContentEvents'; + +jest.mock('electron', () => ({ + app: {}, + shell: { + openExternal: jest.fn(), + }, + BrowserWindow: jest.fn().mockImplementation(() => ({ + once: jest.fn(), + show: jest.fn(), + loadURL: jest.fn(), + webContents: { + setWindowOpenHandler: jest.fn(), + }, + })), +})); + +jest.mock('electron-log', () => ({ + info: jest.fn(), + warn: jest.fn(), +})); + +jest.mock('../allowProtocolDialog', () => ({})); +jest.mock('../windows/windowManager', () => ({ + showMainWindow: jest.fn(), +})); + +jest.mock('common/utils/url', () => ({ + parseURL: (url) => { + try { + return new URL(url); + } catch (e) { + return null; + } + }, + getView: jest.fn(), + isTeamUrl: jest.fn(), + isAdminUrl: jest.fn(), + isTrustedPopupWindow: jest.fn(), + isTrustedURL: jest.fn(), + isCustomLoginURL: jest.fn(), + isInternalURL: jest.fn(), + isValidURI: jest.fn(), + isPluginUrl: jest.fn(), + isManagedResource: jest.fn(), +})); + +jest.mock('../../../electron-builder.json', () => ({ + protocols: [ + { + name: 'Mattermost', + schemes: ['mattermost'], + }, + ], +})); + +jest.mock('../allowProtocolDialog', () => ({ + handleDialogEvent: jest.fn(), +})); + +describe('main/views/webContentsEvents', () => { + const event = {preventDefault: jest.fn(), sender: {id: 1}}; + + describe('willNavigate', () => { + const webContentsEventManager = new WebContentsEventManager(); + const willNavigate = webContentsEventManager.generateWillNavigate(jest.fn()); + + beforeEach(() => { + urlUtils.getView.mockImplementation(() => ({name: 'server_name', url: 'http://server-1.com'})); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + webContentsEventManager.customLogins = {}; + webContentsEventManager.popupWindow = undefined; + }); + + it('should allow navigation when url isTeamURL', () => { + urlUtils.isTeamUrl.mockImplementation((serverURL, parsedURL) => parsedURL.toString().startsWith(serverURL)); + willNavigate(event, 'http://server-1.com/subpath'); + expect(event.preventDefault).not.toBeCalled(); + }); + + it('should allow navigation when url isAdminURL', () => { + urlUtils.isAdminUrl.mockImplementation((serverURL, parsedURL) => parsedURL.toString().startsWith(`${serverURL}/admin_console`)); + willNavigate(event, 'http://server-1.com/admin_console/subpath'); + expect(event.preventDefault).not.toBeCalled(); + }); + + it('should allow navigation when isTrustedPopup', () => { + const spy = jest.spyOn(webContentsEventManager, 'isTrustedPopupWindow'); + spy.mockReturnValue(true); + willNavigate(event, 'http://externalurl.com/popup/subpath'); + expect(event.preventDefault).not.toBeCalled(); + }); + + it('should allow navigation when isCustomLoginURL', () => { + urlUtils.isCustomLoginURL.mockImplementation((parsedURL) => parsedURL.toString().startsWith('http://loginurl.com/login')); + willNavigate(event, 'http://loginurl.com/login/oauth'); + expect(event.preventDefault).not.toBeCalled(); + }); + + it('should allow navigation when protocol is mailto', () => { + willNavigate(event, 'mailto:test@mattermost.com'); + expect(event.preventDefault).not.toBeCalled(); + }); + + it('should allow navigation when a custom login is in progress', () => { + webContentsEventManager.customLogins[1] = {inProgress: true}; + willNavigate(event, 'http://anyoldurl.com'); + expect(event.preventDefault).not.toBeCalled(); + }); + + it('should not allow navigation under any other circumstances', () => { + willNavigate(event, 'http://someotherurl.com'); + expect(event.preventDefault).toBeCalled(); + }); + }); + + describe('didStartNavigation', () => { + const webContentsEventManager = new WebContentsEventManager(); + const didStartNavigation = webContentsEventManager.generateDidStartNavigation(jest.fn()); + + beforeEach(() => { + urlUtils.getView.mockImplementation(() => ({name: 'server_name', url: 'http://server-1.com'})); + urlUtils.isTrustedURL.mockReturnValue(true); + urlUtils.isInternalURL.mockImplementation((serverURL, parsedURL) => parsedURL.toString().startsWith(serverURL)); + urlUtils.isCustomLoginURL.mockImplementation((parsedURL) => parsedURL.toString().startsWith('http://loginurl.com/login')); + }); + + afterEach(() => { + jest.resetAllMocks(); + webContentsEventManager.customLogins = {}; + }); + + it('should add custom login entry on custom login URL', () => { + webContentsEventManager.customLogins[1] = {inProgress: false}; + didStartNavigation(event, 'http://loginurl.com/login/oauth'); + expect(webContentsEventManager.customLogins[1]).toStrictEqual({inProgress: true}); + }); + + it('should remove custom login entry once navigating back to internal URL', () => { + webContentsEventManager.customLogins[1] = {inProgress: true}; + didStartNavigation(event, 'http://server-1.com/subpath'); + expect(webContentsEventManager.customLogins[1]).toStrictEqual({inProgress: false}); + }); + }); + + describe('newWindow', () => { + const webContentsEventManager = new WebContentsEventManager(); + const newWindow = webContentsEventManager.generateNewWindowListener(jest.fn()); + + beforeEach(() => { + urlUtils.isValidURI.mockReturnValue(true); + urlUtils.getView.mockReturnValue({name: 'server_name', url: 'http://server-1.com'}); + urlUtils.isTeamUrl.mockImplementation((serverURL, parsedURL) => parsedURL.toString().startsWith(`${serverURL}/myteam`)); + urlUtils.isAdminUrl.mockImplementation((serverURL, parsedURL) => parsedURL.toString().startsWith(`${serverURL}/admin_console`)); + urlUtils.isPluginUrl.mockImplementation((serverURL, parsedURL) => parsedURL.toString().startsWith(`${serverURL}/myplugin`)); + urlUtils.isManagedResource.mockImplementation((serverURL, parsedURL) => parsedURL.toString().startsWith(`${serverURL}/myplugin`)); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + it('should deny on bad URL', () => { + expect(newWindow({url: 'a-bad { + expect(newWindow({url: 'devtools://aaaaaa.com'})).toStrictEqual({action: 'allow'}); + }); + + it('should deny invalid URI', () => { + urlUtils.isValidURI.mockReturnValue(false); + expect(newWindow({url: 'baduri::'})).toStrictEqual({action: 'deny'}); + }); + + it('should divert to allowProtocolDialog for custom protocols that are not mattermost or http', () => { + expect(newWindow({url: 'spotify:album:2OZbaW9tgO62ndm375lFZr'})).toStrictEqual({action: 'deny'}); + expect(allowProtocolDialog.handleDialogEvent).toBeCalledWith('spotify:', 'spotify:album:2OZbaW9tgO62ndm375lFZr'); + }); + + it('should open in the browser when there is no server matching', () => { + urlUtils.getView.mockReturnValue(null); + expect(newWindow({url: 'http://server-2.com/subpath'})).toStrictEqual({action: 'deny'}); + expect(shell.openExternal).toBeCalledWith('http://server-2.com/subpath'); + }); + + it('should open public file links in browser', () => { + expect(newWindow({url: 'http://server-1.com/api/v4/public/files/myfile.img'})).toStrictEqual({action: 'deny'}); + expect(shell.openExternal).toBeCalledWith('http://server-1.com/api/v4/public/files/myfile.img'); + }); + + it('should open help links in the browser', () => { + expect(newWindow({url: 'http://server-1.com/help/helplink'})).toStrictEqual({action: 'deny'}); + expect(shell.openExternal).toBeCalledWith('http://server-1.com/help/helplink'); + }); + + it('should open team links in the app', () => { + expect(newWindow({url: 'http://server-1.com/myteam/channels/mychannel'})).toStrictEqual({action: 'deny'}); + expect(WindowManager.showMainWindow).toBeCalledWith(new URL('http://server-1.com/myteam/channels/mychannel')); + }); + + it('should prevent admin links from opening in a new window', () => { + expect(newWindow({url: 'http://server-1.com/admin_console/somepage'})).toStrictEqual({action: 'deny'}); + }); + + it('should prevent from opening a new window if popup already exists', () => { + webContentsEventManager.popupWindow = {webContents: {getURL: () => 'http://server-1.com/myplugin/login'}}; + expect(newWindow({url: 'http://server-1.com/myplugin/login'})).toStrictEqual({action: 'deny'}); + }); + + it('should open popup window for plugins', () => { + expect(newWindow({url: 'http://server-1.com/myplugin/login'})).toStrictEqual({action: 'deny'}); + expect(webContentsEventManager.popupWindow).toBeTruthy(); + }); + + it('should open popup window for managed resources', () => { + expect(newWindow({url: 'http://server-1.com/trusted/login'})).toStrictEqual({action: 'deny'}); + expect(webContentsEventManager.popupWindow).toBeTruthy(); + }); + }); +}); diff --git a/src/main/views/webContentEvents.ts b/src/main/views/webContentEvents.ts index 2c601120..e46ed549 100644 --- a/src/main/views/webContentEvents.ts +++ b/src/main/views/webContentEvents.ts @@ -23,238 +23,247 @@ type CustomLogin = { inProgress: boolean; } -const customLogins: Record = {}; -const listeners: Record void> = {}; -let popupWindow: BrowserWindow | undefined; - -function isTrustedPopupWindow(webContents: WebContents) { - if (!webContents) { - return false; - } - if (!popupWindow) { - return false; - } - return BrowserWindow.fromWebContents(webContents) === popupWindow; -} - const scheme = protocols && protocols[0] && protocols[0].schemes && protocols[0].schemes[0]; -const generateWillNavigate = (getServersFunction: () => TeamWithTabs[]) => { - return (event: Event & {sender: WebContents}, url: string) => { - const contentID = event.sender.id; - const parsedURL = urlUtils.parseURL(url)!; - const configServers = getServersFunction(); - const server = urlUtils.getView(parsedURL, configServers); +export class WebContentsEventManager { + customLogins: Record; + listeners: Record void>; + popupWindow?: BrowserWindow; - if (server && (urlUtils.isTeamUrl(server.url, parsedURL) || urlUtils.isAdminUrl(server.url, parsedURL) || isTrustedPopupWindow(event.sender))) { - return; + constructor() { + this.customLogins = {}; + this.listeners = {}; + } + + isTrustedPopupWindow = (webContents: WebContents) => { + if (!webContents) { + return false; } - - if (server && urlUtils.isCustomLoginURL(parsedURL, server, configServers)) { - return; - } - if (parsedURL.protocol === 'mailto:') { - return; - } - if (customLogins[contentID].inProgress) { - return; + if (!this.popupWindow) { + return false; } + return BrowserWindow.fromWebContents(webContents) === this.popupWindow; + } - log.info(`Prevented desktop from navigating to: ${url}`); - event.preventDefault(); - }; -}; + generateWillNavigate = (getServersFunction: () => TeamWithTabs[]) => { + return (event: Event & {sender: WebContents}, url: string) => { + const contentID = event.sender.id; + const parsedURL = urlUtils.parseURL(url)!; + const configServers = getServersFunction(); + const server = urlUtils.getView(parsedURL, configServers); -const generateDidStartNavigation = (getServersFunction: () => TeamWithTabs[]) => { - return (event: Event & {sender: WebContents}, url: string) => { - const serverList = getServersFunction(); - const contentID = event.sender.id; - const parsedURL = urlUtils.parseURL(url)!; - const server = urlUtils.getView(parsedURL, serverList); - - if (!urlUtils.isTrustedURL(parsedURL, serverList)) { - return; - } - - const serverURL = urlUtils.parseURL(server?.url || ''); - - if (server && urlUtils.isCustomLoginURL(parsedURL, server, serverList)) { - customLogins[contentID].inProgress = true; - } else if (server && customLogins[contentID].inProgress && urlUtils.isInternalURL(serverURL || new URL(''), parsedURL)) { - customLogins[contentID].inProgress = false; - } - }; -}; - -const denyNewWindow = (event: Event, url: string) => { - event.preventDefault(); - log.warn(`Prevented popup window to open a new window to ${url}.`); - return null; -}; - -const generateNewWindowListener = (getServersFunction: () => TeamWithTabs[], spellcheck?: boolean) => { - return (details: Electron.HandlerDetails): {action: 'deny' | 'allow'} => { - const parsedURL = urlUtils.parseURL(details.url); - if (!parsedURL) { - log.warn(`Ignoring non-url ${details.url}`); - return {action: 'deny'}; - } - - const configServers = getServersFunction(); - - // Dev tools case - if (parsedURL.protocol === 'devtools:') { - return {action: 'allow'}; - } - - // Check for valid URL - if (!urlUtils.isValidURI(details.url)) { - return {action: 'deny'}; - } - - // Check for custom protocol - if (parsedURL.protocol !== 'http:' && parsedURL.protocol !== 'https:' && parsedURL.protocol !== `${scheme}:`) { - allowProtocolDialog.handleDialogEvent(parsedURL.protocol, details.url); - return {action: 'deny'}; - } - - const server = urlUtils.getView(parsedURL, configServers); - - if (!server) { - shell.openExternal(details.url); - return {action: 'deny'}; - } - - // Public download links case - // We might be handling different types differently in the future, for now - // we are going to mimic the browser and just pop a new browser window for public links - if (parsedURL.pathname.match(/^(\/api\/v[3-4]\/public)*\/files\//)) { - shell.openExternal(details.url); - return {action: 'deny'}; - } - - // Image proxy case - if (parsedURL.pathname.match(/^\/api\/v[3-4]\/image/)) { - shell.openExternal(details.url); - return {action: 'deny'}; - } - - if (parsedURL.pathname.match(/^\/help\//)) { - // Help links case - // continue to open special case internal urls in default browser - shell.openExternal(details.url); - return {action: 'deny'}; - } - - if (urlUtils.isTeamUrl(server.url, parsedURL, true)) { - WindowManager.showMainWindow(parsedURL); - return {action: 'deny'}; - } - if (urlUtils.isAdminUrl(server.url, parsedURL)) { - log.info(`${details.url} is an admin console page, preventing to open a new window`); - return {action: 'deny'}; - } - if (popupWindow && popupWindow.webContents.getURL() === details.url) { - log.info(`Popup window already open at provided url: ${details.url}`); - return {action: 'deny'}; - } - - // TODO: move popups to its own and have more than one. - if (urlUtils.isPluginUrl(server.url, parsedURL) || urlUtils.isManagedResource(server.url, parsedURL)) { - if (!popupWindow) { - popupWindow = new BrowserWindow({ - backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do - //parent: WindowManager.getMainWindow(), - show: false, - center: true, - webPreferences: { - nativeWindowOpen: true, - spellcheck: (typeof spellcheck === 'undefined' ? true : spellcheck), - }, - }); - popupWindow.webContents.on('new-window', denyNewWindow); - popupWindow.once('ready-to-show', () => { - popupWindow!.show(); - }); - popupWindow.once('closed', () => { - popupWindow = undefined; - }); + if (server && (urlUtils.isTeamUrl(server.url, parsedURL) || urlUtils.isAdminUrl(server.url, parsedURL) || this.isTrustedPopupWindow(event.sender))) { + return; } - if (urlUtils.isManagedResource(server.url, parsedURL)) { - popupWindow.loadURL(details.url); - } else { - // currently changing the userAgent for popup windows to allow plugins to go through google's oAuth - // should be removed once a proper oAuth2 implementation is setup. - popupWindow.loadURL(details.url, { - userAgent: composeUserAgent(), - }); + if (server && urlUtils.isCustomLoginURL(parsedURL, server, configServers)) { + return; + } + if (parsedURL.protocol === 'mailto:') { + return; + } + if (this.customLogins[contentID]?.inProgress) { + return; } - const contextMenu = new ContextMenu({}, popupWindow); - contextMenu.reload(); - } + log.info(`Prevented desktop from navigating to: ${url}`); + event.preventDefault(); + }; + }; + generateDidStartNavigation = (getServersFunction: () => TeamWithTabs[]) => { + return (event: Event & {sender: WebContents}, url: string) => { + const serverList = getServersFunction(); + const contentID = event.sender.id; + const parsedURL = urlUtils.parseURL(url)!; + const server = urlUtils.getView(parsedURL, serverList); + + if (!urlUtils.isTrustedURL(parsedURL, serverList)) { + return; + } + + const serverURL = urlUtils.parseURL(server?.url || ''); + + if (server && urlUtils.isCustomLoginURL(parsedURL, server, serverList)) { + this.customLogins[contentID].inProgress = true; + } else if (server && this.customLogins[contentID].inProgress && urlUtils.isInternalURL(serverURL || new URL(''), parsedURL)) { + this.customLogins[contentID].inProgress = false; + } + }; + }; + + denyNewWindow = (details: Electron.HandlerDetails): {action: 'deny' | 'allow'} => { + log.warn(`Prevented popup window to open a new window to ${details.url}.`); return {action: 'deny'}; }; -}; -export const removeWebContentsListeners = (id: number) => { - if (listeners[id]) { - listeners[id](); - } -}; + generateNewWindowListener = (getServersFunction: () => TeamWithTabs[], spellcheck?: boolean) => { + return (details: Electron.HandlerDetails): {action: 'deny' | 'allow'} => { + const parsedURL = urlUtils.parseURL(details.url); + if (!parsedURL) { + log.warn(`Ignoring non-url ${details.url}`); + return {action: 'deny'}; + } -export const addWebContentsEventListeners = (mmview: MattermostView, getServersFunction: () => TeamWithTabs[]) => { - const contents = mmview.view.webContents; + const configServers = getServersFunction(); - // initialize custom login tracking - customLogins[contents.id] = { - inProgress: false, + // Dev tools case + if (parsedURL.protocol === 'devtools:') { + return {action: 'allow'}; + } + + // Check for valid URL + if (!urlUtils.isValidURI(details.url)) { + return {action: 'deny'}; + } + + // Check for custom protocol + if (parsedURL.protocol !== 'http:' && parsedURL.protocol !== 'https:' && parsedURL.protocol !== `${scheme}:`) { + allowProtocolDialog.handleDialogEvent(parsedURL.protocol, details.url); + return {action: 'deny'}; + } + + const server = urlUtils.getView(parsedURL, configServers); + + if (!server) { + shell.openExternal(details.url); + return {action: 'deny'}; + } + + // Public download links case + // TODO: We might be handling different types differently in the future, for now + // we are going to mimic the browser and just pop a new browser window for public links + if (parsedURL.pathname.match(/^(\/api\/v[3-4]\/public)*\/files\//)) { + shell.openExternal(details.url); + return {action: 'deny'}; + } + + // Image proxy case + if (parsedURL.pathname.match(/^\/api\/v[3-4]\/image/)) { + shell.openExternal(details.url); + return {action: 'deny'}; + } + + if (parsedURL.pathname.match(/^\/help\//)) { + // Help links case + // continue to open special case internal urls in default browser + shell.openExternal(details.url); + return {action: 'deny'}; + } + + if (urlUtils.isTeamUrl(server.url, parsedURL, true)) { + WindowManager.showMainWindow(parsedURL); + return {action: 'deny'}; + } + if (urlUtils.isAdminUrl(server.url, parsedURL)) { + log.info(`${details.url} is an admin console page, preventing to open a new window`); + return {action: 'deny'}; + } + if (this.popupWindow && this.popupWindow.webContents.getURL() === details.url) { + log.info(`Popup window already open at provided url: ${details.url}`); + return {action: 'deny'}; + } + + // TODO: move popups to its own and have more than one. + if (urlUtils.isPluginUrl(server.url, parsedURL) || urlUtils.isManagedResource(server.url, parsedURL)) { + if (!this.popupWindow) { + this.popupWindow = new BrowserWindow({ + backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do + //parent: WindowManager.getMainWindow(), + show: false, + center: true, + webPreferences: { + nativeWindowOpen: true, + spellcheck: (typeof spellcheck === 'undefined' ? true : spellcheck), + }, + }); + this.popupWindow.webContents.setWindowOpenHandler(this.denyNewWindow); + this.popupWindow.once('ready-to-show', () => { + this.popupWindow!.show(); + }); + this.popupWindow.once('closed', () => { + this.popupWindow = undefined; + }); + } + + if (urlUtils.isManagedResource(server.url, parsedURL)) { + this.popupWindow.loadURL(details.url); + } else { + // currently changing the userAgent for popup windows to allow plugins to go through google's oAuth + // should be removed once a proper oAuth2 implementation is setup. + this.popupWindow.loadURL(details.url, { + userAgent: composeUserAgent(), + }); + } + + const contextMenu = new ContextMenu({}, this.popupWindow); + contextMenu.reload(); + } + + return {action: 'deny'}; + }; }; - if (listeners[contents.id]) { - removeWebContentsListeners(contents.id); - } - - const willNavigate = generateWillNavigate(getServersFunction); - contents.on('will-navigate', willNavigate as (e: Event, u: string) => void); // Electron types don't include sender for some reason - - // handle custom login requests (oath, saml): - // 1. are we navigating to a supported local custom login path from the `/login` page? - // - indicate custom login is in progress - // 2. are we finished with the custom login process? - // - indicate custom login is NOT in progress - const didStartNavigation = generateDidStartNavigation(getServersFunction); - contents.on('did-start-navigation', didStartNavigation as (e: Event, u: string) => void); - - const spellcheck = mmview.options.webPreferences?.spellcheck; - const newWindow = generateNewWindowListener(getServersFunction, spellcheck); - contents.setWindowOpenHandler(newWindow); - - contents.on('page-title-updated', mmview.handleTitleUpdate); - contents.on('page-favicon-updated', mmview.handleFaviconUpdate); - contents.on('update-target-url', mmview.handleUpdateTarget); - contents.on('did-navigate', mmview.handleDidNavigate); - - const removeListeners = () => { - try { - contents.removeListener('will-navigate', willNavigate as (e: Event, u: string) => void); - contents.removeListener('did-start-navigation', didStartNavigation as (e: Event, u: string) => void); - contents.removeListener('page-title-updated', mmview.handleTitleUpdate); - contents.removeListener('page-favicon-updated', mmview.handleFaviconUpdate); - contents.removeListener('update-target-url', mmview.handleUpdateTarget); - contents.removeListener('did-navigate', mmview.handleDidNavigate); - } catch (e) { - log.error(`Error while trying to detach listeners, this might be ok if the process crashed: ${e}`); + removeWebContentsListeners = (id: number) => { + if (this.listeners[id]) { + this.listeners[id](); } }; - listeners[contents.id] = removeListeners; - contents.once('render-process-gone', (event, details) => { - if (details.reason !== 'clean-exit') { - log.error('Renderer process for a webcontent is no longer available:', details.reason); + addWebContentsEventListeners = (mmview: MattermostView, getServersFunction: () => TeamWithTabs[]) => { + const contents = mmview.view.webContents; + + // initialize custom login tracking + this.customLogins[contents.id] = { + inProgress: false, + }; + + if (this.listeners[contents.id]) { + this.removeWebContentsListeners(contents.id); } - removeListeners(); - }); -}; + + const willNavigate = this.generateWillNavigate(getServersFunction); + contents.on('will-navigate', willNavigate as (e: Event, u: string) => void); // TODO: Electron types don't include sender for some reason + + // handle custom login requests (oath, saml): + // 1. are we navigating to a supported local custom login path from the `/login` page? + // - indicate custom login is in progress + // 2. are we finished with the custom login process? + // - indicate custom login is NOT in progress + const didStartNavigation = this.generateDidStartNavigation(getServersFunction); + contents.on('did-start-navigation', didStartNavigation as (e: Event, u: string) => void); + + const spellcheck = mmview.options.webPreferences?.spellcheck; + const newWindow = this.generateNewWindowListener(getServersFunction, spellcheck); + contents.setWindowOpenHandler(newWindow); + + contents.on('page-title-updated', mmview.handleTitleUpdate); + contents.on('page-favicon-updated', mmview.handleFaviconUpdate); + contents.on('update-target-url', mmview.handleUpdateTarget); + contents.on('did-navigate', mmview.handleDidNavigate); + + const removeListeners = () => { + try { + contents.removeListener('will-navigate', willNavigate as (e: Event, u: string) => void); + contents.removeListener('did-start-navigation', didStartNavigation as (e: Event, u: string) => void); + contents.removeListener('page-title-updated', mmview.handleTitleUpdate); + contents.removeListener('page-favicon-updated', mmview.handleFaviconUpdate); + contents.removeListener('update-target-url', mmview.handleUpdateTarget); + contents.removeListener('did-navigate', mmview.handleDidNavigate); + } catch (e) { + log.error(`Error while trying to detach listeners, this might be ok if the process crashed: ${e}`); + } + }; + + this.listeners[contents.id] = removeListeners; + contents.once('render-process-gone', (event, details) => { + if (details.reason !== 'clean-exit') { + log.error('Renderer process for a webcontent is no longer available:', details.reason); + } + removeListeners(); + }); + }; +} + +const webContentsEventManager = new WebContentsEventManager(); +export default webContentsEventManager;