From 22ec280945e6c28f30140988ace56a3a9f2188f9 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Tue, 4 Apr 2023 10:01:40 -0400 Subject: [PATCH] [MM-51871] Migrate mainWindow and settingsWindow to singletons (#2650) * Migrate mainWindow to singleton * Migrate settingsWindow to singleton * PR feedback * Missed a couple unwrapping cases --- src/main/CriticalErrorHandler.test.js | 30 -- src/main/CriticalErrorHandler.ts | 201 +++++----- src/main/allowProtocolDialog.test.js | 10 +- src/main/allowProtocolDialog.ts | 4 +- src/main/app/app.test.js | 7 +- src/main/app/app.ts | 3 +- src/main/app/config.ts | 8 +- src/main/app/initialize.test.js | 14 +- src/main/app/initialize.ts | 10 +- src/main/app/intercom.test.js | 15 +- src/main/app/intercom.ts | 13 +- src/main/app/utils.test.js | 1 + src/main/app/utils.ts | 3 +- src/main/authManager.test.js | 10 +- src/main/authManager.ts | 5 +- src/main/badge.test.js | 69 +++- src/main/badge.ts | 64 +++- src/main/certificateManager.test.js | 8 +- src/main/certificateManager.ts | 5 +- src/main/diagnostics/index.test.js | 4 +- .../diagnostics/steps/step5.browserWindows.ts | 8 +- src/main/downloadsManager.test.js | 1 + src/main/menus/app.test.js | 5 + src/main/menus/app.ts | 3 +- src/main/menus/tray.test.js | 1 + src/main/menus/tray.ts | 3 +- src/main/notifications/index.test.js | 123 +++++- src/main/notifications/index.ts | 19 +- src/main/tray/tray.test.js | 2 +- src/main/tray/tray.ts | 10 +- src/main/utils.ts | 4 + src/main/views/MattermostView.test.js | 39 +- src/main/views/MattermostView.ts | 38 +- .../views/downloadsDropdownMenuView.test.js | 18 +- src/main/views/downloadsDropdownMenuView.ts | 57 +-- src/main/views/downloadsDropdownView.test.js | 31 +- src/main/views/downloadsDropdownView.ts | 56 +-- src/main/views/teamDropdownView.test.js | 27 +- src/main/views/teamDropdownView.ts | 17 +- src/main/views/viewManager.test.js | 19 +- src/main/views/viewManager.ts | 48 +-- src/main/windows/mainWindow.test.js | 106 +++--- src/main/windows/mainWindow.ts | 359 +++++++++++------- src/main/windows/settingsWindow.ts | 85 +++-- src/main/windows/windowManager.test.js | 282 ++++---------- src/main/windows/windowManager.ts | 276 ++++---------- 46 files changed, 1131 insertions(+), 990 deletions(-) diff --git a/src/main/CriticalErrorHandler.test.js b/src/main/CriticalErrorHandler.test.js index 984a57bb..f800bf21 100644 --- a/src/main/CriticalErrorHandler.test.js +++ b/src/main/CriticalErrorHandler.test.js @@ -42,30 +42,10 @@ jest.mock('main/i18nManager', () => ({ describe('main/CriticalErrorHandler', () => { const criticalErrorHandler = new CriticalErrorHandler(); - beforeEach(() => { - criticalErrorHandler.setMainWindow({}); - }); - - describe('windowUnresponsiveHandler', () => { - it('should do nothing when mainWindow is null', () => { - criticalErrorHandler.setMainWindow(null); - criticalErrorHandler.windowUnresponsiveHandler(); - expect(dialog.showMessageBox).not.toBeCalled(); - }); - - it('should call app.relaunch when user elects not to wait', async () => { - const promise = Promise.resolve({response: 0}); - dialog.showMessageBox.mockImplementation(() => promise); - criticalErrorHandler.windowUnresponsiveHandler(); - await promise; - expect(app.relaunch).toBeCalled(); - }); - }); describe('processUncaughtExceptionHandler', () => { beforeEach(() => { app.isReady.mockImplementation(() => true); - criticalErrorHandler.setMainWindow({isVisible: true}); }); it('should throw error if app is not ready', () => { @@ -76,16 +56,6 @@ describe('main/CriticalErrorHandler', () => { expect(dialog.showMessageBox).not.toBeCalled(); }); - it('should do nothing if main window is null or not visible', () => { - criticalErrorHandler.setMainWindow(null); - criticalErrorHandler.processUncaughtExceptionHandler(new Error('test')); - expect(dialog.showMessageBox).not.toBeCalled(); - - criticalErrorHandler.setMainWindow({isVisible: false}); - criticalErrorHandler.processUncaughtExceptionHandler(new Error('test')); - expect(dialog.showMessageBox).not.toBeCalled(); - }); - it('should open external file on Show Details', async () => { path.join.mockImplementation(() => 'testfile.txt'); const promise = Promise.resolve({response: process.platform === 'darwin' ? 2 : 0}); diff --git a/src/main/CriticalErrorHandler.ts b/src/main/CriticalErrorHandler.ts index 7c85b900..dc8aaefa 100644 --- a/src/main/CriticalErrorHandler.ts +++ b/src/main/CriticalErrorHandler.ts @@ -7,132 +7,111 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import {app, BrowserWindow, dialog} from 'electron'; +import {app, dialog} from 'electron'; import log from 'electron-log'; import {localizeMessage} from 'main/i18nManager'; -function createErrorReport(err: Error) { - // eslint-disable-next-line no-undef - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - return `Application: ${app.name} ${app.getVersion()}${__HASH_VERSION__ ? ` [commit: ${__HASH_VERSION__}]` : ''}\n` + - `Platform: ${os.type()} ${os.release()} ${os.arch()}\n` + - `${err.stack}`; -} - -function openDetachedExternal(url: string) { - const spawnOption = {detached: true, stdio: 'ignore' as const}; - switch (process.platform) { - case 'win32': - return spawn('cmd', ['/C', 'start', url], spawnOption); - case 'darwin': - return spawn('open', [url], spawnOption); - case 'linux': - return spawn('xdg-open', [url], spawnOption); - default: - return undefined; - } -} - export class CriticalErrorHandler { - mainWindow?: BrowserWindow; - - setMainWindow(mainWindow: BrowserWindow) { - this.mainWindow = mainWindow; + init = () => { + process.on('unhandledRejection', this.processUncaughtExceptionHandler); + process.on('uncaughtException', this.processUncaughtExceptionHandler); } - windowUnresponsiveHandler() { - if (!this.mainWindow) { - return; - } - dialog.showMessageBox(this.mainWindow, { - type: 'warning', - title: app.name, - message: localizeMessage('main.CriticalErrorHandler.unresponsive.dialog.message', 'The window is no longer responsive.\nDo you wait until the window becomes responsive again?'), - buttons: [ - localizeMessage('label.no', 'No'), - localizeMessage('label.yes', 'Yes'), - ], - defaultId: 0, - }).then(({response}) => { - if (response === 0) { - log.error('BrowserWindow \'unresponsive\' event has been emitted'); - app.relaunch(); - } - }); - } - - processUncaughtExceptionHandler(err: Error) { - const file = path.join(app.getPath('userData'), `uncaughtException-${Date.now()}.txt`); - const report = createErrorReport(err); - fs.writeFileSync(file, report.replace(new RegExp('\\n', 'g'), os.EOL)); - + private processUncaughtExceptionHandler = (err: Error) => { if (process.env.NODE_ENV === 'test') { return; } if (app.isReady()) { - const buttons = [ - localizeMessage('main.CriticalErrorHandler.uncaughtException.button.showDetails', 'Show Details'), - localizeMessage('label.ok', 'OK'), - localizeMessage('main.CriticalErrorHandler.uncaughtException.button.reopen', 'Reopen'), - ]; - let indexOfReopen = 2; - let indexOfShowDetails = 0; - if (process.platform === 'darwin') { - buttons.reverse(); - indexOfReopen = 0; - indexOfShowDetails = 2; - } - if (!this.mainWindow?.isVisible) { - return; - } - dialog.showMessageBox( - this.mainWindow, - { - type: 'error', - title: app.name, - message: localizeMessage( - 'main.CriticalErrorHandler.uncaughtException.dialog.message', - 'The {appName} app quit unexpectedly. Click "{showDetails}" to learn more or "{reopen}" to open the application again.\n\nInternal error: {err}', - { - appName: app.name, - showDetails: localizeMessage('main.CriticalErrorHandler.uncaughtException.button.showDetails', 'Show Details'), - reopen: localizeMessage('main.CriticalErrorHandler.uncaughtException.button.reopen', 'Reopen'), - err: err.message, - }, - ), - buttons, - defaultId: indexOfReopen, - noLink: true, - }, - ).then(({response}) => { - let child; - switch (response) { - case indexOfShowDetails: - child = openDetachedExternal(file); - if (child) { - child.on( - 'error', - (spawnError) => { - log.error(spawnError); - }, - ); - child.unref(); - } - break; - case indexOfReopen: - app.relaunch(); - break; - } - app.exit(-1); - }); + this.showExceptionDialog(err); } else { - log.error(`Window wasn't ready to handle the error: ${err}\ntrace: ${err.stack}`); - throw err; + app.once('ready', () => { + this.showExceptionDialog(err); + }); } } + + private showExceptionDialog = (err: Error) => { + const file = path.join(app.getPath('userData'), `uncaughtException-${Date.now()}.txt`); + const report = this.createErrorReport(err); + fs.writeFileSync(file, report.replace(new RegExp('\\n', 'g'), os.EOL)); + + const buttons = [ + localizeMessage('main.CriticalErrorHandler.uncaughtException.button.showDetails', 'Show Details'), + localizeMessage('label.ok', 'OK'), + localizeMessage('main.CriticalErrorHandler.uncaughtException.button.reopen', 'Reopen'), + ]; + let indexOfReopen = 2; + let indexOfShowDetails = 0; + if (process.platform === 'darwin') { + buttons.reverse(); + indexOfReopen = 0; + indexOfShowDetails = 2; + } + dialog.showMessageBox( + { + type: 'error', + title: app.name, + message: localizeMessage( + 'main.CriticalErrorHandler.uncaughtException.dialog.message', + 'The {appName} app quit unexpectedly. Click "{showDetails}" to learn more or "{reopen}" to open the application again.\n\nInternal error: {err}', + { + appName: app.name, + showDetails: localizeMessage('main.CriticalErrorHandler.uncaughtException.button.showDetails', 'Show Details'), + reopen: localizeMessage('main.CriticalErrorHandler.uncaughtException.button.reopen', 'Reopen'), + err: err.message, + }, + ), + buttons, + defaultId: indexOfReopen, + noLink: true, + }, + ).then(({response}) => { + let child; + switch (response) { + case indexOfShowDetails: + child = this.openDetachedExternal(file); + if (child) { + child.on( + 'error', + (spawnError) => { + log.error(spawnError); + }, + ); + child.unref(); + } + break; + case indexOfReopen: + app.relaunch(); + break; + } + app.exit(-1); + }); + } + + private openDetachedExternal = (url: string) => { + const spawnOption = {detached: true, stdio: 'ignore' as const}; + switch (process.platform) { + case 'win32': + return spawn('cmd', ['/C', 'start', url], spawnOption); + case 'darwin': + return spawn('open', [url], spawnOption); + case 'linux': + return spawn('xdg-open', [url], spawnOption); + default: + return undefined; + } + } + + private createErrorReport = (err: Error) => { + // eslint-disable-next-line no-undef + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return `Application: ${app.name} ${app.getVersion()}${__HASH_VERSION__ ? ` [commit: ${__HASH_VERSION__}]` : ''}\n` + + `Platform: ${os.type()} ${os.release()} ${os.arch()}\n` + + `${err.stack}`; + } } const criticalErrorHandler = new CriticalErrorHandler(); diff --git a/src/main/allowProtocolDialog.test.js b/src/main/allowProtocolDialog.test.js index ea800f18..978ed0dd 100644 --- a/src/main/allowProtocolDialog.test.js +++ b/src/main/allowProtocolDialog.test.js @@ -6,7 +6,7 @@ import fs from 'fs'; import {shell, dialog} from 'electron'; -import WindowManager from './windows/windowManager'; +import MainWindow from './windows/mainWindow'; import {AllowProtocolDialog} from './allowProtocolDialog'; @@ -42,8 +42,8 @@ jest.mock('common/Validator', () => ({ validateAllowedProtocols: (protocols) => protocols, })); -jest.mock('./windows/windowManager', () => ({ - getMainWindow: jest.fn(), +jest.mock('./windows/mainWindow', () => ({ + get: jest.fn(), })); jest.mock('main/i18nManager', () => ({ @@ -117,7 +117,7 @@ describe('main/allowProtocolDialog', () => { }); it('should not open message box if main window is missing', () => { - WindowManager.getMainWindow.mockImplementation(() => null); + MainWindow.get.mockImplementation(() => null); allowProtocolDialog.handleDialogEvent('mattermost:', 'mattermost://community.mattermost.com'); expect(shell.openExternal).not.toBeCalled(); expect(dialog.showMessageBox).not.toBeCalled(); @@ -125,7 +125,7 @@ describe('main/allowProtocolDialog', () => { describe('main window not null', () => { beforeEach(() => { - WindowManager.getMainWindow.mockImplementation(() => ({})); + MainWindow.get.mockImplementation(() => ({})); }); it('should open the window but not save when clicking Yes', async () => { diff --git a/src/main/allowProtocolDialog.ts b/src/main/allowProtocolDialog.ts index dc482ef1..076f4e1b 100644 --- a/src/main/allowProtocolDialog.ts +++ b/src/main/allowProtocolDialog.ts @@ -13,7 +13,7 @@ import {localizeMessage} from 'main/i18nManager'; import buildConfig from 'common/config/buildConfig'; import * as Validator from 'common/Validator'; -import WindowManager from './windows/windowManager'; +import MainWindow from './windows/mainWindow'; import {allowedProtocolFile} from './constants'; export class AllowProtocolDialog { @@ -47,7 +47,7 @@ export class AllowProtocolDialog { shell.openExternal(URL); return; } - const mainWindow = WindowManager.getMainWindow(); + const mainWindow = MainWindow.get(); if (!mainWindow) { return; } diff --git a/src/main/app/app.test.js b/src/main/app/app.test.js index 3beb0c67..cefa9651 100644 --- a/src/main/app/app.test.js +++ b/src/main/app/app.test.js @@ -4,6 +4,7 @@ import {app, dialog} from 'electron'; import CertificateStore from 'main/certificateStore'; +import MainWindow from 'main/windows/mainWindow'; import WindowManager from 'main/windows/windowManager'; import {handleAppWillFinishLaunching, handleAppCertificateError, certificateErrorCallbacks} from 'main/app/app'; @@ -45,13 +46,15 @@ jest.mock('main/i18nManager', () => ({ })); jest.mock('main/tray/tray', () => ({})); jest.mock('main/windows/windowManager', () => ({ - getMainWindow: jest.fn(), getViewNameByWebContentsId: jest.fn(), getServerNameByWebContentsId: jest.fn(), viewManager: { views: new Map(), }, })); +jest.mock('main/windows/mainWindow', () => ({ + get: jest.fn(), +})); describe('main/app/app', () => { describe('handleAppWillFinishLaunching', () => { @@ -103,7 +106,7 @@ describe('main/app/app', () => { const certificate = {}; beforeEach(() => { - WindowManager.getMainWindow.mockReturnValue(mainWindow); + MainWindow.get.mockReturnValue(mainWindow); WindowManager.getServerNameByWebContentsId.mockReturnValue('test-team'); }); diff --git a/src/main/app/app.ts b/src/main/app/app.ts index 85290af7..0101a36b 100644 --- a/src/main/app/app.ts +++ b/src/main/app/app.ts @@ -12,6 +12,7 @@ import CertificateStore from 'main/certificateStore'; import {localizeMessage} from 'main/i18nManager'; import {destroyTray} from 'main/tray/tray'; import WindowManager from 'main/windows/windowManager'; +import MainWindow from 'main/windows/mainWindow'; import {getDeeplinkingURL, openDeepLink, resizeScreen} from './utils'; @@ -115,7 +116,7 @@ export async function handleAppCertificateError(event: Event, webContents: WebCo certificateErrorCallbacks.set(errorID, callback); // TODO: should we move this to window manager or provide a handler for dialogs? - const mainWindow = WindowManager.getMainWindow(); + const mainWindow = MainWindow.get(); if (!mainWindow) { return; } diff --git a/src/main/app/config.ts b/src/main/app/config.ts index 7233f752..90b206a6 100644 --- a/src/main/app/config.ts +++ b/src/main/app/config.ts @@ -61,9 +61,11 @@ export function handleConfigUpdate(newConfig: CombinedConfig) { }); } - updateServerInfos(newConfig.teams); - WindowManager.initializeCurrentServerName(); - handleMainWindowIsShown(); + if (app.isReady()) { + updateServerInfos(newConfig.teams); + WindowManager.initializeCurrentServerName(); + handleMainWindowIsShown(); + } handleUpdateMenuEvent(); if (newConfig.trayIconTheme) { diff --git a/src/main/app/initialize.test.js b/src/main/app/initialize.test.js index 0f3a698f..47acad5d 100644 --- a/src/main/app/initialize.test.js +++ b/src/main/app/initialize.test.js @@ -9,6 +9,7 @@ import Config from 'common/config'; import urlUtils from 'common/utils/url'; import parseArgs from 'main/ParseArgs'; +import MainWindow from 'main/windows/mainWindow'; import WindowManager from 'main/windows/windowManager'; import {initialize} from './initialize'; @@ -138,8 +139,7 @@ jest.mock('main/badge', () => ({ })); jest.mock('main/certificateManager', () => ({})); jest.mock('main/CriticalErrorHandler', () => ({ - processUncaughtExceptionHandler: jest.fn(), - setMainWindow: jest.fn(), + init: jest.fn(), })); jest.mock('main/notifications', () => ({ displayDownloadCompleted: jest.fn(), @@ -157,13 +157,17 @@ jest.mock('main/UserActivityMonitor', () => ({ startMonitoring: jest.fn(), })); jest.mock('main/windows/windowManager', () => ({ - getMainWindow: jest.fn(), showMainWindow: jest.fn(), - sendToMattermostViews: jest.fn(), sendToRenderer: jest.fn(), getServerNameByWebContentsId: jest.fn(), getServerURLFromWebContentsId: jest.fn(), })); +jest.mock('main/windows/settingsWindow', () => ({ + show: jest.fn(), +})); +jest.mock('main/windows/mainWindow', () => ({ + get: jest.fn(), +})); const originalProcess = process; describe('main/app/initialize', () => { beforeAll(() => { @@ -269,7 +273,7 @@ describe('main/app/initialize', () => { expect(callback).toHaveBeenCalledWith(false); callback = jest.fn(); - WindowManager.getMainWindow.mockReturnValue({webContents: {id: 1}}); + MainWindow.get.mockReturnValue({webContents: {id: 1}}); session.defaultSession.setPermissionRequestHandler.mockImplementation((cb) => { cb({id: 1, getURL: () => 'http://server-1.com'}, 'openExternal', callback); }); diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index 613630f4..065d654e 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -52,10 +52,12 @@ import CriticalErrorHandler from 'main/CriticalErrorHandler'; import downloadsManager from 'main/downloadsManager'; import i18nManager from 'main/i18nManager'; import parseArgs from 'main/ParseArgs'; +import SettingsWindow from 'main/windows/settingsWindow'; import TrustedOriginsStore from 'main/trustedOrigins'; import {refreshTrayImages, setupTray} from 'main/tray/tray'; import UserActivityMonitor from 'main/UserActivityMonitor'; import WindowManager from 'main/windows/windowManager'; +import MainWindow from 'main/windows/mainWindow'; import {protocols} from '../../../electron-builder.json'; @@ -105,7 +107,7 @@ export const mainProtocol = protocols?.[0]?.schemes?.[0]; * Main entry point for the application, ensures that everything initializes in the proper order */ export async function initialize() { - process.on('uncaughtException', CriticalErrorHandler.processUncaughtExceptionHandler.bind(CriticalErrorHandler)); + CriticalErrorHandler.init(); global.willAppQuit = false; // initialization that can run before the app is ready @@ -258,7 +260,7 @@ function initializeInterCommunicationEventListeners() { ipcMain.on(WINDOW_MAXIMIZE, WindowManager.maximize); ipcMain.on(WINDOW_MINIMIZE, WindowManager.minimize); ipcMain.on(WINDOW_RESTORE, WindowManager.restore); - ipcMain.on(SHOW_SETTINGS_WINDOW, WindowManager.showSettingsWindow); + ipcMain.on(SHOW_SETTINGS_WINDOW, SettingsWindow.show); ipcMain.handle(GET_AVAILABLE_SPELL_CHECKER_LANGUAGES, () => session.defaultSession.availableSpellCheckerLanguages); ipcMain.handle(GET_DOWNLOAD_LOCATION, handleSelectDownload); ipcMain.on(START_UPDATE_DOWNLOAD, handleStartDownload); @@ -344,8 +346,6 @@ function initializeAfterAppReady() { WindowManager.showMainWindow(deeplinkingURL); - CriticalErrorHandler.setMainWindow(WindowManager.getMainWindow()!); - // listen for status updates and pass on to renderer UserActivityMonitor.on('status', (status) => { log.debug('Initialize.UserActivityMonitor.on(status)', status); @@ -396,7 +396,7 @@ function initializeAfterAppReady() { } // is the request coming from the renderer? - const mainWindow = WindowManager.getMainWindow(); + const mainWindow = MainWindow.get(); if (mainWindow && webContents.id === mainWindow.webContents.id) { callback(true); return; diff --git a/src/main/app/intercom.test.js b/src/main/app/intercom.test.js index c77e3664..90fef811 100644 --- a/src/main/app/intercom.test.js +++ b/src/main/app/intercom.test.js @@ -5,6 +5,7 @@ import Config from 'common/config'; import {getDefaultTeamWithTabsFromTeam} from 'common/tabs/TabView'; import {getLocalURLString, getLocalPreload} from 'main/utils'; +import MainWindow from 'main/windows/mainWindow'; import ModalManager from 'main/views/modalManager'; import WindowManager from 'main/windows/windowManager'; @@ -33,10 +34,12 @@ jest.mock('main/views/modalManager', () => ({ addModal: jest.fn(), })); jest.mock('main/windows/windowManager', () => ({ - getMainWindow: jest.fn(), switchServer: jest.fn(), switchTab: jest.fn(), })); +jest.mock('main/windows/mainWindow', () => ({ + get: jest.fn(), +})); jest.mock('./app', () => ({})); jest.mock('./utils', () => ({ @@ -111,7 +114,7 @@ describe('main/app/intercom', () => { beforeEach(() => { getLocalURLString.mockReturnValue('/some/index.html'); getLocalPreload.mockReturnValue('/some/preload.js'); - WindowManager.getMainWindow.mockReturnValue({}); + MainWindow.get.mockReturnValue({}); Config.set.mockImplementation((name, value) => { Config[name] = value; @@ -150,7 +153,7 @@ describe('main/app/intercom', () => { beforeEach(() => { getLocalURLString.mockReturnValue('/some/index.html'); getLocalPreload.mockReturnValue('/some/preload.js'); - WindowManager.getMainWindow.mockReturnValue({}); + MainWindow.get.mockReturnValue({}); Config.set.mockImplementation((name, value) => { Config[name] = value; @@ -193,7 +196,7 @@ describe('main/app/intercom', () => { beforeEach(() => { getLocalURLString.mockReturnValue('/some/index.html'); getLocalPreload.mockReturnValue('/some/preload.js'); - WindowManager.getMainWindow.mockReturnValue({}); + MainWindow.get.mockReturnValue({}); Config.set.mockImplementation((name, value) => { Config[name] = value; @@ -242,7 +245,7 @@ describe('main/app/intercom', () => { beforeEach(() => { getLocalURLString.mockReturnValue('/some/index.html'); getLocalPreload.mockReturnValue('/some/preload.js'); - WindowManager.getMainWindow.mockReturnValue({}); + MainWindow.get.mockReturnValue({}); Config.set.mockImplementation((name, value) => { Config[name] = value; @@ -263,7 +266,7 @@ describe('main/app/intercom', () => { it('MM-48079 should not show onboarding screen or server screen if GPO server is pre-configured', () => { getLocalURLString.mockReturnValue('/some/index.html'); getLocalPreload.mockReturnValue('/some/preload.js'); - WindowManager.getMainWindow.mockReturnValue({ + MainWindow.get.mockReturnValue({ isVisible: () => true, }); diff --git a/src/main/app/intercom.ts b/src/main/app/intercom.ts index 980a5ad5..e239e7c2 100644 --- a/src/main/app/intercom.ts +++ b/src/main/app/intercom.ts @@ -15,6 +15,7 @@ import {displayMention} from 'main/notifications'; import {getLocalPreload, getLocalURLString} from 'main/utils'; import ModalManager from 'main/views/modalManager'; import WindowManager from 'main/windows/windowManager'; +import MainWindow from 'main/windows/mainWindow'; import {handleAppBeforeQuit} from './app'; import {updateServerInfos} from './utils'; @@ -121,7 +122,7 @@ export function handleMainWindowIsShown() { * calls of this function will notification re-evaluate the booleans passed to "handleShowOnboardingScreens". */ - const mainWindow = WindowManager.getMainWindow(); + const mainWindow = MainWindow.get(); log.debug('intercom.handleMainWindowIsShown', {configTeams: Config.teams, showWelcomeScreen, showNewServerModal, mainWindow: Boolean(mainWindow)}); if (mainWindow?.isVisible()) { @@ -140,7 +141,7 @@ export function handleNewServerModal() { const preload = getLocalPreload('desktopAPI.js'); - const mainWindow = WindowManager.getMainWindow(); + const mainWindow = MainWindow.get(); if (!mainWindow) { return; } @@ -172,7 +173,7 @@ export function handleEditServerModal(e: IpcMainEvent, name: string) { const preload = getLocalPreload('desktopAPI.js'); - const mainWindow = WindowManager.getMainWindow(); + const mainWindow = MainWindow.get(); if (!mainWindow) { return; } @@ -214,7 +215,7 @@ export function handleRemoveServerModal(e: IpcMainEvent, name: string) { const preload = getLocalPreload('desktopAPI.js'); - const mainWindow = WindowManager.getMainWindow(); + const mainWindow = MainWindow.get(); if (!mainWindow) { return; } @@ -254,7 +255,7 @@ export function handleWelcomeScreenModal() { const preload = getLocalPreload('desktopAPI.js'); - const mainWindow = WindowManager.getMainWindow(); + const mainWindow = MainWindow.get(); if (!mainWindow) { return; } @@ -293,7 +294,7 @@ export function handleOpenAppMenu() { return; } windowMenu.popup({ - window: WindowManager.getMainWindow(), + window: MainWindow.get(), x: 18, y: 18, }); diff --git a/src/main/app/utils.test.js b/src/main/app/utils.test.js index c258d308..376252c1 100644 --- a/src/main/app/utils.test.js +++ b/src/main/app/utils.test.js @@ -60,6 +60,7 @@ jest.mock('main/server/serverInfo', () => ({ ServerInfo: jest.fn(), })); jest.mock('main/tray/tray', () => ({})); +jest.mock('main/windows/mainWindow', () => ({})); jest.mock('main/windows/windowManager', () => ({})); jest.mock('./initialize', () => ({ diff --git a/src/main/app/utils.ts b/src/main/app/utils.ts index 514766b0..c8928930 100644 --- a/src/main/app/utils.ts +++ b/src/main/app/utils.ts @@ -28,6 +28,7 @@ import {createMenu as createTrayMenu} from 'main/menus/tray'; import {ServerInfo} from 'main/server/serverInfo'; import {setTrayMenu} from 'main/tray/tray'; import WindowManager from 'main/windows/windowManager'; +import MainWindow from 'main/windows/mainWindow'; import {mainProtocol} from './initialize'; @@ -140,7 +141,7 @@ export function wasUpdated(lastAppVersion?: string) { export function clearAppCache() { // TODO: clear cache on browserviews, not in the renderer. - const mainWindow = WindowManager.getMainWindow(); + const mainWindow = MainWindow.get(); if (mainWindow) { mainWindow.webContents.session.clearCache().then(mainWindow.reload); } else { diff --git a/src/main/authManager.test.js b/src/main/authManager.test.js index a2e629f1..9a691090 100644 --- a/src/main/authManager.test.js +++ b/src/main/authManager.test.js @@ -3,6 +3,7 @@ 'use strict'; import {AuthManager} from 'main/authManager'; +import MainWindow from 'main/windows/mainWindow'; import WindowManager from 'main/windows/windowManager'; import ModalManager from 'main/views/modalManager'; @@ -84,8 +85,11 @@ jest.mock('main/trustedOrigins', () => ({ save: jest.fn(), })); +jest.mock('main/windows/mainWindow', () => ({ + get: jest.fn().mockImplementation(() => ({})), +})); + jest.mock('main/windows/windowManager', () => ({ - getMainWindow: jest.fn().mockImplementation(() => ({})), getServerURLFromWebContentsId: jest.fn(), })); @@ -151,7 +155,7 @@ describe('main/authManager', () => { const authManager = new AuthManager(); it('should not pop modal when no main window exists', () => { - WindowManager.getMainWindow.mockImplementationOnce(() => null); + MainWindow.get.mockImplementationOnce(() => null); authManager.popLoginModal({url: 'http://anormalurl.com'}, { isProxy: false, host: 'anormalurl', @@ -219,7 +223,7 @@ describe('main/authManager', () => { const authManager = new AuthManager(); it('should not pop modal when no main window exists', () => { - WindowManager.getMainWindow.mockImplementationOnce(() => null); + MainWindow.get.mockImplementationOnce(() => null); authManager.popPermissionModal({url: 'http://anormalurl.com'}, { isProxy: false, host: 'anormalurl', diff --git a/src/main/authManager.ts b/src/main/authManager.ts index 3f261225..24b5c8aa 100644 --- a/src/main/authManager.ts +++ b/src/main/authManager.ts @@ -13,6 +13,7 @@ import modalManager from 'main/views/modalManager'; import TrustedOriginsStore from 'main/trustedOrigins'; import {getLocalURLString, getLocalPreload} from 'main/utils'; import WindowManager from 'main/windows/windowManager'; +import MainWindow from 'main/windows/mainWindow'; const preload = getLocalPreload('desktopAPI.js'); const loginModalHtml = getLocalURLString('loginModal.html'); @@ -52,7 +53,7 @@ export class AuthManager { } popLoginModal = (request: AuthenticationResponseDetails, authInfo: AuthInfo) => { - const mainWindow = WindowManager.getMainWindow(); + const mainWindow = MainWindow.get(); if (!mainWindow) { return; } @@ -71,7 +72,7 @@ export class AuthManager { } popPermissionModal = (request: AuthenticationResponseDetails, authInfo: AuthInfo, permission: PermissionType) => { - const mainWindow = WindowManager.getMainWindow(); + const mainWindow = MainWindow.get(); if (!mainWindow) { return; } diff --git a/src/main/badge.test.js b/src/main/badge.test.js index e0c208e8..b4646063 100644 --- a/src/main/badge.test.js +++ b/src/main/badge.test.js @@ -2,24 +2,29 @@ // See LICENSE.txt for license information. 'use strict'; -import {app} from 'electron'; +import {app, nativeImage} from 'electron'; + +import MainWindow from './windows/mainWindow'; import * as Badge from './badge'; -import WindowManager from './windows/windowManager'; - jest.mock('electron', () => ({ app: { dock: { setBadge: jest.fn(), }, }, + nativeImage: { + createFromDataURL: jest.fn(), + }, })); jest.mock('./appState', () => ({ updateBadge: jest.fn(), })); - +jest.mock('./windows/mainWindow', () => ({ + get: jest.fn(), +})); jest.mock('./windows/windowManager', () => ({ setOverlayIcon: jest.fn(), })); @@ -30,35 +35,69 @@ jest.mock('main/i18nManager', () => ({ describe('main/badge', () => { describe('showBadgeWindows', () => { - it('should show dot when session expired', () => { + const window = { + setOverlayIcon: jest.fn(), + webContents: { + executeJavaScript: jest.fn(), + }, + }; + let promise; + + beforeEach(() => { + window.webContents.executeJavaScript.mockImplementation((code) => { + promise = new Promise((resolve) => resolve(code)); + return promise; + }); + nativeImage.createFromDataURL.mockImplementation((url) => url); + Badge.setUnreadBadgeSetting(false); + MainWindow.get.mockReturnValue(window); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should show dot when session expired', async () => { Badge.showBadgeWindows(true, 0, false); - expect(WindowManager.setOverlayIcon).toBeCalledWith('•', expect.any(String), expect.any(Boolean)); + await promise; + expect(window.setOverlayIcon).toBeCalledWith(expect.stringContaining('window.drawBadge(\'•\', false)'), expect.any(String)); }); - it('should show mention count when has mention count', () => { + it('should show mention count when has mention count', async () => { Badge.showBadgeWindows(true, 50, false); - expect(WindowManager.setOverlayIcon).toBeCalledWith('50', expect.any(String), false); + await promise; + expect(window.setOverlayIcon).toBeCalledWith(expect.stringContaining('window.drawBadge(\'50\', false)'), expect.any(String)); }); - it('should show 99+ when has mention count over 99', () => { + it('should show 99+ when has mention count over 99', async () => { Badge.showBadgeWindows(true, 200, false); - expect(WindowManager.setOverlayIcon).toBeCalledWith('99+', expect.any(String), true); + await promise; + expect(window.setOverlayIcon).toBeCalledWith(expect.stringContaining('window.drawBadge(\'99+\', true)'), expect.any(String)); }); - it('should not show dot when has unreads but setting is off', () => { + it('should not show dot when has unreads but setting is off', async () => { Badge.showBadgeWindows(false, 0, true); - expect(WindowManager.setOverlayIcon).not.toBeCalledWith('•', expect.any(String), expect.any(Boolean)); + await promise; + expect(window.setOverlayIcon).toBeCalledWith(null, expect.any(String)); }); - it('should show dot when has unreads', () => { + it('should show dot when has unreads', async () => { Badge.setUnreadBadgeSetting(true); Badge.showBadgeWindows(false, 0, true); - expect(WindowManager.setOverlayIcon).toBeCalledWith('•', expect.any(String), expect.any(Boolean)); - Badge.setUnreadBadgeSetting(false); + await promise; + expect(window.setOverlayIcon).toBeCalledWith(expect.stringContaining('window.drawBadge(\'•\', false)'), expect.any(String)); }); }); describe('showBadgeOSX', () => { + beforeEach(() => { + Badge.setUnreadBadgeSetting(false); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + it('should show dot when session expired', () => { Badge.showBadgeOSX(true, 0, false); expect(app.dock.setBadge).toBeCalledWith('•'); diff --git a/src/main/badge.ts b/src/main/badge.ts index cbd1ee5f..5fc0a183 100644 --- a/src/main/badge.ts +++ b/src/main/badge.ts @@ -2,20 +2,78 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {app} from 'electron'; +import {BrowserWindow, app, nativeImage} from 'electron'; import log from 'electron-log'; import {UPDATE_BADGE} from 'common/communication'; import {localizeMessage} from 'main/i18nManager'; -import WindowManager from './windows/windowManager'; import * as AppState from './appState'; +import MainWindow from './windows/mainWindow'; const MAX_WIN_COUNT = 99; let showUnreadBadgeSetting: boolean; +/** + * Badge generation for Windows + */ + +function drawBadge(text: string, small: boolean) { + const scale = 2; // should rely display dpi + const size = (small ? 20 : 16) * scale; + const canvas = document.createElement('canvas'); + canvas.setAttribute('width', `${size}`); + canvas.setAttribute('height', `${size}`); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + log.error('Could not create canvas context'); + return null; + } + + // circle + ctx.fillStyle = '#FF1744'; // Material Red A400 + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.fill(); + + // text + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = (11 * scale) + 'px sans-serif'; + ctx.fillText(text, size / 2, size / 2, size); + + return canvas.toDataURL(); +} + +function createDataURL(win: BrowserWindow, text: string, small: boolean) { + // since we don't have a document/canvas object in the main process, we use the webcontents from the window to draw. + const code = ` + window.drawBadge = ${drawBadge}; + window.drawBadge('${text || ''}', ${small}); + `; + return win.webContents.executeJavaScript(code); +} + +async function setOverlayIcon(badgeText: string | undefined, description: string, small: boolean) { + let overlay = null; + const mainWindow = MainWindow.get(); + if (mainWindow) { + if (badgeText) { + try { + const dataUrl = await createDataURL(mainWindow, badgeText, small); + overlay = nativeImage.createFromDataURL(dataUrl); + } catch (err) { + log.error('Could not generate a badge:', err); + } + } + mainWindow.setOverlayIcon(overlay, description); + } +} + export function showBadgeWindows(sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean) { let description = localizeMessage('main.badge.noUnreads', 'You have no unread messages'); let text; @@ -29,7 +87,7 @@ export function showBadgeWindows(sessionExpired: boolean, mentionCount: number, text = '•'; description = localizeMessage('main.badge.sessionExpired', 'Session Expired: Please sign in to continue receiving notifications.'); } - WindowManager.setOverlayIcon(text, description, mentionCount > 99); + setOverlayIcon(text, description, mentionCount > 99); } export function showBadgeOSX(sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean) { diff --git a/src/main/certificateManager.test.js b/src/main/certificateManager.test.js index d58e8bf9..74f37461 100644 --- a/src/main/certificateManager.test.js +++ b/src/main/certificateManager.test.js @@ -2,12 +2,12 @@ // See LICENSE.txt for license information. 'use strict'; -import WindowManager from 'main/windows/windowManager'; +import MainWindow from 'main/windows/mainWindow'; import ModalManager from 'main/views/modalManager'; import {CertificateManager} from 'main/certificateManager'; -jest.mock('main/windows/windowManager', () => ({ - getMainWindow: jest.fn().mockImplementation(() => ({})), +jest.mock('main/windows/mainWindow', () => ({ + get: jest.fn().mockImplementation(() => ({})), })); jest.mock('main/views/modalManager', () => ({ @@ -50,7 +50,7 @@ describe('main/certificateManager', () => { const certificateManager = new CertificateManager(); it('should not pop modal when no main window exists', () => { - WindowManager.getMainWindow.mockImplementationOnce(() => null); + MainWindow.get.mockImplementationOnce(() => null); certificateManager.popCertificateModal('http://anormalurl.com', [{data: 'test 1'}, {data: 'test 2'}, {data: 'test 3'}]); expect(ModalManager.addModal).not.toBeCalled(); }); diff --git a/src/main/certificateManager.ts b/src/main/certificateManager.ts index 00869b2f..4e6545d1 100644 --- a/src/main/certificateManager.ts +++ b/src/main/certificateManager.ts @@ -5,10 +5,9 @@ import {Certificate, WebContents} from 'electron'; import {CertificateModalData} from 'types/certificate'; -import WindowManager from './windows/windowManager'; - import modalManager from './views/modalManager'; import {getLocalURLString, getLocalPreload} from './utils'; +import MainWindow from './windows/mainWindow'; const preload = getLocalPreload('desktopAPI.js'); const html = getLocalURLString('certificateModal.html'); @@ -39,7 +38,7 @@ export class CertificateManager { } popCertificateModal = (url: string, list: Certificate[]) => { - const mainWindow = WindowManager.getMainWindow(); + const mainWindow = MainWindow.get(); if (!mainWindow) { return; } diff --git a/src/main/diagnostics/index.test.js b/src/main/diagnostics/index.test.js index d03d6756..4f9915fb 100644 --- a/src/main/diagnostics/index.test.js +++ b/src/main/diagnostics/index.test.js @@ -3,9 +3,7 @@ import Diagnostics from '.'; -jest.mock('main/windows/windowManager', () => ({ - mainWindow: {}, -})); +jest.mock('main/windows/mainWindow', () => ({})); jest.mock('common/config', () => ({ configFilePath: 'mock/config/filepath/', })); diff --git a/src/main/diagnostics/steps/step5.browserWindows.ts b/src/main/diagnostics/steps/step5.browserWindows.ts index 35846431..5f747b11 100644 --- a/src/main/diagnostics/steps/step5.browserWindows.ts +++ b/src/main/diagnostics/steps/step5.browserWindows.ts @@ -5,7 +5,7 @@ import {ElectronLog} from 'electron-log'; import {DiagnosticStepResponse} from 'types/diagnostics'; -import windowManager from 'main/windows/windowManager'; +import MainWindow from 'main/windows/mainWindow'; import DiagnosticsStep from '../DiagnosticStep'; @@ -17,11 +17,11 @@ const stepDescriptiveName = 'BrowserWindowsChecks'; const run = async (logger: ElectronLog): Promise => { try { /** Main window check */ - if (!windowManager.mainWindowReady) { + if (!MainWindow.isReady) { throw new Error('Main window not ready'); } - const mainWindowVisibilityStatus = browserWindowVisibilityStatus('mainWindow', windowManager.mainWindow); - const webContentsOk = webContentsCheck(windowManager.mainWindow?.webContents); + const mainWindowVisibilityStatus = browserWindowVisibilityStatus('mainWindow', MainWindow.get()); + const webContentsOk = webContentsCheck(MainWindow.get()?.webContents); if (mainWindowVisibilityStatus.some((status) => !status.ok) || !webContentsOk) { return { diff --git a/src/main/downloadsManager.test.js b/src/main/downloadsManager.test.js index 607eb6ce..b75b5201 100644 --- a/src/main/downloadsManager.test.js +++ b/src/main/downloadsManager.test.js @@ -76,6 +76,7 @@ jest.mock('fs', () => ({ jest.mock('macos-notification-state', () => ({ getDoNotDisturb: jest.fn(), })); +jest.mock('main/notifications', () => ({})); jest.mock('main/windows/windowManager', () => ({ sendToRenderer: jest.fn(), })); diff --git a/src/main/menus/app.test.js b/src/main/menus/app.test.js index c6b55daf..0c6cad72 100644 --- a/src/main/menus/app.test.js +++ b/src/main/menus/app.test.js @@ -49,10 +49,15 @@ jest.mock('macos-notification-state', () => ({ jest.mock('main/i18nManager', () => ({ localizeMessage: jest.fn(), })); +jest.mock('main/downloadsManager', () => ({ + hasDownloads: jest.fn(), +})); +jest.mock('main/diagnostics', () => ({})); jest.mock('main/windows/windowManager', () => ({ getCurrentTeamName: jest.fn(), sendToRenderer: jest.fn(), })); +jest.mock('main/windows/settingsWindow', () => ({})); jest.mock('common/tabs/TabView', () => ({ getTabDisplayName: (name) => name, })); diff --git a/src/main/menus/app.ts b/src/main/menus/app.ts index 79e7ee6d..bb6dcc57 100644 --- a/src/main/menus/app.ts +++ b/src/main/menus/app.ts @@ -16,6 +16,7 @@ import WindowManager from 'main/windows/windowManager'; import {UpdateManager} from 'main/autoUpdater'; import downloadsManager from 'main/downloadsManager'; import Diagnostics from 'main/diagnostics'; +import SettingsWindow from 'main/windows/settingsWindow'; export function createTemplate(config: Config, updateManager: UpdateManager) { const separatorItem: MenuItemConstructorOptions = { @@ -43,7 +44,7 @@ export function createTemplate(config: Config, updateManager: UpdateManager) { label: settingsLabel, accelerator: 'CmdOrCtrl+,', click() { - WindowManager.showSettingsWindow(); + SettingsWindow.show(); }, }); diff --git a/src/main/menus/tray.test.js b/src/main/menus/tray.test.js index 5f8eb3b0..563f0f8d 100644 --- a/src/main/menus/tray.test.js +++ b/src/main/menus/tray.test.js @@ -9,6 +9,7 @@ jest.mock('main/i18nManager', () => ({ localizeMessage: jest.fn(), })); +jest.mock('main/windows/settingsWindow', () => ({})); jest.mock('main/windows/windowManager', () => ({})); describe('main/menus/tray', () => { diff --git a/src/main/menus/tray.ts b/src/main/menus/tray.ts index 4fd4343d..aab87d8d 100644 --- a/src/main/menus/tray.ts +++ b/src/main/menus/tray.ts @@ -8,6 +8,7 @@ import {CombinedConfig} from 'types/config'; import WindowManager from 'main/windows/windowManager'; import {localizeMessage} from 'main/i18nManager'; +import SettingsWindow from 'main/windows/settingsWindow'; export function createTemplate(config: CombinedConfig) { const teams = config.teams; @@ -24,7 +25,7 @@ export function createTemplate(config: CombinedConfig) { }, { label: process.platform === 'darwin' ? localizeMessage('main.menus.tray.preferences', 'Preferences...') : localizeMessage('main.menus.tray.settings', 'Settings'), click: () => { - WindowManager.showSettingsWindow(); + SettingsWindow.show(); }, }, { type: 'separator', diff --git a/src/main/notifications/index.test.js b/src/main/notifications/index.test.js index 20a34e1f..efef7ee8 100644 --- a/src/main/notifications/index.test.js +++ b/src/main/notifications/index.test.js @@ -4,16 +4,18 @@ 'use strict'; import cp from 'child_process'; -import {Notification, shell} from 'electron'; +import {Notification, shell, app} from 'electron'; import {getFocusAssist} from 'windows-focus-assist'; import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; import {PLAY_SOUND} from 'common/communication'; +import Config from 'common/config'; import {TAB_MESSAGING} from 'common/tabs/TabView'; import {localizeMessage} from 'main/i18nManager'; +import MainWindow from '../windows/mainWindow'; import WindowManager from '../windows/windowManager'; import getLinuxDoNotDisturb from './dnd-linux'; @@ -55,6 +57,9 @@ jest.mock('electron', () => { return { app: { getAppPath: () => '/path/to/app', + dock: { + bounce: jest.fn(), + }, }, Notification: NotificationMock, shell: { @@ -70,7 +75,9 @@ jest.mock('windows-focus-assist', () => ({ jest.mock('macos-notification-state', () => ({ getDoNotDisturb: jest.fn(), })); - +jest.mock('../windows/mainWindow', () => ({ + get: jest.fn(), +})); jest.mock('../windows/windowManager', () => ({ getServerNameByWebContentsId: () => 'server_name', sendToRenderer: jest.fn(), @@ -82,12 +89,25 @@ jest.mock('main/i18nManager', () => ({ localizeMessage: jest.fn(), })); +jest.mock('common/config', () => ({})); + describe('main/notifications', () => { describe('displayMention', () => { + const mainWindow = { + flashFrame: jest.fn(), + }; + beforeEach(() => { Notification.isSupported.mockImplementation(() => true); getFocusAssist.mockReturnValue({value: false}); getDarwinDoNotDisturb.mockReturnValue(false); + Config.notifications = {}; + MainWindow.get.mockReturnValue(mainWindow); + }); + + afterEach(() => { + jest.resetAllMocks(); + Config.notifications = {}; }); it('should do nothing when Notification is not supported', () => { @@ -220,11 +240,108 @@ describe('main/notifications', () => { ); const mention = mentions.find((m) => m.body === 'mention_click_body'); mention.value.click(); - expect(WindowManager.switchTab).toHaveBeenCalledWith('server_name', TAB_MESSAGING); + expect(WindowManager.switchTab).toBeCalledWith('server_name', TAB_MESSAGING); + }); + + it('linux/windows - should not flash frame when config item is not set', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + displayMention( + 'click_test', + 'mention_click_body', + {id: 'channel_id'}, + 'team_id', + 'http://server-1.com/team_id/channel_id', + false, + {id: 1, send: jest.fn()}, + {}, + ); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(mainWindow.flashFrame).not.toBeCalled(); + }); + + it('linux/windows - should flash frame when config item is set', () => { + Config.notifications = { + flashWindow: true, + }; + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + displayMention( + 'click_test', + 'mention_click_body', + {id: 'channel_id'}, + 'team_id', + 'http://server-1.com/team_id/channel_id', + false, + {id: 1, send: jest.fn()}, + {}, + ); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(mainWindow.flashFrame).toBeCalledWith(true); + }); + + it('mac - should not bounce icon when config item is not set', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + displayMention( + 'click_test', + 'mention_click_body', + {id: 'channel_id'}, + 'team_id', + 'http://server-1.com/team_id/channel_id', + false, + {id: 1, send: jest.fn()}, + {}, + ); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(app.dock.bounce).not.toBeCalled(); + }); + + it('mac - should bounce icon when config item is set', () => { + Config.notifications = { + bounceIcon: true, + bounceIconType: 'critical', + }; + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + displayMention( + 'click_test', + 'mention_click_body', + {id: 'channel_id'}, + 'team_id', + 'http://server-1.com/team_id/channel_id', + false, + {id: 1, send: jest.fn()}, + {}, + ); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(app.dock.bounce).toHaveBeenCalledWith('critical'); }); }); describe('displayDownloadCompleted', () => { + beforeEach(() => { + Notification.isSupported.mockImplementation(() => true); + getFocusAssist.mockReturnValue({value: false}); + getDarwinDoNotDisturb.mockReturnValue(false); + }); + it('should open file when clicked', () => { getDarwinDoNotDisturb.mockReturnValue(false); localizeMessage.mockReturnValue('test_filename'); diff --git a/src/main/notifications/index.ts b/src/main/notifications/index.ts index 769d3dc4..e44d846b 100644 --- a/src/main/notifications/index.ts +++ b/src/main/notifications/index.ts @@ -1,16 +1,18 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {shell, Notification} from 'electron'; +import {app, shell, Notification} from 'electron'; import log from 'electron-log'; import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; import {MentionData} from 'types/notification'; +import Config from 'common/config'; import {PLAY_SOUND} from 'common/communication'; import {TAB_MESSAGING} from 'common/tabs/TabView'; +import MainWindow from '../windows/mainWindow'; import WindowManager from '../windows/windowManager'; import {Mention} from './Mention'; @@ -61,7 +63,7 @@ export function displayMention(title: string, body: string, channel: {id: string if (notificationSound) { WindowManager.sendToRenderer(PLAY_SOUND, notificationSound); } - WindowManager.flashFrame(true); + flashFrame(true); }); mention.on('click', () => { @@ -89,7 +91,7 @@ export function displayDownloadCompleted(fileName: string, path: string, serverN const download = new DownloadNotification(fileName, serverName); download.on('show', () => { - WindowManager.flashFrame(true); + flashFrame(true); }); download.on('click', () => { @@ -153,3 +155,14 @@ function getDoNotDisturb() { return false; } + +function flashFrame(flash: boolean) { + if (process.platform === 'linux' || process.platform === 'win32') { + if (Config.notifications.flashWindow) { + MainWindow.get()?.flashFrame(flash); + } + } + if (process.platform === 'darwin' && Config.notifications.bounceIcon) { + app.dock.bounce(Config.notifications.bounceIconType); + } +} diff --git a/src/main/tray/tray.test.js b/src/main/tray/tray.test.js index 06cf04bf..20cd47d5 100644 --- a/src/main/tray/tray.test.js +++ b/src/main/tray/tray.test.js @@ -46,8 +46,8 @@ jest.mock('electron', () => { jest.mock('main/app/utils', () => ({ handleUpdateMenuEvent: jest.fn(), updateSpellCheckerLocales: jest.fn(), - updateServerInfos: jest.fn(), setLoggingLevel: jest.fn(), + updateServerInfos: jest.fn(), })); jest.mock('main/app/intercom', () => ({ handleMainWindowIsShown: jest.fn(), diff --git a/src/main/tray/tray.ts b/src/main/tray/tray.ts index 3be03b6c..a922389b 100644 --- a/src/main/tray/tray.ts +++ b/src/main/tray/tray.ts @@ -8,8 +8,9 @@ import {app, nativeImage, Tray, systemPreferences, nativeTheme} from 'electron'; import {UPDATE_TRAY} from 'common/communication'; import {localizeMessage} from 'main/i18nManager'; +import MainWindow from 'main/windows/mainWindow'; +import WindowManager from 'main/windows/windowManager'; -import WindowManager from '../windows/windowManager'; import * as AppState from '../appState'; const assetsDir = path.resolve(app.getAppPath(), 'assets'); @@ -85,9 +86,10 @@ export function setupTray(iconTheme: string) { trayIcon.setToolTip(app.name); trayIcon.on('click', () => { - if (WindowManager.mainWindow!.isVisible()) { - WindowManager.mainWindow!.blur(); // To move focus to the next top-level window in Windows - WindowManager.mainWindow!.hide(); + const mainWindow = MainWindow.get(true)!; + if (mainWindow.isVisible()) { + mainWindow.blur(); // To move focus to the next top-level window in Windows + mainWindow.hide(); } else { WindowManager.restoreMain(); } diff --git a/src/main/utils.ts b/src/main/utils.ts index 28e8488d..7f5f4f07 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -18,6 +18,10 @@ import {BACK_BAR_HEIGHT, customLoginRegexPaths, PRODUCTION, TAB_BAR_HEIGHT} from import UrlUtils from 'common/utils/url'; import Utils from 'common/utils/util'; +export function isInsideRectangle(container: Electron.Rectangle, rect: Electron.Rectangle) { + return container.x <= rect.x && container.y <= rect.y && container.width >= rect.width && container.height >= rect.height; +} + export function shouldBeHiddenOnStartup(parsedArgv: Args) { if (parsedArgv.hidden) { return true; diff --git a/src/main/views/MattermostView.test.js b/src/main/views/MattermostView.test.js index ba0b3c3f..2c803fea 100644 --- a/src/main/views/MattermostView.test.js +++ b/src/main/views/MattermostView.test.js @@ -7,6 +7,7 @@ import {LOAD_FAILED, TOGGLE_BACK_BUTTON, UPDATE_TARGET_URL} from 'common/communi import {MattermostServer} from 'common/servers/MattermostServer'; import MessagingTabView from 'common/tabs/MessagingTabView'; +import MainWindow from '../windows/mainWindow'; import * as WindowManager from '../windows/windowManager'; import * as appState from '../appState'; import Utils from '../utils'; @@ -30,9 +31,12 @@ jest.mock('electron', () => ({ }, })); +jest.mock('../windows/mainWindow', () => ({ + focusThreeDotMenu: jest.fn(), + get: jest.fn(), +})); jest.mock('../windows/windowManager', () => ({ sendToRenderer: jest.fn(), - focusThreeDotMenu: jest.fn(), })); jest.mock('../appState', () => ({ updateMentions: jest.fn(), @@ -54,9 +58,10 @@ const tabView = new MessagingTabView(server); describe('main/views/MattermostView', () => { describe('load', () => { const window = {on: jest.fn()}; - const mattermostView = new MattermostView(tabView, {}, window, {}); + const mattermostView = new MattermostView(tabView, {}, {}); beforeEach(() => { + MainWindow.get.mockReturnValue(window); mattermostView.loadSuccess = jest.fn(); mattermostView.loadRetry = jest.fn(); }); @@ -112,11 +117,12 @@ describe('main/views/MattermostView', () => { describe('retry', () => { const window = {on: jest.fn()}; - const mattermostView = new MattermostView(tabView, {}, window, {}); + const mattermostView = new MattermostView(tabView, {}, {}); const retryInBackgroundFn = jest.fn(); beforeEach(() => { jest.useFakeTimers(); + MainWindow.get.mockReturnValue(window); mattermostView.view.webContents.loadURL.mockImplementation(() => Promise.resolve()); mattermostView.loadSuccess = jest.fn(); mattermostView.loadRetry = jest.fn(); @@ -175,10 +181,11 @@ describe('main/views/MattermostView', () => { describe('loadSuccess', () => { const window = {on: jest.fn()}; - const mattermostView = new MattermostView(tabView, {}, window, {}); + const mattermostView = new MattermostView(tabView, {}, {}); beforeEach(() => { jest.useFakeTimers(); + MainWindow.get.mockReturnValue(window); mattermostView.emit = jest.fn(); mattermostView.setBounds = jest.fn(); mattermostView.setInitialized = jest.fn(); @@ -202,10 +209,11 @@ describe('main/views/MattermostView', () => { describe('show', () => { const window = {addBrowserView: jest.fn(), removeBrowserView: jest.fn(), on: jest.fn()}; - const mattermostView = new MattermostView(tabView, {}, window, {}); + const mattermostView = new MattermostView(tabView, {}, {}); beforeEach(() => { jest.useFakeTimers(); + MainWindow.get.mockReturnValue(window); mattermostView.setBounds = jest.fn(); mattermostView.focus = jest.fn(); }); @@ -253,9 +261,10 @@ describe('main/views/MattermostView', () => { describe('destroy', () => { const window = {removeBrowserView: jest.fn(), on: jest.fn()}; - const mattermostView = new MattermostView(tabView, {}, window, {}); + const mattermostView = new MattermostView(tabView, {}, {}); beforeEach(() => { + MainWindow.get.mockReturnValue(window); mattermostView.view.webContents.destroy = jest.fn(); }); @@ -280,17 +289,18 @@ describe('main/views/MattermostView', () => { describe('handleInputEvents', () => { const window = {on: jest.fn()}; - const mattermostView = new MattermostView(tabView, {}, window, {}); + const mattermostView = new MattermostView(tabView, {}, {}); it('should open three dot menu on pressing Alt', () => { + MainWindow.get.mockReturnValue(window); mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyDown', alt: true, shift: false, control: false, meta: false}); mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyUp'}); - expect(WindowManager.focusThreeDotMenu).toHaveBeenCalled(); + expect(MainWindow.focusThreeDotMenu).toHaveBeenCalled(); }); it('should not open three dot menu on holding Alt', () => { mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyDown'}); - expect(WindowManager.focusThreeDotMenu).not.toHaveBeenCalled(); + expect(MainWindow.focusThreeDotMenu).not.toHaveBeenCalled(); }); it('should not open three dot menu on Alt as key combp', () => { @@ -298,15 +308,16 @@ describe('main/views/MattermostView', () => { 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(); + expect(MainWindow.focusThreeDotMenu).not.toHaveBeenCalled(); }); }); describe('handleDidNavigate', () => { const window = {on: jest.fn()}; - const mattermostView = new MattermostView(tabView, {}, window, {}); + const mattermostView = new MattermostView(tabView, {}, {}); beforeEach(() => { + MainWindow.get.mockReturnValue(window); mattermostView.setBounds = jest.fn(); }); @@ -325,9 +336,10 @@ describe('main/views/MattermostView', () => { describe('handleUpdateTarget', () => { const window = {on: jest.fn()}; - const mattermostView = new MattermostView(tabView, {}, window, {}); + const mattermostView = new MattermostView(tabView, {}, {}); beforeEach(() => { + MainWindow.get.mockReturnValue(window); mattermostView.emit = jest.fn(); }); @@ -355,8 +367,7 @@ describe('main/views/MattermostView', () => { }); describe('updateMentionsFromTitle', () => { - const window = {on: jest.fn()}; - const mattermostView = new MattermostView(tabView, {}, window, {}); + const mattermostView = new MattermostView(tabView, {}, {}); it('should parse mentions from title', () => { mattermostView.updateMentionsFromTitle('(7) Mattermost'); diff --git a/src/main/views/MattermostView.ts b/src/main/views/MattermostView.ts index d20fbbfd..19979381 100644 --- a/src/main/views/MattermostView.ts +++ b/src/main/views/MattermostView.ts @@ -1,7 +1,7 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {BrowserView, app, ipcMain, BrowserWindow} from 'electron'; +import {BrowserView, app, ipcMain} from 'electron'; import {BrowserViewConstructorOptions, Event, Input} from 'electron/main'; import log from 'electron-log'; @@ -25,6 +25,8 @@ import {MattermostServer} from 'common/servers/MattermostServer'; import {TabView, TabTuple} from 'common/tabs/TabView'; import {ServerInfo} from 'main/server/serverInfo'; +import MainWindow from 'main/windows/mainWindow'; + import ContextMenu from '../contextMenu'; import {getWindowBoundaries, getLocalPreload, composeUserAgent, shouldHaveBackBar} from '../utils'; import WindowManager from '../windows/windowManager'; @@ -43,7 +45,6 @@ const MENTIONS_GROUP = 2; export class MattermostView extends EventEmitter { tab: TabView; - window: BrowserWindow; view: BrowserView; isVisible: boolean; isLoggedIn: boolean; @@ -63,10 +64,9 @@ export class MattermostView extends EventEmitter { private altPressStatus: boolean; - constructor(tab: TabView, serverInfo: ServerInfo, win: BrowserWindow, options: BrowserViewConstructorOptions) { + constructor(tab: TabView, serverInfo: ServerInfo, options: BrowserViewConstructorOptions) { super(); this.tab = tab; - this.window = win; this.serverInfo = serverInfo; const preload = getLocalPreload('preload.js'); @@ -118,7 +118,7 @@ export class MattermostView extends EventEmitter { this.altPressStatus = false; - this.window.on('blur', () => { + MainWindow.get()?.on('blur', () => { this.altPressStatus = false; }); } @@ -224,6 +224,11 @@ export class MattermostView extends EventEmitter { loadSuccess = (loadURL: string) => { return () => { + const mainWindow = MainWindow.get(); + if (!mainWindow) { + return; + } + log.verbose(`[${Util.shorten(this.tab.name)}] finished loading ${loadURL}`); WindowManager.sendToRenderer(LOAD_SUCCESS, this.tab.name); this.maxRetries = MAX_SERVER_RETRIES; @@ -235,21 +240,26 @@ export class MattermostView extends EventEmitter { this.status = Status.WAITING_MM; this.removeLoading = setTimeout(this.setInitialized, MAX_LOADING_SCREEN_SECONDS, true); this.emit(LOAD_SUCCESS, this.tab.name, loadURL); - this.setBounds(getWindowBoundaries(this.window, shouldHaveBackBar(this.tab.url || '', this.view.webContents.getURL()))); + this.setBounds(getWindowBoundaries(mainWindow, shouldHaveBackBar(this.tab.url || '', this.view.webContents.getURL()))); }; } show = (requestedVisibility?: boolean) => { + const mainWindow = MainWindow.get(); + if (!mainWindow) { + return; + } + this.hasBeenShown = true; const request = typeof requestedVisibility === 'undefined' ? true : requestedVisibility; if (request && !this.isVisible) { - this.window.addBrowserView(this.view); - this.setBounds(getWindowBoundaries(this.window, shouldHaveBackBar(this.tab.url || '', this.view.webContents.getURL()))); + mainWindow.addBrowserView(this.view); + this.setBounds(getWindowBoundaries(mainWindow, shouldHaveBackBar(this.tab.url || '', this.view.webContents.getURL()))); if (this.status === Status.READY) { this.focus(); } } else if (!request && this.isVisible) { - this.window.removeBrowserView(this.view); + mainWindow.removeBrowserView(this.view); } this.isVisible = request; } @@ -268,9 +278,7 @@ export class MattermostView extends EventEmitter { destroy = () => { WebContentsEventManager.removeWebContentsListeners(this.view.webContents.id); appState.updateMentions(this.tab.name, 0, false); - if (this.window) { - this.window.removeBrowserView(this.view); - } + MainWindow.get()?.removeBrowserView(this.view); // workaround to eliminate zombie processes // https://github.com/mattermost/desktop/pull/1519 @@ -352,7 +360,7 @@ export class MattermostView extends EventEmitter { this.registerAltKeyPressed(input); if (this.isAltKeyReleased(input)) { - WindowManager.focusThreeDotMenu(); + MainWindow.focusThreeDotMenu(); } } @@ -360,11 +368,11 @@ export class MattermostView extends EventEmitter { log.debug('MattermostView.handleDidNavigate', {tabName: this.tab.name, url}); if (shouldHaveBackBar(this.tab.url || '', url)) { - this.setBounds(getWindowBoundaries(this.window, true)); + this.setBounds(getWindowBoundaries(MainWindow.get()!, true)); WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, true); log.info('show back button'); } else { - this.setBounds(getWindowBoundaries(this.window)); + this.setBounds(getWindowBoundaries(MainWindow.get()!)); WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, false); log.info('hide back button'); } diff --git a/src/main/views/downloadsDropdownMenuView.test.js b/src/main/views/downloadsDropdownMenuView.test.js index 24a7cf25..bff652de 100644 --- a/src/main/views/downloadsDropdownMenuView.test.js +++ b/src/main/views/downloadsDropdownMenuView.test.js @@ -7,6 +7,8 @@ import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state import {DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT, DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, TAB_BAR_HEIGHT} from 'common/utils/constants'; +import MainWindow from 'main/windows/mainWindow'; + import DownloadsDropdownMenuView from './downloadsDropdownMenuView'; jest.mock('main/utils', () => ({ @@ -50,6 +52,11 @@ jest.mock('electron', () => { jest.mock('macos-notification-state', () => ({ getDoNotDisturb: jest.fn(), })); +jest.mock('main/downloadsManager', () => ({})); +jest.mock('main/windows/mainWindow', () => ({ + get: jest.fn(), + getBounds: jest.fn(), +})); jest.mock('main/windows/windowManager', () => ({ sendToRenderer: jest.fn(), })); @@ -60,24 +67,21 @@ jest.mock('fs', () => ({ })); describe('main/views/DownloadsDropdownMenuView', () => { - const window = { - getContentBounds: () => ({width: 800, height: 600, x: 0, y: 0}), - addBrowserView: jest.fn(), - setTopBrowserView: jest.fn(), - }; - const downloadsDropdownMenuView = new DownloadsDropdownMenuView(window, {}, false); - beforeEach(() => { + MainWindow.get.mockReturnValue({addBrowserView: jest.fn(), setTopBrowserView: jest.fn()}); + MainWindow.getBounds.mockReturnValue({width: 800, height: 600, x: 0, y: 0}); getDarwinDoNotDisturb.mockReturnValue(false); }); describe('getBounds', () => { it('should be placed top-left inside the downloads dropdown if coordinates not used', () => { + const downloadsDropdownMenuView = new DownloadsDropdownMenuView(); expect(downloadsDropdownMenuView.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT)).toStrictEqual({x: 800 - DOWNLOADS_DROPDOWN_FULL_WIDTH - DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, y: TAB_BAR_HEIGHT, width: DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, height: DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT}); }); }); it('should change the view bounds based on open/closed state', () => { + const downloadsDropdownMenuView = new DownloadsDropdownMenuView(); downloadsDropdownMenuView.bounds = {width: 400, height: 300}; downloadsDropdownMenuView.handleOpen(); expect(downloadsDropdownMenuView.view.setBounds).toBeCalledWith(downloadsDropdownMenuView.bounds); diff --git a/src/main/views/downloadsDropdownMenuView.ts b/src/main/views/downloadsDropdownMenuView.ts index 6a01fd84..a9e7e905 100644 --- a/src/main/views/downloadsDropdownMenuView.ts +++ b/src/main/views/downloadsDropdownMenuView.ts @@ -1,6 +1,6 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {BrowserView, BrowserWindow, ipcMain, IpcMainEvent} from 'electron'; +import {BrowserView, ipcMain, IpcMainEvent} from 'electron'; import log from 'electron-log'; @@ -30,40 +30,23 @@ import {getLocalPreload, getLocalURLString} from 'main/utils'; import WindowManager from '../windows/windowManager'; import downloadsManager from 'main/downloadsManager'; +import MainWindow from 'main/windows/mainWindow'; export default class DownloadsDropdownMenuView { open: boolean; view: BrowserView; - bounds?: Electron.Rectangle; + bounds: Electron.Rectangle; item?: DownloadedItem; coordinates?: CoordinatesToJsonType; darkMode: boolean; - window: BrowserWindow; windowBounds: Electron.Rectangle; - constructor(window: BrowserWindow, darkMode: boolean) { + constructor(darkMode: boolean) { this.open = false; this.item = undefined; this.coordinates = undefined; - this.window = window; this.darkMode = darkMode; - this.windowBounds = this.window.getContentBounds(); - this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT); - - const preload = getLocalPreload('desktopAPI.js'); - this.view = new BrowserView({webPreferences: { - preload, - - // Workaround for this issue: https://github.com/electron/electron/issues/30993 - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - transparent: true, - }}); - - this.view.webContents.loadURL(getLocalURLString('downloadsDropdownMenu.html')); - this.window.addBrowserView(this.view); - ipcMain.on(OPEN_DOWNLOADS_DROPDOWN_MENU, this.handleOpen); ipcMain.on(CLOSE_DOWNLOADS_DROPDOWN_MENU, this.handleClose); ipcMain.on(TOGGLE_DOWNLOADS_DROPDOWN_MENU, this.handleToggle); @@ -74,6 +57,27 @@ export default class DownloadsDropdownMenuView { ipcMain.on(DOWNLOADS_DROPDOWN_MENU_CANCEL_DOWNLOAD, this.cancelDownload); ipcMain.on(DOWNLOADS_DROPDOWN_MENU_CLEAR_FILE, this.clearFile); ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN_MENU, this.updateItem); + + const mainWindow = MainWindow.get(); + const windowBounds = MainWindow.getBounds(); + if (!(mainWindow && windowBounds)) { + throw new Error('Cannot initialize downloadsDropdownMenuView, missing MainWindow'); + } + + this.windowBounds = windowBounds; + this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT); + + const preload = getLocalPreload('desktopAPI.js'); + this.view = new BrowserView({webPreferences: { + preload, + + // Workaround for this issue: https://github.com/electron/electron/issues/30993 + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + transparent: true, + }}); + this.view.webContents.loadURL(getLocalURLString('downloadsDropdownMenu.html')); + mainWindow.addBrowserView(this.view); } updateItem = (event: IpcMainEvent, item: DownloadedItem) => { @@ -98,9 +102,12 @@ export default class DownloadsDropdownMenuView { updateWindowBounds = () => { log.debug('DownloadsDropdownMenuView.updateWindowBounds'); - this.windowBounds = this.window.getContentBounds(); - this.updateDownloadsDropdownMenu(); - this.repositionDownloadsDropdownMenu(); + const mainWindow = MainWindow.get(); + if (mainWindow) { + this.windowBounds = mainWindow.getContentBounds(); + this.updateDownloadsDropdownMenu(); + this.repositionDownloadsDropdownMenu(); + } } updateDownloadsDropdownMenu = () => { @@ -131,7 +138,7 @@ export default class DownloadsDropdownMenuView { this.item = item; this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT); this.view.setBounds(this.bounds); - this.window.setTopBrowserView(this.view); + MainWindow.get()?.setTopBrowserView(this.view); this.view.webContents.focus(); this.updateDownloadsDropdownMenu(); } diff --git a/src/main/views/downloadsDropdownView.test.js b/src/main/views/downloadsDropdownView.test.js index d4281d59..b6957f3a 100644 --- a/src/main/views/downloadsDropdownView.test.js +++ b/src/main/views/downloadsDropdownView.test.js @@ -7,6 +7,8 @@ import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state import {DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT, TAB_BAR_HEIGHT} from 'common/utils/constants'; +import MainWindow from 'main/windows/mainWindow'; + import DownloadsDropdownView from './downloadsDropdownView'; jest.mock('main/utils', () => ({ @@ -59,42 +61,35 @@ jest.mock('electron', () => { Notification: NotificationMock, }; }); +jest.mock('main/windows/mainWindow', () => ({ + get: jest.fn(), + getBounds: jest.fn(), +})); jest.mock('main/windows/windowManager', () => ({ sendToRenderer: jest.fn(), })); describe('main/views/DownloadsDropdownView', () => { beforeEach(() => { + MainWindow.get.mockReturnValue({addBrowserView: jest.fn(), setTopBrowserView: jest.fn()}); getDarwinDoNotDisturb.mockReturnValue(false); }); describe('getBounds', () => { it('should be placed far right when window is large enough', () => { - const window = { - getContentBounds: () => ({width: 800, height: 600, x: 0, y: 0}), - addBrowserView: jest.fn(), - setTopBrowserView: jest.fn(), - }; - const downloadsDropdownView = new DownloadsDropdownView(window, {}, false); + MainWindow.getBounds.mockReturnValue({width: 800, height: 600, x: 0, y: 0}); + const downloadsDropdownView = new DownloadsDropdownView(); expect(downloadsDropdownView.getBounds(DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT)).toStrictEqual({x: 800 - DOWNLOADS_DROPDOWN_FULL_WIDTH, y: TAB_BAR_HEIGHT, width: DOWNLOADS_DROPDOWN_FULL_WIDTH, height: DOWNLOADS_DROPDOWN_HEIGHT}); }); it('should be placed left if window is very small', () => { - const window = { - getContentBounds: () => ({width: 500, height: 400, x: 0, y: 0}), - addBrowserView: jest.fn(), - setTopBrowserView: jest.fn(), - }; - const downloadsDropdownView = new DownloadsDropdownView(window, {}, false); + MainWindow.getBounds.mockReturnValue({width: 500, height: 400, x: 0, y: 0}); + const downloadsDropdownView = new DownloadsDropdownView(); expect(downloadsDropdownView.getBounds(DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT)).toStrictEqual({x: 0, y: TAB_BAR_HEIGHT, width: DOWNLOADS_DROPDOWN_FULL_WIDTH, height: DOWNLOADS_DROPDOWN_HEIGHT}); }); }); it('should change the view bounds based on open/closed state', () => { - const window = { - getContentBounds: () => ({width: 800, height: 600, x: 0, y: 0}), - addBrowserView: jest.fn(), - setTopBrowserView: jest.fn(), - }; - const downloadsDropdownView = new DownloadsDropdownView(window, {}, false); + MainWindow.getBounds.mockReturnValue({width: 800, height: 600, x: 0, y: 0}); + const downloadsDropdownView = new DownloadsDropdownView(); downloadsDropdownView.bounds = {width: 400, height: 300}; downloadsDropdownView.handleOpen(); expect(downloadsDropdownView.view.setBounds).toBeCalledWith(downloadsDropdownView.bounds); diff --git a/src/main/views/downloadsDropdownView.ts b/src/main/views/downloadsDropdownView.ts index 8d2e5614..ed0faf24 100644 --- a/src/main/views/downloadsDropdownView.ts +++ b/src/main/views/downloadsDropdownView.ts @@ -1,7 +1,7 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {BrowserView, BrowserWindow, ipcMain, IpcMainEvent, IpcMainInvokeEvent} from 'electron'; +import {BrowserView, ipcMain, IpcMainEvent, IpcMainInvokeEvent} from 'electron'; import log from 'electron-log'; @@ -25,23 +25,38 @@ import {getLocalPreload, getLocalURLString} from 'main/utils'; import WindowManager from '../windows/windowManager'; import downloadsManager from 'main/downloadsManager'; +import MainWindow from 'main/windows/mainWindow'; export default class DownloadsDropdownView { bounds?: Electron.Rectangle; darkMode: boolean; downloads: DownloadedItems; - item: DownloadedItem | undefined; + item?: DownloadedItem; view: BrowserView; - window: BrowserWindow; windowBounds: Electron.Rectangle; - constructor(window: BrowserWindow, downloads: DownloadedItems, darkMode: boolean) { + constructor(downloads: DownloadedItems, darkMode: boolean) { this.downloads = downloads; - this.window = window; this.darkMode = darkMode; - this.item = undefined; - this.windowBounds = this.window.getContentBounds(); + ipcMain.on(OPEN_DOWNLOADS_DROPDOWN, this.handleOpen); + ipcMain.on(CLOSE_DOWNLOADS_DROPDOWN, this.handleClose); + ipcMain.on(EMIT_CONFIGURATION, this.updateConfig); + ipcMain.on(REQUEST_DOWNLOADS_DROPDOWN_INFO, this.updateDownloadsDropdown); + ipcMain.on(REQUEST_CLEAR_DOWNLOADS_DROPDOWN, this.clearDownloads); + ipcMain.on(RECEIVE_DOWNLOADS_DROPDOWN_SIZE, this.handleReceivedDownloadsDropdownSize); + ipcMain.on(DOWNLOADS_DROPDOWN_OPEN_FILE, this.openFile); + ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN, this.updateDownloads); + ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM, this.updateDownloadsDropdownMenuItem); + ipcMain.handle(GET_DOWNLOADED_IMAGE_THUMBNAIL_LOCATION, this.getDownloadImageThumbnailLocation); + + const mainWindow = MainWindow.get(); + const windowBounds = MainWindow.getBounds(); + if (!(mainWindow && windowBounds)) { + throw new Error('Cannot initialize downloadsDropdownView, missing MainWindow'); + } + + this.windowBounds = windowBounds; this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT); const preload = getLocalPreload('desktopAPI.js'); @@ -55,20 +70,8 @@ export default class DownloadsDropdownView { }}); this.view.webContents.loadURL(getLocalURLString('downloadsDropdown.html')); - this.window.addBrowserView(this.view); - this.view.webContents.session.webRequest.onHeadersReceived(downloadsManager.webRequestOnHeadersReceivedHandler); - - ipcMain.on(OPEN_DOWNLOADS_DROPDOWN, this.handleOpen); - ipcMain.on(CLOSE_DOWNLOADS_DROPDOWN, this.handleClose); - ipcMain.on(EMIT_CONFIGURATION, this.updateConfig); - ipcMain.on(REQUEST_DOWNLOADS_DROPDOWN_INFO, this.updateDownloadsDropdown); - ipcMain.on(REQUEST_CLEAR_DOWNLOADS_DROPDOWN, this.clearDownloads); - ipcMain.on(RECEIVE_DOWNLOADS_DROPDOWN_SIZE, this.handleReceivedDownloadsDropdownSize); - ipcMain.on(DOWNLOADS_DROPDOWN_OPEN_FILE, this.openFile); - ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN, this.updateDownloads); - ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM, this.updateDownloadsDropdownMenuItem); - ipcMain.handle(GET_DOWNLOADED_IMAGE_THUMBNAIL_LOCATION, this.getDownloadImageThumbnailLocation); + mainWindow.addBrowserView(this.view); } updateDownloads = (event: IpcMainEvent, downloads: DownloadedItems) => { @@ -99,9 +102,12 @@ export default class DownloadsDropdownView { updateWindowBounds = () => { log.debug('DownloadsDropdownView.updateWindowBounds'); - this.windowBounds = this.window.getContentBounds(); - this.updateDownloadsDropdown(); - this.repositionDownloadsDropdown(); + const mainWindow = MainWindow.get(); + if (mainWindow) { + this.windowBounds = mainWindow.getContentBounds(); + this.updateDownloadsDropdown(); + this.repositionDownloadsDropdown(); + } } updateDownloadsDropdown = () => { @@ -124,7 +130,7 @@ export default class DownloadsDropdownView { } this.view.setBounds(this.bounds); - this.window.setTopBrowserView(this.view); + MainWindow.get()?.setTopBrowserView(this.view); this.view.webContents.focus(); downloadsManager.onOpen(); WindowManager.sendToRenderer(OPEN_DOWNLOADS_DROPDOWN); @@ -172,7 +178,7 @@ export default class DownloadsDropdownView { } repositionDownloadsDropdown = () => { - if (!this.bounds) { + if (!(this.bounds && this.windowBounds)) { return; } this.bounds = { diff --git a/src/main/views/teamDropdownView.test.js b/src/main/views/teamDropdownView.test.js index 213f5fa1..dd8b3f1d 100644 --- a/src/main/views/teamDropdownView.test.js +++ b/src/main/views/teamDropdownView.test.js @@ -5,6 +5,8 @@ import {TAB_BAR_HEIGHT, THREE_DOT_MENU_WIDTH, THREE_DOT_MENU_WIDTH_MAC, MENU_SHADOW_WIDTH} from 'common/utils/constants'; +import MainWindow from 'main/windows/mainWindow'; + import TeamDropdownView from './teamDropdownView'; jest.mock('main/utils', () => ({ @@ -24,20 +26,23 @@ jest.mock('electron', () => ({ on: jest.fn(), }, })); - +jest.mock('main/windows/mainWindow', () => ({ + get: jest.fn(), + getBounds: jest.fn(), + addBrowserView: jest.fn(), + setTopBrowserView: 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); + beforeEach(() => { + MainWindow.getBounds.mockReturnValue({width: 500, height: 400, x: 0, y: 0}); + }); + + const teamDropdownView = new TeamDropdownView([], 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}); @@ -50,7 +55,7 @@ describe('main/views/teamDropdownView', () => { }); it('should change the view bounds based on open/closed state', () => { - const teamDropdownView = new TeamDropdownView(window, [], false, true); + const teamDropdownView = new TeamDropdownView([], false, true); teamDropdownView.bounds = {width: 400, height: 300}; teamDropdownView.handleOpen(); expect(teamDropdownView.view.setBounds).toBeCalledWith(teamDropdownView.bounds); @@ -60,7 +65,7 @@ describe('main/views/teamDropdownView', () => { describe('addGpoToTeams', () => { it('should return teams with "isGPO": false when no config.registryTeams exist', () => { - const teamDropdownView = new TeamDropdownView(window, [], false, true); + const teamDropdownView = new TeamDropdownView([], false, true); const teams = [{ name: 'team-1', url: 'https://mattermost.team-1.com', @@ -81,7 +86,7 @@ describe('main/views/teamDropdownView', () => { }]); }); it('should return teams with "isGPO": true if they exist in config.registryTeams', () => { - const teamDropdownView = new TeamDropdownView(window, [], false, true); + const teamDropdownView = new TeamDropdownView([], false, true); const teams = [{ name: 'team-1', url: 'https://mattermost.team-1.com', diff --git a/src/main/views/teamDropdownView.ts b/src/main/views/teamDropdownView.ts index 52c1c2d9..3f22a42b 100644 --- a/src/main/views/teamDropdownView.ts +++ b/src/main/views/teamDropdownView.ts @@ -1,7 +1,7 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {BrowserView, BrowserWindow, ipcMain, IpcMainEvent} from 'electron'; +import {BrowserView, ipcMain, IpcMainEvent} from 'electron'; import log from 'electron-log'; @@ -21,6 +21,7 @@ import * as AppState from '../appState'; import {TAB_BAR_HEIGHT, THREE_DOT_MENU_WIDTH, THREE_DOT_MENU_WIDTH_MAC, MENU_SHADOW_WIDTH} from 'common/utils/constants'; import {getLocalPreload, getLocalURLString} from 'main/utils'; import WindowManager from '../windows/windowManager'; +import MainWindow from '../windows/mainWindow'; export default class TeamDropdownView { view: BrowserView; @@ -33,18 +34,16 @@ export default class TeamDropdownView { unreads?: Map; mentions?: Map; expired?: Map; - window: BrowserWindow; - windowBounds: Electron.Rectangle; + windowBounds?: Electron.Rectangle; isOpen: boolean; - constructor(window: BrowserWindow, teams: TeamWithTabs[], darkMode: boolean, enableServerManagement: boolean) { + constructor(teams: TeamWithTabs[], darkMode: boolean, enableServerManagement: boolean) { this.teams = this.addGpoToTeams(teams, []); - this.window = window; this.darkMode = darkMode; this.enableServerManagement = enableServerManagement; this.isOpen = false; - this.windowBounds = this.window.getContentBounds(); + this.windowBounds = MainWindow.getBounds(); const preload = getLocalPreload('desktopAPI.js'); this.view = new BrowserView({webPreferences: { @@ -57,7 +56,7 @@ export default class TeamDropdownView { }}); this.view.webContents.loadURL(getLocalURLString('dropdown.html')); - this.window.addBrowserView(this.view); + MainWindow.get()?.addBrowserView(this.view); ipcMain.on(OPEN_TEAMS_DROPDOWN, this.handleOpen); ipcMain.on(CLOSE_TEAMS_DROPDOWN, this.handleClose); @@ -95,7 +94,7 @@ export default class TeamDropdownView { } updateWindowBounds = () => { - this.windowBounds = this.window.getContentBounds(); + this.windowBounds = MainWindow.getBounds(); this.updateDropdown(); } @@ -123,7 +122,7 @@ export default class TeamDropdownView { return; } this.view.setBounds(this.bounds); - this.window.setTopBrowserView(this.view); + MainWindow.get()?.setTopBrowserView(this.view); this.view.webContents.focus(); WindowManager.sendToRenderer(OPEN_TEAMS_DROPDOWN); this.isOpen = true; diff --git a/src/main/views/viewManager.test.js b/src/main/views/viewManager.test.js index 89f64dc6..48356ec0 100644 --- a/src/main/views/viewManager.test.js +++ b/src/main/views/viewManager.test.js @@ -7,11 +7,13 @@ import {dialog, ipcMain} from 'electron'; import {Tuple as tuple} from '@bloomberg/record-tuple-polyfill'; -import {BROWSER_HISTORY_PUSH, LOAD_SUCCESS, MAIN_WINDOW_SHOWN} from 'common/communication'; +import {LOAD_SUCCESS, MAIN_WINDOW_SHOWN, BROWSER_HISTORY_PUSH} from 'common/communication'; import {MattermostServer} from 'common/servers/MattermostServer'; import {getTabViewName} from 'common/tabs/TabView'; import {equalUrlsIgnoringSubpath} from 'common/utils/url'; +import MainWindow from 'main/windows/mainWindow'; + import {MattermostView} from './MattermostView'; import {ViewManager} from './viewManager'; @@ -56,6 +58,9 @@ jest.mock('main/server/serverInfo', () => ({ ServerInfo: jest.fn(), })); +jest.mock('main/windows/mainWindow', () => ({ + get: jest.fn(), +})); jest.mock('./MattermostView', () => ({ MattermostView: jest.fn(), })); @@ -174,15 +179,18 @@ describe('main/views/viewManager', () => { }); describe('reloadConfiguration', () => { - const viewManager = new ViewManager({}); + const viewManager = new ViewManager(); beforeEach(() => { viewManager.loadView = jest.fn(); viewManager.showByName = jest.fn(); viewManager.showInitial = jest.fn(); - viewManager.mainWindow.webContents = { - send: jest.fn(), + const mainWindow = { + webContents: { + send: jest.fn(), + }, }; + MainWindow.get.mockReturnValue(mainWindow); viewManager.getServerView = jest.fn().mockImplementation((srv, tabName) => ({ name: `${srv.name}-${tabName}`, @@ -653,10 +661,11 @@ describe('main/views/viewManager', () => { setTopBrowserView: jest.fn(), addBrowserView: jest.fn(), }; - const viewManager = new ViewManager(window); + const viewManager = new ViewManager(); const loadingScreen = {webContents: {send: jest.fn(), isLoading: () => false}}; beforeEach(() => { + MainWindow.get.mockReturnValue(window); viewManager.createLoadingScreen = jest.fn(); viewManager.setLoadingScreenBounds = jest.fn(); window.getBrowserViews.mockImplementation(() => []); diff --git a/src/main/views/viewManager.ts b/src/main/views/viewManager.ts index 34b59a97..7d880471 100644 --- a/src/main/views/viewManager.ts +++ b/src/main/views/viewManager.ts @@ -1,8 +1,10 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. + +import {BrowserView, dialog, ipcMain, IpcMainEvent} from 'electron'; import log from 'electron-log'; -import {BrowserView, BrowserWindow, dialog, ipcMain, IpcMainEvent} from 'electron'; import {BrowserViewConstructorOptions} from 'electron/main'; + import {Tuple as tuple} from '@bloomberg/record-tuple-polyfill'; import {Tab, TeamWithTabs} from 'types/config'; @@ -33,6 +35,7 @@ import PlaybooksTabView from 'common/tabs/PlaybooksTabView'; import {localizeMessage} from 'main/i18nManager'; import {ServerInfo} from 'main/server/serverInfo'; +import MainWindow from 'main/windows/mainWindow'; import {getLocalURLString, getLocalPreload, getWindowBoundaries} from '../utils'; @@ -57,23 +60,17 @@ export class ViewManager { currentView?: string; urlView?: BrowserView; urlViewCancel?: () => void; - mainWindow: BrowserWindow; loadingScreen?: BrowserView; loadingScreenState: LoadingScreenState; - constructor(mainWindow: BrowserWindow) { + constructor() { this.lastActiveServer = Config.lastActiveTeam; this.viewOptions = {webPreferences: {spellcheck: Config.useSpellChecker}}; this.views = new Map(); // keep in mind that this doesn't need to hold server order, only tabs on the renderer need that. - this.mainWindow = mainWindow; this.closedViews = new Map(); this.loadingScreenState = LoadingScreenState.HIDDEN; } - updateMainWindow = (mainWindow: BrowserWindow) => { - this.mainWindow = mainWindow; - } - getServers = () => { return Config.teams.concat(); } @@ -86,7 +83,7 @@ export class ViewManager { makeView = (srv: MattermostServer, serverInfo: ServerInfo, tab: Tab, url?: string): MattermostView => { const tabView = this.getServerView(srv, tab.name); - const view = new MattermostView(tabView, serverInfo, this.mainWindow, this.viewOptions); + const view = new MattermostView(tabView, serverInfo, this.viewOptions); view.once(LOAD_SUCCESS, this.activateView); view.load(url); view.on(UPDATE_TARGET_URL, this.showURLView); @@ -186,7 +183,7 @@ export class ViewManager { this.currentView = undefined; this.showInitial(); } else { - this.mainWindow.webContents.send(SET_ACTIVE_VIEW); + MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW); } } @@ -196,7 +193,7 @@ export class ViewManager { if (view) { this.currentView = view.name; this.showByName(view.name); - this.mainWindow.webContents.send(SET_ACTIVE_VIEW, view.tab.server.name, view.tab.type); + MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW, view.tab.server.name, view.tab.type); } } else { this.showInitial(); @@ -221,7 +218,7 @@ export class ViewManager { } } } else { - this.mainWindow.webContents.send(SET_ACTIVE_VIEW, null, null); + MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW, null, null); ipcMain.emit(MAIN_WINDOW_SHOWN); } } @@ -248,7 +245,7 @@ export class ViewManager { this.showLoadingScreen(); } } - newView.window.webContents.send(SET_ACTIVE_VIEW, newView.tab.server.name, newView.tab.type); + MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW, newView.tab.server.name, newView.tab.type); ipcMain.emit(SET_ACTIVE_VIEW, true, newView.tab.server.name, newView.tab.type); if (newView.isReady()) { ipcMain.emit(UPDATE_LAST_ACTIVE, true, newView.tab.server.name, newView.tab.type); @@ -375,13 +372,13 @@ export class ViewManager { const query = new Map([['url', urlString]]); const localURL = getLocalURLString('urlView.html', query); urlView.webContents.loadURL(localURL); - this.mainWindow.addBrowserView(urlView); - const boundaries = this.views.get(this.currentView || '')?.view.getBounds() ?? this.mainWindow.getBounds(); + MainWindow.get()?.addBrowserView(urlView); + const boundaries = this.views.get(this.currentView || '')?.view.getBounds() ?? MainWindow.get()!.getBounds(); const hideView = () => { delete this.urlViewCancel; try { - this.mainWindow.removeBrowserView(urlView); + MainWindow.get()?.removeBrowserView(urlView); } catch (e) { log.error('Failed to remove URL view', e); } @@ -421,9 +418,11 @@ export class ViewManager { } setLoadingScreenBounds = () => { - if (this.loadingScreen) { - this.loadingScreen.setBounds(getWindowBoundaries(this.mainWindow)); + const mainWindow = MainWindow.get(); + if (!mainWindow) { + return; } + this.loadingScreen?.setBounds(getWindowBoundaries(mainWindow)); } createLoadingScreen = () => { @@ -441,6 +440,11 @@ export class ViewManager { } showLoadingScreen = () => { + const mainWindow = MainWindow.get(); + if (!mainWindow) { + return; + } + if (!this.loadingScreen) { this.createLoadingScreen(); } @@ -455,10 +459,10 @@ export class ViewManager { this.loadingScreen!.webContents.send(TOGGLE_LOADING_SCREEN_VISIBILITY, true); } - if (this.mainWindow.getBrowserViews().includes(this.loadingScreen!)) { - this.mainWindow.setTopBrowserView(this.loadingScreen!); + if (mainWindow.getBrowserViews().includes(this.loadingScreen!)) { + mainWindow.setTopBrowserView(this.loadingScreen!); } else { - this.mainWindow.addBrowserView(this.loadingScreen!); + mainWindow.addBrowserView(this.loadingScreen!); } this.setLoadingScreenBounds(); @@ -474,7 +478,7 @@ export class ViewManager { hideLoadingScreen = () => { if (this.loadingScreen && this.loadingScreenState !== LoadingScreenState.HIDDEN) { this.loadingScreenState = LoadingScreenState.HIDDEN; - this.mainWindow.removeBrowserView(this.loadingScreen); + MainWindow.get()?.removeBrowserView(this.loadingScreen); } } diff --git a/src/main/windows/mainWindow.test.js b/src/main/windows/mainWindow.test.js index 8134c92c..d6af271c 100644 --- a/src/main/windows/mainWindow.test.js +++ b/src/main/windows/mainWindow.test.js @@ -10,21 +10,24 @@ import {BrowserWindow, screen, app, globalShortcut, dialog} from 'electron'; import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB} from 'common/communication'; import Config from 'common/config'; import {DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH} from 'common/utils/constants'; - -import ContextMenu from '../contextMenu'; import * as Validator from 'common/Validator'; -import createMainWindow from './mainWindow'; +import ContextMenu from '../contextMenu'; +import {isInsideRectangle} from '../utils'; +import {MainWindow} from './mainWindow'; jest.mock('path', () => ({ join: jest.fn(), + resolve: jest.fn(), })); jest.mock('electron', () => ({ app: { + getAppPath: jest.fn(), getPath: jest.fn(), hide: jest.fn(), quit: jest.fn(), + relaunch: jest.fn(), }, dialog: { showMessageBox: jest.fn(), @@ -65,6 +68,7 @@ jest.mock('common/Validator', () => ({ jest.mock('../contextMenu', () => jest.fn()); jest.mock('../utils', () => ({ + isInsideRectangle: jest.fn(), getLocalPreload: jest.fn(), getLocalURLString: jest.fn(), })); @@ -76,7 +80,7 @@ jest.mock('main/i18nManager', () => ({ 'use strict'; describe('main/windows/mainWindow', () => { - describe('createMainWindow', () => { + describe('init', () => { const baseWindow = { setMenuBarVisibility: jest.fn(), loadURL: jest.fn(), @@ -90,6 +94,7 @@ describe('main/windows/mainWindow', () => { webContents: { on: jest.fn(), send: jest.fn(), + setWindowOpenHandler: jest.fn(), }, isMaximized: jest.fn(), isFullScreen: jest.fn(), @@ -104,6 +109,7 @@ describe('main/windows/mainWindow', () => { fs.readFileSync.mockImplementation(() => '{"x":400,"y":300,"width":1280,"height":700,"maximized":false,"fullscreen":false}'); path.join.mockImplementation(() => 'anyfile.txt'); screen.getDisplayMatching.mockImplementation(() => ({bounds: {x: 0, y: 0, width: 1920, height: 1080}})); + isInsideRectangle.mockReturnValue(true); Validator.validateBoundsInfo.mockImplementation((data) => data); ContextMenu.mockImplementation(() => ({ reload: jest.fn(), @@ -115,7 +121,8 @@ describe('main/windows/mainWindow', () => { }); it('should set window size using bounds read from file', () => { - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ x: 400, y: 300, @@ -126,16 +133,10 @@ describe('main/windows/mainWindow', () => { })); }); - it('should open in fullscreen if fullscreen set to true', () => { - createMainWindow({fullscreen: true}); - expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ - fullscreen: true, - })); - }); - it('should set default window size when failing to read bounds from file', () => { fs.readFileSync.mockImplementation(() => 'just a bunch of garbage'); - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT, @@ -145,27 +146,15 @@ describe('main/windows/mainWindow', () => { it('should set default window size when bounds are outside the normal screen', () => { fs.readFileSync.mockImplementation(() => '{"x":-400,"y":-300,"width":1280,"height":700,"maximized":false,"fullscreen":false}'); screen.getDisplayMatching.mockImplementation(() => ({bounds: {x: 0, y: 0, width: 1920, height: 1080}})); - createMainWindow({}); + isInsideRectangle.mockReturnValue(false); + const mainWindow = new MainWindow(); + mainWindow.init(); expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT, })); }); - it('should set linux app icon', () => { - const originalPlatform = process.platform; - Object.defineProperty(process, 'platform', { - value: 'linux', - }); - createMainWindow({linuxAppIcon: 'linux-icon.png'}); - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }); - expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ - icon: 'linux-icon.png', - })); - }); - it('should reset zoom level and maximize if applicable on ready-to-show', () => { const window = { ...baseWindow, @@ -178,7 +167,8 @@ describe('main/windows/mainWindow', () => { BrowserWindow.mockImplementation(() => window); fs.readFileSync.mockImplementation(() => '{"x":400,"y":300,"width":1280,"height":700,"maximized":true,"fullscreen":false}'); Config.hideOnStart = false; - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); expect(window.webContents.zoomLevel).toStrictEqual(0); expect(window.maximize).toBeCalled(); }); @@ -195,7 +185,8 @@ describe('main/windows/mainWindow', () => { BrowserWindow.mockImplementation(() => window); fs.readFileSync.mockImplementation(() => '{"x":400,"y":300,"width":1280,"height":700,"maximized":true,"fullscreen":false}'); Config.hideOnStart = true; - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); expect(window.show).not.toHaveBeenCalled(); }); @@ -210,7 +201,8 @@ describe('main/windows/mainWindow', () => { }), }; BrowserWindow.mockImplementation(() => window); - createMainWindow({}, {}); + const mainWindow = new MainWindow(); + mainWindow.init(); global.willAppQuit = false; expect(fs.writeFileSync).toHaveBeenCalled(); }); @@ -231,7 +223,8 @@ describe('main/windows/mainWindow', () => { BrowserWindow.mockImplementation(() => window); Config.minimizeToTray = true; Config.alwaysMinimize = true; - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); Config.minimizeToTray = false; Config.alwaysMinimize = false; Object.defineProperty(process, 'platform', { @@ -255,7 +248,8 @@ describe('main/windows/mainWindow', () => { }; BrowserWindow.mockImplementation(() => window); Config.alwaysClose = true; - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); Config.alwaysClose = false; Object.defineProperty(process, 'platform', { value: originalPlatform, @@ -278,11 +272,13 @@ describe('main/windows/mainWindow', () => { }; BrowserWindow.mockImplementation(() => window); dialog.showMessageBox.mockResolvedValue({response: 1}); - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); expect(app.quit).not.toHaveBeenCalled(); const promise = Promise.resolve({response: 0}); dialog.showMessageBox.mockImplementation(() => promise); - createMainWindow({}); + const mainWindow2 = new MainWindow(); + mainWindow2.init(); Object.defineProperty(process, 'platform', { value: originalPlatform, }); @@ -306,7 +302,8 @@ describe('main/windows/mainWindow', () => { BrowserWindow.mockImplementation(() => window); Config.minimizeToTray = true; Config.alwaysMinimize = true; - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); Config.minimizeToTray = false; Config.alwaysMinimize = false; Object.defineProperty(process, 'platform', { @@ -330,7 +327,8 @@ describe('main/windows/mainWindow', () => { }; BrowserWindow.mockImplementation(() => window); Config.alwaysClose = true; - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); Config.alwaysClose = false; Object.defineProperty(process, 'platform', { value: originalPlatform, @@ -353,11 +351,13 @@ describe('main/windows/mainWindow', () => { }; BrowserWindow.mockImplementation(() => window); dialog.showMessageBox.mockResolvedValue({response: 1}); - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); expect(app.quit).not.toHaveBeenCalled(); const promise = Promise.resolve({response: 0}); dialog.showMessageBox.mockImplementation(() => promise); - createMainWindow({}); + const mainWindow2 = new MainWindow(); + mainWindow2.init(); Object.defineProperty(process, 'platform', { value: originalPlatform, }); @@ -379,7 +379,8 @@ describe('main/windows/mainWindow', () => { }), }; BrowserWindow.mockImplementation(() => window); - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); Object.defineProperty(process, 'platform', { value: originalPlatform, }); @@ -407,7 +408,8 @@ describe('main/windows/mainWindow', () => { }), }; BrowserWindow.mockImplementation(() => window); - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); Object.defineProperty(process, 'platform', { value: originalPlatform, }); @@ -434,7 +436,8 @@ describe('main/windows/mainWindow', () => { }, }; BrowserWindow.mockImplementation(() => window); - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); Object.defineProperty(process, 'platform', { value: originalPlatform, }); @@ -456,11 +459,28 @@ describe('main/windows/mainWindow', () => { }), }; BrowserWindow.mockImplementation(() => window); - createMainWindow({}); + const mainWindow = new MainWindow(); + mainWindow.init(); Object.defineProperty(process, 'platform', { value: originalPlatform, }); expect(globalShortcut.registerAll).toHaveBeenCalledWith(['Alt+F', 'Alt+E', 'Alt+V', 'Alt+H', 'Alt+W', 'Alt+P'], expect.any(Function)); }); }); + + describe('onUnresponsive', () => { + const mainWindow = new MainWindow(); + + beforeEach(() => { + mainWindow.win = {}; + }); + + it('should call app.relaunch when user elects not to wait', async () => { + const promise = Promise.resolve({response: 0}); + dialog.showMessageBox.mockImplementation(() => promise); + mainWindow.onUnresponsive(); + await promise; + expect(app.relaunch).toBeCalled(); + }); + }); }); diff --git a/src/main/windows/mainWindow.ts b/src/main/windows/mainWindow.ts index 16a0c59b..318ceae5 100644 --- a/src/main/windows/mainWindow.ts +++ b/src/main/windows/mainWindow.ts @@ -3,14 +3,16 @@ import fs from 'fs'; +import path from 'path'; + import os from 'os'; -import {app, BrowserWindow, BrowserWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen} from 'electron'; +import {app, BrowserWindow, BrowserWindowConstructorOptions, dialog, Event, globalShortcut, Input, ipcMain, screen} from 'electron'; import log from 'electron-log'; import {SavedWindowState} from 'types/mainWindow'; -import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB, GET_FULL_SCREEN_STATUS} from 'common/communication'; +import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB, GET_FULL_SCREEN_STATUS, FOCUS_THREE_DOT_MENU} from 'common/communication'; import Config from 'common/config'; import {DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, MINIMUM_WINDOW_HEIGHT, MINIMUM_WINDOW_WIDTH} from 'common/utils/constants'; import Utils from 'common/utils/util'; @@ -20,53 +22,121 @@ import {boundsInfoPath} from 'main/constants'; import {localizeMessage} from 'main/i18nManager'; import ContextMenu from '../contextMenu'; -import {getLocalPreload, getLocalURLString} from '../utils'; +import {getLocalPreload, getLocalURLString, isInsideRectangle} from '../utils'; -function saveWindowState(file: string, window: BrowserWindow) { - const windowState: SavedWindowState = { - ...window.getBounds(), - maximized: window.isMaximized(), - fullscreen: window.isFullScreen(), - }; - try { - fs.writeFileSync(file, JSON.stringify(windowState)); - } catch (e) { - // [Linux] error happens only when the window state is changed before the config dir is created. - log.error(e); - } -} +const ALT_MENU_KEYS = ['Alt+F', 'Alt+E', 'Alt+V', 'Alt+H', 'Alt+W', 'Alt+P']; -function isInsideRectangle(container: Electron.Rectangle, rect: Electron.Rectangle) { - return container.x <= rect.x && container.y <= rect.y && container.width >= rect.width && container.height >= rect.height; -} +export class MainWindow { + private win?: BrowserWindow; -function isFramelessWindow() { - return os.platform() === 'darwin' || (os.platform() === 'win32' && Utils.isVersionGreaterThanOrEqualTo(os.release(), '6.2')); -} + private savedWindowState: SavedWindowState; + private ready: boolean; -function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean}) { - // Create the browser window. - const preload = getLocalPreload('desktopAPI.js'); - let savedWindowState: any; - try { - savedWindowState = JSON.parse(fs.readFileSync(boundsInfoPath, 'utf-8')); - savedWindowState = Validator.validateBoundsInfo(savedWindowState); - if (!savedWindowState) { - throw new Error('Provided bounds info file does not validate, using defaults instead.'); - } - const matchingScreen = screen.getDisplayMatching(savedWindowState); - if (!(matchingScreen && (isInsideRectangle(matchingScreen.bounds, savedWindowState) || savedWindowState.maximized))) { - throw new Error('Provided bounds info are outside the bounds of your screen, using defaults instead.'); - } - } catch (e) { - // Follow Electron's defaults, except for window dimensions which targets 1024x768 screen resolution. - savedWindowState = {width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT}; + constructor() { + // Create the browser window. + this.ready = false; + this.savedWindowState = this.getSavedWindowState(); + + ipcMain.handle(GET_FULL_SCREEN_STATUS, () => this.win?.isFullScreen()); } - const {maximized: windowIsMaximized} = savedWindowState; + init = () => { + const windowOptions: BrowserWindowConstructorOptions = Object.assign({}, this.savedWindowState, { + title: app.name, + fullscreenable: true, + show: false, // don't start the window until it is ready and only if it isn't hidden + paintWhenInitiallyHidden: true, // we want it to start painting to get info from the webapp + minWidth: MINIMUM_WINDOW_WIDTH, + minHeight: MINIMUM_WINDOW_HEIGHT, + frame: !this.isFramelessWindow(), + fullscreen: this.shouldStartFullScreen(), + titleBarStyle: 'hidden' as const, + trafficLightPosition: {x: 12, y: 12}, + backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do + webPreferences: { + disableBlinkFeatures: 'Auxclick', + preload: getLocalPreload('desktopAPI.js'), + spellcheck: typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker, + }, + }); - const spellcheck = (typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker); - const isFullScreen = () => { + if (process.platform === 'linux') { + windowOptions.icon = path.join(path.resolve(app.getAppPath(), 'assets'), 'linux', 'app_icon.png'); + } + + this.win = new BrowserWindow(windowOptions); + this.win.setMenuBarVisibility(false); + + if (!this.win) { + throw new Error('unable to create main window'); + } + + const localURL = getLocalURLString('index.html'); + this.win.loadURL(localURL).catch( + (reason) => { + log.error('failed to load', reason); + }); + this.win.once('ready-to-show', () => { + if (!this.win) { + return; + } + this.win.webContents.zoomLevel = 0; + + if (Config.hideOnStart === false) { + this.win.show(); + if (this.savedWindowState.maximized) { + this.win.maximize(); + } + } + + this.ready = true; + }); + + this.win.once('restore', () => { + this.win?.restore(); + }); + + this.win.on('close', this.onClose); + this.win.on('closed', this.onClosed); + this.win.on('focus', this.onFocus); + this.win.on('blur', this.onBlur); + this.win.on('unresponsive', this.onUnresponsive); + + this.win.webContents.on('before-input-event', this.onBeforeInputEvent); + + // Should not allow the main window to generate a window of its own + this.win.webContents.setWindowOpenHandler(() => ({action: 'deny'})); + if (process.env.MM_DEBUG_SETTINGS) { + this.win.webContents.openDevTools({mode: 'detach'}); + } + + const contextMenu = new ContextMenu({}, this.win); + contextMenu.reload(); + } + + get isReady() { + return this.ready; + } + + get = (ensureCreated?: boolean) => { + if (ensureCreated && !this.win) { + this.init(); + } + return this.win; + } + + getBounds = () => { + return this.win?.getContentBounds(); + } + + focusThreeDotMenu = () => { + if (this.win) { + this.win.webContents.focus(); + this.win.webContents.send(FOCUS_THREE_DOT_MENU); + } + } + + private shouldStartFullScreen = () => { if (global?.args?.fullscreen !== undefined) { return global.args.fullscreen; } @@ -74,75 +144,94 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean}) if (Config.startInFullscreen) { return Config.startInFullscreen; } - return options.fullscreen || savedWindowState.fullscreen || false; - }; - - const windowOptions: BrowserWindowConstructorOptions = Object.assign({}, savedWindowState, { - title: app.name, - fullscreenable: true, - show: false, // don't start the window until it is ready and only if it isn't hidden - paintWhenInitiallyHidden: true, // we want it to start painting to get info from the webapp - minWidth: MINIMUM_WINDOW_WIDTH, - minHeight: MINIMUM_WINDOW_HEIGHT, - frame: !isFramelessWindow(), - fullscreen: isFullScreen(), - titleBarStyle: 'hidden' as const, - trafficLightPosition: {x: 12, y: 12}, - backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do - webPreferences: { - disableBlinkFeatures: 'Auxclick', - preload, - spellcheck, - }, - }); - - if (process.platform === 'linux') { - windowOptions.icon = options.linuxAppIcon; + return this.savedWindowState.fullscreen || false; } - const mainWindow = new BrowserWindow(windowOptions); - mainWindow.setMenuBarVisibility(false); - - try { - ipcMain.handle(GET_FULL_SCREEN_STATUS, () => mainWindow.isFullScreen()); - } catch (e) { - log.error('Tried to register second handler, skipping'); + private isFramelessWindow = () => { + return os.platform() === 'darwin' || (os.platform() === 'win32' && Utils.isVersionGreaterThanOrEqualTo(os.release(), '6.2')); } - const localURL = getLocalURLString('index.html'); - mainWindow.loadURL(localURL).catch( - (reason) => { - log.error(`Main window failed to load: ${reason}`); - }); - mainWindow.once('ready-to-show', () => { - mainWindow.webContents.zoomLevel = 0; + private getSavedWindowState = () => { + let savedWindowState: any; + try { + savedWindowState = JSON.parse(fs.readFileSync(boundsInfoPath, 'utf-8')); + savedWindowState = Validator.validateBoundsInfo(savedWindowState); + if (!savedWindowState) { + throw new Error('Provided bounds info file does not validate, using defaults instead.'); + } + const matchingScreen = screen.getDisplayMatching(savedWindowState); + if (!(matchingScreen && (isInsideRectangle(matchingScreen.bounds, savedWindowState) || savedWindowState.maximized))) { + throw new Error('Provided bounds info are outside the bounds of your screen, using defaults instead.'); + } + } catch (e) { + // Follow Electron's defaults, except for window dimensions which targets 1024x768 screen resolution. + savedWindowState = {width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT}; + } + return savedWindowState; + } - if (Config.hideOnStart === false) { - mainWindow.show(); - if (windowIsMaximized) { - mainWindow.maximize(); + private saveWindowState = (file: string, window: BrowserWindow) => { + const windowState: SavedWindowState = { + ...window.getBounds(), + maximized: window.isMaximized(), + fullscreen: window.isFullScreen(), + }; + try { + fs.writeFileSync(file, JSON.stringify(windowState)); + } catch (e) { + // [Linux] error happens only when the window state is changed before the config dir is created. + log.error('failed to save window state', e); + } + } + + private onBeforeInputEvent = (event: Event, input: Input) => { + // Register keyboard shortcuts + // Add Alt+Cmd+(Right|Left) as alternative to switch between servers + if (this.win && process.platform === 'darwin') { + if (input.alt && input.meta) { + if (input.key === 'ArrowRight') { + this.win.webContents.send(SELECT_NEXT_TAB); + } + if (input.key === 'ArrowLeft') { + this.win.webContents.send(SELECT_PREVIOUS_TAB); + } } } - }); + } - mainWindow.once('restore', () => { - mainWindow.restore(); - }); + private onFocus = () => { + // Only add shortcuts when window is in focus + if (process.platform === 'linux') { + globalShortcut.registerAll(ALT_MENU_KEYS, () => { + // do nothing because we want to supress the menu popping up + }); + } + } - // App should save bounds when a window is closed. - // However, 'close' is not fired in some situations(shutdown, ctrl+c) - // because main process is killed in such situations. - // 'blur' event was effective in order to avoid this. - // Ideally, app should detect that OS is shutting down. - mainWindow.on('blur', () => { - saveWindowState(boundsInfoPath, mainWindow); - }); + private onBlur = () => { + if (!this.win) { + return; + } - mainWindow.on('close', (event) => { + globalShortcut.unregisterAll(); + + // App should save bounds when a window is closed. + // However, 'close' is not fired in some situations(shutdown, ctrl+c) + // because main process is killed in such situations. + // 'blur' event was effective in order to avoid this. + // Ideally, app should detect that OS is shutting down. + this.saveWindowState(boundsInfoPath, this.win); + } + + private onClose = (event: Event) => { log.debug('MainWindow.on.close'); + if (!this.win) { + return; + } + if (global.willAppQuit) { // when [Ctrl|Cmd]+Q - saveWindowState(boundsInfoPath, mainWindow); + this.saveWindowState(boundsInfoPath, this.win); } else { // Minimize or hide the window for close button. event.preventDefault(); function hideWindow(window: BrowserWindow) { @@ -154,9 +243,9 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean}) case 'linux': if (Config.minimizeToTray) { if (Config.alwaysMinimize) { - hideWindow(mainWindow); + hideWindow(this.win); } else { - dialog.showMessageBox(mainWindow, { + dialog.showMessageBox(this.win, { title: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.title', 'Minimize to Tray'), message: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.message', '{appName} will continue to run in the system tray. This can be disabled in Settings.', {appName: app.name}), type: 'info', @@ -164,13 +253,13 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean}) checkboxLabel: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.checkboxLabel', 'Don\'t show again'), }).then((result: {response: number; checkboxChecked: boolean}) => { Config.set('alwaysMinimize', result.checkboxChecked); - hideWindow(mainWindow); + hideWindow(this.win!); }); } } else if (Config.alwaysClose) { app.quit(); } else { - dialog.showMessageBox(mainWindow, { + dialog.showMessageBox(this.win, { title: localizeMessage('main.windows.mainWindow.closeApp.dialog.title', 'Close Application'), message: localizeMessage('main.windows.mainWindow.closeApp.dialog.message', 'Are you sure you want to quit?'), detail: localizeMessage('main.windows.mainWindow.closeApp.dialog.detail', 'You will no longer receive notifications for messages. If you want to leave {appName} running in the system tray, you can enable this in Settings.', {appName: app.name}), @@ -191,11 +280,11 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean}) break; case 'darwin': // need to leave fullscreen first, then hide the window - if (mainWindow.isFullScreen()) { - mainWindow.once('leave-full-screen', () => { + if (this.win.isFullScreen()) { + this.win.once('leave-full-screen', () => { app.hide(); }); - mainWindow.setFullScreen(false); + this.win.setFullScreen(false); } else { app.hide(); } @@ -203,39 +292,35 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean}) default: } } - }); + } - // Register keyboard shortcuts - mainWindow.webContents.on('before-input-event', (event, input) => { - // Add Alt+Cmd+(Right|Left) as alternative to switch between servers - if (process.platform === 'darwin') { - if (input.alt && input.meta) { - if (input.key === 'ArrowRight') { - mainWindow.webContents.send(SELECT_NEXT_TAB); - } - if (input.key === 'ArrowLeft') { - mainWindow.webContents.send(SELECT_PREVIOUS_TAB); - } + private onClosed = () => { + log.verbose('main window closed'); + delete this.win; + this.ready = false; + } + + private onUnresponsive = () => { + if (!this.win) { + throw new Error('BrowserWindow \'unresponsive\' event has been emitted'); + } + dialog.showMessageBox(this.win, { + type: 'warning', + title: app.name, + message: localizeMessage('main.CriticalErrorHandler.unresponsive.dialog.message', 'The window is no longer responsive.\nDo you wait until the window becomes responsive again?'), + buttons: [ + localizeMessage('label.no', 'No'), + localizeMessage('label.yes', 'Yes'), + ], + defaultId: 0, + }).then(({response}) => { + if (response === 0) { + log.error('BrowserWindow \'unresponsive\' event has been emitted'); + app.relaunch(); } - } - }); - - // Only add shortcuts when window is in focus - mainWindow.on('focus', () => { - if (process.platform === 'linux') { - globalShortcut.registerAll(['Alt+F', 'Alt+E', 'Alt+V', 'Alt+H', 'Alt+W', 'Alt+P'], () => { - // do nothing because we want to supress the menu popping up - }); - } - }); - mainWindow.on('blur', () => { - globalShortcut.unregisterAll(); - }); - - const contextMenu = new ContextMenu({}, mainWindow); - contextMenu.reload(); - - return mainWindow; + }); + } } -export default createMainWindow; +const mainWindow = new MainWindow(); +export default mainWindow; diff --git a/src/main/windows/settingsWindow.ts b/src/main/windows/settingsWindow.ts index 68247987..ced050d4 100644 --- a/src/main/windows/settingsWindow.ts +++ b/src/main/windows/settingsWindow.ts @@ -1,40 +1,73 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {BrowserWindow} from 'electron'; +import {BrowserWindow, ipcMain} from 'electron'; import log from 'electron-log'; +import {SHOW_SETTINGS_WINDOW} from 'common/communication'; import Config from 'common/config'; import ContextMenu from '../contextMenu'; import {getLocalPreload, getLocalURLString} from '../utils'; -export function createSettingsWindow(mainWindow: BrowserWindow, withDevTools: boolean) { - const preload = getLocalPreload('desktopAPI.js'); - const spellcheck = (typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker); - const settingsWindow = new BrowserWindow({ - parent: mainWindow, - title: 'Desktop App Settings', - fullscreen: false, - webPreferences: { - preload, - spellcheck, - }}); +import MainWindow from './mainWindow'; - const contextMenu = new ContextMenu({}, settingsWindow); - contextMenu.reload(); +export class SettingsWindow { + private win?: BrowserWindow; - const localURL = getLocalURLString('settings.html'); - settingsWindow.setMenuBarVisibility(false); - settingsWindow.loadURL(localURL).catch( - (reason) => { - log.error(`Settings window failed to load: ${reason}`); - log.info(process.env); - }); - settingsWindow.show(); - - if (withDevTools) { - settingsWindow.webContents.openDevTools({mode: 'detach'}); + constructor() { + ipcMain.on(SHOW_SETTINGS_WINDOW, this.show); + } + + show = () => { + if (this.win) { + this.win.show(); + } else { + this.create(); + } + } + + get = () => { + return this.win; + } + + private create = () => { + const mainWindow = MainWindow.get(true); + if (!mainWindow) { + return; + } + + const preload = getLocalPreload('desktopAPI.js'); + const spellcheck = (typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker); + this.win = new BrowserWindow({ + parent: mainWindow, + title: 'Desktop App Settings', + fullscreen: false, + webPreferences: { + preload, + spellcheck, + }}); + + const contextMenu = new ContextMenu({}, this.win); + contextMenu.reload(); + + const localURL = getLocalURLString('settings.html'); + this.win.setMenuBarVisibility(false); + this.win.loadURL(localURL).catch( + (reason) => { + log.error('failed to load', reason); + }); + this.win.show(); + + if (Boolean(process.env.MM_DEBUG_SETTINGS) || false) { + this.win.webContents.openDevTools({mode: 'detach'}); + } + + this.win.on('closed', () => { + delete this.win; + }); } - return settingsWindow; } + +const settingsWindow = new SettingsWindow(); +export default settingsWindow; diff --git a/src/main/windows/windowManager.test.js b/src/main/windows/windowManager.test.js index e0b01ac6..b244cd6b 100644 --- a/src/main/windows/windowManager.test.js +++ b/src/main/windows/windowManager.test.js @@ -4,7 +4,7 @@ /* eslint-disable max-lines */ 'use strict'; -import {app, systemPreferences, desktopCapturer} from 'electron'; +import {systemPreferences, desktopCapturer} from 'electron'; import Config from 'common/config'; import {getTabViewName, TAB_MESSAGING} from 'common/tabs/TabView'; @@ -17,9 +17,8 @@ import { } from 'main/utils'; import {WindowManager} from './windowManager'; -import createMainWindow from './mainWindow'; -import {createSettingsWindow} from './settingsWindow'; - +import MainWindow from './mainWindow'; +import SettingsWindow from './settingsWindow'; import CallsWidgetWindow from './callsWidgetWindow'; jest.mock('path', () => ({ @@ -78,9 +77,13 @@ jest.mock('../views/teamDropdownView', () => jest.fn()); jest.mock('../views/downloadsDropdownView', () => jest.fn()); jest.mock('../views/downloadsDropdownMenuView', () => jest.fn()); jest.mock('./settingsWindow', () => ({ - createSettingsWindow: jest.fn(), + show: jest.fn(), + get: jest.fn(), +})); +jest.mock('./mainWindow', () => ({ + get: jest.fn(), + focus: jest.fn(), })); -jest.mock('./mainWindow', () => jest.fn()); jest.mock('../downloadsManager', () => ({ getDownloads: () => {}, })); @@ -104,40 +107,6 @@ describe('main/windows/windowManager', () => { }); }); - describe('showSettingsWindow', () => { - const windowManager = new WindowManager(); - windowManager.showMainWindow = jest.fn(); - - afterEach(() => { - jest.resetAllMocks(); - delete windowManager.settingsWindow; - delete windowManager.mainWindow; - }); - - it('should show settings window if it exists', () => { - const settingsWindow = {show: jest.fn()}; - windowManager.settingsWindow = settingsWindow; - windowManager.showSettingsWindow(); - expect(settingsWindow.show).toHaveBeenCalled(); - }); - - it('should create windows if they dont exist and delete the settings window when it is closed', () => { - let callback; - createSettingsWindow.mockReturnValue({on: (event, cb) => { - if (event === 'closed') { - callback = cb; - } - }}); - windowManager.showSettingsWindow(); - expect(windowManager.showMainWindow).toHaveBeenCalled(); - expect(createSettingsWindow).toHaveBeenCalled(); - expect(windowManager.settingsWindow).toBeDefined(); - - callback(); - expect(windowManager.settingsWindow).toBeUndefined(); - }); - }); - describe('showMainWindow', () => { const windowManager = new WindowManager(); windowManager.viewManager = { @@ -146,56 +115,38 @@ describe('main/windows/windowManager', () => { }; windowManager.initializeViewManager = jest.fn(); + const mainWindow = { + visible: false, + isVisible: () => mainWindow.visible, + show: jest.fn(), + focus: jest.fn(), + on: jest.fn(), + once: jest.fn(), + webContents: { + setWindowOpenHandler: jest.fn(), + }, + }; + + beforeEach(() => { + mainWindow.show.mockImplementation(() => { + mainWindow.visible = true; + }); + MainWindow.get.mockReturnValue(mainWindow); + }); + afterEach(() => { - delete windowManager.mainWindow; + jest.resetAllMocks(); }); it('should show main window if it exists and focus it if it is already visible', () => { - windowManager.mainWindow = { - visible: false, - isVisible: () => windowManager.mainWindow.visible, - show: jest.fn().mockImplementation(() => { - windowManager.mainWindow.visible = true; - }), - focus: jest.fn(), - }; + windowManager.showMainWindow(); + expect(mainWindow.show).toHaveBeenCalled(); windowManager.showMainWindow(); - expect(windowManager.mainWindow.show).toHaveBeenCalled(); - - windowManager.showMainWindow(); - expect(windowManager.mainWindow.focus).toHaveBeenCalled(); - }); - - it('should quit the app when the main window fails to create', () => { - windowManager.showMainWindow(); - expect(app.quit).toHaveBeenCalled(); - }); - - it('should create the main window and add listeners', () => { - const window = { - on: jest.fn(), - once: jest.fn(), - webContents: { - setWindowOpenHandler: jest.fn(), - }, - }; - createMainWindow.mockReturnValue(window); - windowManager.showMainWindow(); - expect(windowManager.mainWindow).toBe(window); - expect(window.on).toHaveBeenCalled(); - expect(window.webContents.setWindowOpenHandler).toHaveBeenCalled(); + expect(mainWindow.focus).toHaveBeenCalled(); }); it('should open deep link when provided', () => { - const window = { - on: jest.fn(), - once: jest.fn(), - webContents: { - setWindowOpenHandler: jest.fn(), - }, - }; - createMainWindow.mockReturnValue(window); windowManager.showMainWindow('mattermost://server-1.com/subpath'); expect(windowManager.viewManager.handleDeepLink).toHaveBeenCalledWith('mattermost://server-1.com/subpath'); }); @@ -218,7 +169,7 @@ describe('main/windows/windowManager', () => { getCurrentView: () => view, setLoadingScreenBounds: jest.fn(), }; - windowManager.mainWindow = { + const mainWindow = { getContentBounds: () => ({width: 800, height: 600}), getSize: () => [1000, 900], }; @@ -227,6 +178,7 @@ describe('main/windows/windowManager', () => { }; beforeEach(() => { + MainWindow.get.mockReturnValue(mainWindow); jest.useFakeTimers(); getAdjustedWindowBoundaries.mockImplementation((width, height) => ({width, height})); }); @@ -281,15 +233,16 @@ describe('main/windows/windowManager', () => { setLoadingScreenBounds: jest.fn(), loadingScreenState: 3, }; - windowManager.mainWindow = { - getContentBounds: () => ({width: 1000, height: 900}), - getSize: () => [1000, 900], - }; windowManager.teamDropdown = { updateWindowBounds: jest.fn(), }; + const mainWindow = { + getContentBounds: () => ({width: 1000, height: 900}), + getSize: () => [1000, 900], + }; beforeEach(() => { + MainWindow.get.mockReturnValue(mainWindow); getAdjustedWindowBoundaries.mockImplementation((width, height) => ({width, height})); }); @@ -333,12 +286,13 @@ describe('main/windows/windowManager', () => { }, }, }; - windowManager.mainWindow = { + const mainWindow = { getContentBounds: () => ({width: 800, height: 600}), getSize: () => [1000, 900], }; beforeEach(() => { + MainWindow.get.mockReturnValue(mainWindow); getAdjustedWindowBoundaries.mockImplementation((width, height) => ({width, height})); }); @@ -392,7 +346,7 @@ describe('main/windows/windowManager', () => { describe('restoreMain', () => { const windowManager = new WindowManager(); - windowManager.mainWindow = { + const mainWindow = { isVisible: jest.fn(), isMinimized: jest.fn(), restore: jest.fn(), @@ -400,134 +354,62 @@ describe('main/windows/windowManager', () => { focus: jest.fn(), }; + beforeEach(() => { + MainWindow.get.mockReturnValue(mainWindow); + }); + afterEach(() => { jest.resetAllMocks(); delete windowManager.settingsWindow; }); it('should restore main window if minimized', () => { - windowManager.mainWindow.isMinimized.mockReturnValue(true); + mainWindow.isMinimized.mockReturnValue(true); windowManager.restoreMain(); - expect(windowManager.mainWindow.restore).toHaveBeenCalled(); + expect(mainWindow.restore).toHaveBeenCalled(); }); it('should show main window if not visible or minimized', () => { - windowManager.mainWindow.isVisible.mockReturnValue(false); - windowManager.mainWindow.isMinimized.mockReturnValue(false); + mainWindow.isVisible.mockReturnValue(false); + mainWindow.isMinimized.mockReturnValue(false); windowManager.restoreMain(); - expect(windowManager.mainWindow.show).toHaveBeenCalled(); + expect(mainWindow.show).toHaveBeenCalled(); }); it('should focus main window if visible and not minimized', () => { - windowManager.mainWindow.isVisible.mockReturnValue(true); - windowManager.mainWindow.isMinimized.mockReturnValue(false); + mainWindow.isVisible.mockReturnValue(true); + mainWindow.isMinimized.mockReturnValue(false); windowManager.restoreMain(); - expect(windowManager.mainWindow.focus).toHaveBeenCalled(); + expect(mainWindow.focus).toHaveBeenCalled(); }); it('should focus settings window regardless of main window state if it exists', () => { - windowManager.settingsWindow = { - focus: jest.fn(), - }; + const settingsWindow = {focus: jest.fn()}; + SettingsWindow.get.mockReturnValue(settingsWindow); - windowManager.mainWindow.isVisible.mockReturnValue(false); - windowManager.mainWindow.isMinimized.mockReturnValue(false); + mainWindow.isVisible.mockReturnValue(false); + mainWindow.isMinimized.mockReturnValue(false); windowManager.restoreMain(); - expect(windowManager.settingsWindow.focus).toHaveBeenCalled(); - windowManager.settingsWindow.focus.mockClear(); + expect(settingsWindow.focus).toHaveBeenCalled(); + settingsWindow.focus.mockClear(); - windowManager.mainWindow.isVisible.mockReturnValue(true); - windowManager.mainWindow.isMinimized.mockReturnValue(false); + mainWindow.isVisible.mockReturnValue(true); + mainWindow.isMinimized.mockReturnValue(false); windowManager.restoreMain(); - expect(windowManager.settingsWindow.focus).toHaveBeenCalled(); - windowManager.settingsWindow.focus.mockClear(); + expect(settingsWindow.focus).toHaveBeenCalled(); + settingsWindow.focus.mockClear(); - windowManager.mainWindow.isVisible.mockReturnValue(false); - windowManager.mainWindow.isMinimized.mockReturnValue(true); + mainWindow.isVisible.mockReturnValue(false); + mainWindow.isMinimized.mockReturnValue(true); windowManager.restoreMain(); - expect(windowManager.settingsWindow.focus).toHaveBeenCalled(); - windowManager.settingsWindow.focus.mockClear(); + expect(settingsWindow.focus).toHaveBeenCalled(); + settingsWindow.focus.mockClear(); - windowManager.mainWindow.isVisible.mockReturnValue(true); - windowManager.mainWindow.isMinimized.mockReturnValue(true); + mainWindow.isVisible.mockReturnValue(true); + mainWindow.isMinimized.mockReturnValue(true); windowManager.restoreMain(); - expect(windowManager.settingsWindow.focus).toHaveBeenCalled(); - windowManager.settingsWindow.focus.mockClear(); - }); - }); - - describe('flashFrame', () => { - const windowManager = new WindowManager(); - windowManager.mainWindow = { - flashFrame: jest.fn(), - }; - windowManager.settingsWindow = { - flashFrame: jest.fn(), - }; - - beforeEach(() => { - Config.notifications = {}; - }); - - afterEach(() => { - jest.resetAllMocks(); - Config.notifications = {}; - }); - - it('linux/windows - should not flash frame when config item is not set', () => { - const originalPlatform = process.platform; - Object.defineProperty(process, 'platform', { - value: 'linux', - }); - windowManager.flashFrame(true); - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }); - expect(windowManager.mainWindow.flashFrame).not.toBeCalled(); - expect(windowManager.settingsWindow.flashFrame).not.toBeCalled(); - }); - - it('linux/windows - should flash frame when config item is set', () => { - Config.notifications = { - flashWindow: true, - }; - const originalPlatform = process.platform; - Object.defineProperty(process, 'platform', { - value: 'linux', - }); - windowManager.flashFrame(true); - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }); - expect(windowManager.mainWindow.flashFrame).toBeCalledWith(true); - }); - - it('mac - should not bounce icon when config item is not set', () => { - const originalPlatform = process.platform; - Object.defineProperty(process, 'platform', { - value: 'darwin', - }); - windowManager.flashFrame(true); - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }); - expect(app.dock.bounce).not.toBeCalled(); - }); - - it('mac - should bounce icon when config item is set', () => { - Config.notifications = { - bounceIcon: true, - bounceIconType: 'critical', - }; - const originalPlatform = process.platform; - Object.defineProperty(process, 'platform', { - value: 'darwin', - }); - windowManager.flashFrame(true); - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }); - expect(app.dock.bounce).toHaveBeenCalledWith('critical'); + expect(settingsWindow.focus).toHaveBeenCalled(); + settingsWindow.focus.mockClear(); }); }); @@ -569,13 +451,13 @@ describe('main/windows/windowManager', () => { }); it('should maximize when not maximized and vice versa', () => { - windowManager.mainWindow = mainWindow; + MainWindow.get.mockReturnValue(mainWindow); - windowManager.mainWindow.isMaximized.mockReturnValue(false); + mainWindow.isMaximized.mockReturnValue(false); windowManager.handleDoubleClick(); expect(mainWindow.maximize).toHaveBeenCalled(); - windowManager.mainWindow.isMaximized.mockReturnValue(true); + mainWindow.isMaximized.mockReturnValue(true); windowManager.handleDoubleClick(); expect(mainWindow.unmaximize).toHaveBeenCalled(); }); @@ -585,16 +467,15 @@ describe('main/windows/windowManager', () => { Object.defineProperty(process, 'platform', { value: 'darwin', }); - windowManager.flashFrame(true); systemPreferences.getUserDefault.mockReturnValue('Minimize'); - windowManager.settingsWindow = settingsWindow; + SettingsWindow.get.mockReturnValue(settingsWindow); - windowManager.settingsWindow.isMinimized.mockReturnValue(false); + settingsWindow.isMinimized.mockReturnValue(false); windowManager.handleDoubleClick(null, 'settings'); expect(settingsWindow.minimize).toHaveBeenCalled(); - windowManager.settingsWindow.isMinimized.mockReturnValue(true); + settingsWindow.isMinimized.mockReturnValue(true); windowManager.handleDoubleClick(null, 'settings'); expect(settingsWindow.restore).toHaveBeenCalled(); @@ -1418,10 +1299,10 @@ describe('main/windows/windowManager', () => { describe('handleCallsError', () => { const windowManager = new WindowManager(); - windowManager.switchServer = jest.fn(); - windowManager.mainWindow = { + const mainWindow = { focus: jest.fn(), }; + windowManager.switchServer = jest.fn(); beforeEach(() => { CallsWidgetWindow.mockImplementation(() => { @@ -1436,6 +1317,7 @@ describe('main/windows/windowManager', () => { }), }; }); + MainWindow.get.mockReturnValue(mainWindow); }); afterEach(() => { @@ -1447,7 +1329,7 @@ describe('main/windows/windowManager', () => { windowManager.callsWidgetWindow = new CallsWidgetWindow(); windowManager.handleCallsError('', {err: 'client-error'}); expect(windowManager.switchServer).toHaveBeenCalledWith('server-2'); - expect(windowManager.mainWindow.focus).toHaveBeenCalled(); + expect(mainWindow.focus).toHaveBeenCalled(); expect(windowManager.callsWidgetWindow.getMainView().view.webContents.send).toHaveBeenCalledWith('calls-error', {err: 'client-error'}); }); }); diff --git a/src/main/windows/windowManager.ts b/src/main/windows/windowManager.ts index 5195703f..515826dd 100644 --- a/src/main/windows/windowManager.ts +++ b/src/main/windows/windowManager.ts @@ -4,7 +4,7 @@ /* eslint-disable max-lines */ import path from 'path'; -import {app, BrowserWindow, nativeImage, systemPreferences, ipcMain, IpcMainEvent, IpcMainInvokeEvent, desktopCapturer} from 'electron'; +import {app, BrowserWindow, systemPreferences, ipcMain, IpcMainEvent, IpcMainInvokeEvent, desktopCapturer} from 'electron'; import log from 'electron-log'; import { @@ -55,7 +55,6 @@ import { } from '../utils'; import {ViewManager, LoadingScreenState} from '../views/viewManager'; -import CriticalErrorHandler from '../CriticalErrorHandler'; import TeamDropdownView from '../views/teamDropdownView'; import DownloadsDropdownView from '../views/downloadsDropdownView'; @@ -63,19 +62,16 @@ import DownloadsDropdownMenuView from '../views/downloadsDropdownMenuView'; import downloadsManager from 'main/downloadsManager'; -import {createSettingsWindow} from './settingsWindow'; -import createMainWindow from './mainWindow'; +import MainWindow from './mainWindow'; import CallsWidgetWindow from './callsWidgetWindow'; +import SettingsWindow from './settingsWindow'; // singleton module to manage application's windows export class WindowManager { assetsDir: string; - mainWindow?: BrowserWindow; - mainWindowReady: boolean; - settingsWindow?: BrowserWindow; callsWidgetWindow?: CallsWidgetWindow; viewManager?: ViewManager; teamDropdown?: TeamDropdownView; @@ -85,7 +81,6 @@ export class WindowManager { missingScreensharePermissions?: boolean; constructor() { - this.mainWindowReady = false; this.assetsDir = path.resolve(app.getAppPath(), 'assets'); ipcMain.on(HISTORY, this.handleHistory); @@ -145,7 +140,7 @@ export class WindowManager { return; } - this.callsWidgetWindow = new CallsWidgetWindow(this.mainWindow!, currentView, { + this.callsWidgetWindow = new CallsWidgetWindow(MainWindow.get()!, currentView, { callID: msg.callID, title: msg.title, rootID: msg.rootID, @@ -160,7 +155,7 @@ export class WindowManager { if (this.callsWidgetWindow) { this.switchServer(this.callsWidgetWindow.getServerName()); - this.mainWindow?.focus(); + MainWindow.get()?.focus(); this.callsWidgetWindow.getMainView().view.webContents.send(DESKTOP_SOURCES_MODAL_REQUEST); } } @@ -170,7 +165,7 @@ export class WindowManager { if (this.callsWidgetWindow) { this.switchServer(this.callsWidgetWindow.getServerName()); - this.mainWindow?.focus(); + MainWindow.get()?.focus(); this.callsWidgetWindow.getMainView().view.webContents.send(BROWSER_HISTORY_PUSH, this.callsWidgetWindow.getChannelURL()); } } @@ -180,7 +175,7 @@ export class WindowManager { if (this.callsWidgetWindow) { this.switchServer(this.callsWidgetWindow.getServerName()); - this.mainWindow?.focus(); + MainWindow.get()?.focus(); this.callsWidgetWindow.getMainView().view.webContents.send(CALLS_ERROR, msg); } } @@ -190,7 +185,7 @@ export class WindowManager { if (this.callsWidgetWindow) { this.switchServer(this.callsWidgetWindow.getServerName()); - this.mainWindow?.focus(); + MainWindow.get()?.focus(); this.callsWidgetWindow.getMainView().view.webContents.send(BROWSER_HISTORY_PUSH, msg.link); } } @@ -201,100 +196,49 @@ export class WindowManager { this.callsWidgetWindow?.close(); } - showSettingsWindow = () => { - log.debug('WindowManager.showSettingsWindow'); - - if (this.settingsWindow) { - this.settingsWindow.show(); - } else { - if (!this.mainWindow) { - this.showMainWindow(); - } - const withDevTools = Boolean(process.env.MM_DEBUG_SETTINGS) || false; - - this.settingsWindow = createSettingsWindow(this.mainWindow!, withDevTools); - this.settingsWindow.on('closed', () => { - delete this.settingsWindow; - }); - } - } - showMainWindow = (deeplinkingURL?: string | URL) => { log.debug('WindowManager.showMainWindow', deeplinkingURL); - if (this.mainWindow) { - if (this.mainWindow.isVisible()) { - this.mainWindow.focus(); + const mainWindow = MainWindow.get(); + if (mainWindow) { + if (mainWindow.isVisible()) { + mainWindow.focus(); } else { - this.mainWindow.show(); + mainWindow.show(); } } else { - this.mainWindowReady = false; - this.mainWindow = createMainWindow({ - linuxAppIcon: path.join(this.assetsDir, 'linux', 'app_icon.png'), - }); - - if (!this.mainWindow) { - log.error('unable to create main window'); - app.quit(); - return; - } - - this.mainWindow.once('ready-to-show', () => { - this.mainWindowReady = true; - }); - - // window handlers - this.mainWindow.on('closed', () => { - log.warn('main window closed'); - delete this.mainWindow; - this.mainWindowReady = false; - }); - this.mainWindow.on('unresponsive', () => { - CriticalErrorHandler.setMainWindow(this.mainWindow!); - CriticalErrorHandler.windowUnresponsiveHandler(); - }); - this.mainWindow.on('maximize', this.handleMaximizeMainWindow); - this.mainWindow.on('unmaximize', this.handleUnmaximizeMainWindow); - if (process.platform !== 'darwin') { - this.mainWindow.on('resize', this.handleResizeMainWindow); - } - this.mainWindow.on('will-resize', this.handleWillResizeMainWindow); - this.mainWindow.on('resized', this.handleResizedMainWindow); - this.mainWindow.on('focus', this.focusBrowserView); - this.mainWindow.on('enter-full-screen', () => this.sendToRenderer('enter-full-screen')); - this.mainWindow.on('leave-full-screen', () => this.sendToRenderer('leave-full-screen')); - - // Should not allow the main window to generate a window of its own - this.mainWindow.webContents.setWindowOpenHandler(() => ({action: 'deny'})); - - if (process.env.MM_DEBUG_SETTINGS) { - this.mainWindow.webContents.openDevTools({mode: 'detach'}); - } - - if (this.viewManager) { - this.viewManager.updateMainWindow(this.mainWindow); - } - - this.teamDropdown = new TeamDropdownView(this.mainWindow, Config.teams, Config.darkMode, Config.enableServerManagement); - this.downloadsDropdown = new DownloadsDropdownView(this.mainWindow, downloadsManager.getDownloads(), Config.darkMode); - this.downloadsDropdownMenu = new DownloadsDropdownMenuView(this.mainWindow, Config.darkMode); + this.createMainWindow(); } - this.initializeViewManager(); if (deeplinkingURL) { - this.viewManager!.handleDeepLink(deeplinkingURL); + this.viewManager?.handleDeepLink(deeplinkingURL); } } - getMainWindow = (ensureCreated?: boolean) => { - if (ensureCreated && !this.mainWindow) { - this.showMainWindow(); + private createMainWindow = () => { + const mainWindow = MainWindow.get(true); + if (!mainWindow) { + return; } - return this.mainWindow; - } - on = this.mainWindow?.on; + // window handlers + mainWindow.on('maximize', this.handleMaximizeMainWindow); + mainWindow.on('unmaximize', this.handleUnmaximizeMainWindow); + if (process.platform !== 'darwin') { + mainWindow.on('resize', this.handleResizeMainWindow); + } + mainWindow.on('will-resize', this.handleWillResizeMainWindow); + mainWindow.on('resized', this.handleResizedMainWindow); + mainWindow.on('focus', this.focusBrowserView); + mainWindow.on('enter-full-screen', () => this.sendToRenderer('enter-full-screen')); + mainWindow.on('leave-full-screen', () => this.sendToRenderer('leave-full-screen')); + + this.teamDropdown = new TeamDropdownView(Config.teams, Config.darkMode, Config.enableServerManagement); + this.downloadsDropdown = new DownloadsDropdownView(downloadsManager.getDownloads(), Config.darkMode); + this.downloadsDropdownMenu = new DownloadsDropdownMenuView(Config.darkMode); + + this.initializeViewManager(); + } handleMaximizeMainWindow = () => { this.downloadsDropdown?.updateWindowBounds(); @@ -313,7 +257,7 @@ export class WindowManager { handleWillResizeMainWindow = (event: Event, newBounds: Electron.Rectangle) => { log.silly('WindowManager.handleWillResizeMainWindow'); - if (!(this.viewManager && this.mainWindow)) { + if (!(this.viewManager && MainWindow.get())) { return; } @@ -343,7 +287,7 @@ export class WindowManager { handleResizedMainWindow = () => { log.silly('WindowManager.handleResizedMainWindow'); - if (this.mainWindow) { + if (MainWindow.get()) { const bounds = this.getBounds(); this.throttledWillResize(bounds); ipcMain.emit(RESIZE_MODAL, null, bounds); @@ -368,7 +312,7 @@ export class WindowManager { handleResizeMainWindow = () => { log.silly('WindowManager.handleResizeMainWindow'); - if (!(this.viewManager && this.mainWindow)) { + if (!(this.viewManager && MainWindow.get())) { return; } if (this.isResizing) { @@ -405,15 +349,16 @@ export class WindowManager { private getBounds = () => { let bounds; - if (this.mainWindow) { + const mainWindow = MainWindow.get(); + if (mainWindow) { // Workaround for linux maximizing/minimizing, which doesn't work properly because of these bugs: // https://github.com/electron/electron/issues/28699 // https://github.com/electron/electron/issues/28106 if (process.platform === 'linux') { - const size = this.mainWindow.getSize(); + const size = mainWindow.getSize(); bounds = {width: size[0], height: size[1]}; } else { - bounds = this.mainWindow.getContentBounds(); + bounds = mainWindow.getContentBounds(); } } @@ -422,7 +367,8 @@ export class WindowManager { // max retries allows the message to get to the renderer even if it is sent while the app is starting up. sendToRendererWithRetry = (maxRetries: number, channel: string, ...args: unknown[]) => { - if (!this.mainWindow || !this.mainWindowReady) { + const mainWindow = MainWindow.get(); + if (!mainWindow || !MainWindow.isReady) { if (maxRetries > 0) { log.info(`Can't send ${channel}, will retry`); setTimeout(() => { @@ -433,14 +379,8 @@ export class WindowManager { } return; } - this.mainWindow!.webContents.send(channel, ...args); - if (this.settingsWindow && this.settingsWindow.isVisible()) { - try { - this.settingsWindow.webContents.send(channel, ...args); - } catch (e) { - log.error(`There was an error while trying to communicate with the renderer: ${e}`); - } - } + mainWindow.webContents.send(channel, ...args); + SettingsWindow.get()?.webContents.send(channel, ...args); } sendToRenderer = (channel: string, ...args: unknown[]) => { @@ -449,9 +389,7 @@ export class WindowManager { sendToAll = (channel: string, ...args: unknown[]) => { this.sendToRenderer(channel, ...args); - if (this.settingsWindow) { - this.settingsWindow.webContents.send(channel, ...args); - } + SettingsWindow.get()?.webContents.send(channel, ...args); // TODO: should we include popups? } @@ -464,103 +402,31 @@ export class WindowManager { restoreMain = () => { log.info('restoreMain'); - if (!this.mainWindow) { - this.showMainWindow(); + + const mainWindow = MainWindow.get(true); + if (!mainWindow) { + throw new Error('Main window does not exist'); } - if (!this.mainWindow!.isVisible() || this.mainWindow!.isMinimized()) { - if (this.mainWindow!.isMinimized()) { - this.mainWindow!.restore(); + + if (!mainWindow.isVisible() || mainWindow.isMinimized()) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); } else { - this.mainWindow!.show(); + mainWindow.show(); } - if (this.settingsWindow) { - this.settingsWindow.focus(); + const settingsWindow = SettingsWindow.get(); + if (settingsWindow) { + settingsWindow.focus(); } else { - this.mainWindow!.focus(); + mainWindow.focus(); } - } else if (this.settingsWindow) { - this.settingsWindow.focus(); + } else if (SettingsWindow.get()) { + SettingsWindow.get()?.focus(); } else { - this.mainWindow!.focus(); + mainWindow.focus(); } } - flashFrame = (flash: boolean) => { - if (process.platform === 'linux' || process.platform === 'win32') { - if (Config.notifications.flashWindow) { - this.mainWindow?.flashFrame(flash); - } - } - if (process.platform === 'darwin' && Config.notifications.bounceIcon) { - app.dock.bounce(Config.notifications.bounceIconType); - } - } - - drawBadge = (text: string, small: boolean) => { - const scale = 2; // should rely display dpi - const size = (small ? 20 : 16) * scale; - const canvas = document.createElement('canvas'); - canvas.setAttribute('width', `${size}`); - canvas.setAttribute('height', `${size}`); - const ctx = canvas.getContext('2d'); - - if (!ctx) { - log.error('Could not create canvas context'); - return null; - } - - // circle - ctx.fillStyle = '#FF1744'; // Material Red A400 - ctx.beginPath(); - ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); - ctx.fill(); - - // text - ctx.fillStyle = '#ffffff'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.font = (11 * scale) + 'px sans-serif'; - ctx.fillText(text, size / 2, size / 2, size); - - return canvas.toDataURL(); - } - - createDataURL = (text: string, small: boolean) => { - const win = this.mainWindow; - if (!win) { - return null; - } - - // since we don't have a document/canvas object in the main process, we use the webcontents from the window to draw. - const safeSmall = Boolean(small); - const code = ` - window.drawBadge = ${this.drawBadge}; - window.drawBadge('${text || ''}', ${safeSmall}); - `; - return win.webContents.executeJavaScript(code); - } - - setOverlayIcon = async (badgeText: string | undefined, description: string, small: boolean) => { - if (process.platform === 'win32') { - let overlay = null; - if (this.mainWindow) { - if (badgeText) { - try { - const dataUrl = await this.createDataURL(badgeText, small); - overlay = nativeImage.createFromDataURL(dataUrl); - } catch (err) { - log.error(`Couldn't generate a badge: ${err}`); - } - } - this.mainWindow.setOverlayIcon(overlay, description); - } - } - } - - isMainWindow = (window: BrowserWindow) => { - return this.mainWindow && this.mainWindow === window; - } - handleDoubleClick = (e: IpcMainEvent, windowType?: string) => { log.debug('WindowManager.handleDoubleClick', windowType); @@ -568,7 +434,7 @@ export class WindowManager { if (process.platform === 'darwin') { action = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string'); } - const win = (windowType === 'settings') ? this.settingsWindow : this.mainWindow; + const win = (windowType === 'settings') ? SettingsWindow.get() : MainWindow.get(); if (!win) { return; } @@ -592,8 +458,8 @@ export class WindowManager { } initializeViewManager = () => { - if (!this.viewManager && Config && this.mainWindow) { - this.viewManager = new ViewManager(this.mainWindow); + if (!this.viewManager && Config) { + this.viewManager = new ViewManager(); this.viewManager.load(); this.viewManager.showInitial(); this.initializeCurrentServerName(); @@ -658,10 +524,8 @@ export class WindowManager { } focusThreeDotMenu = () => { - if (this.mainWindow) { - this.mainWindow.webContents.focus(); - this.mainWindow.webContents.send(FOCUS_THREE_DOT_MENU); - } + MainWindow.get()?.webContents.focus(); + MainWindow.get()?.webContents.send(FOCUS_THREE_DOT_MENU); } handleLoadingScreenDataRequest = () => {