From 39fbdf45c5ca12c61f6d07b437d85746acfdea7d Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Thu, 9 Dec 2021 15:11:55 -0500 Subject: [PATCH] [MM-40406] Add more singletons, refactor main.ts into pieces, add tests and some cleanup + tests for additional coverage (#1890) * Refactor main.ts dependencies into singleton pattern * Split main.ts into testable pieces, some other refactoring for singleton pattern * Unit tests for main/app/app * Unit tests for main/app/config * Unit tests for main/app/initialize * Unit tests for main/app/intercom * Unit tests for main/app/utils * Add some more tests to get to 70% coverage * Fix for linux * Fix for alternate data dir paths * Fix E2E test --- .eslintrc.json | 1 - e2e/specs/startup/config.test.js | 2 +- package.json | 7 +- src/common/communication.ts | 2 + src/common/config/RegistryConfig.test.js | 50 +- src/common/config/index.test.js | 12 +- src/common/config/index.ts | 23 +- src/common/utils/util.ts | 4 +- src/jestSetup.js | 13 + src/main/AppVersionManager.test.js | 35 + src/main/AppVersionManager.ts | 20 +- src/main/AutoLauncher.test.js | 51 ++ src/main/AutoLauncher.ts | 5 +- src/main/CriticalErrorHandler.test.js | 2 +- src/main/CriticalErrorHandler.ts | 5 +- src/main/ParseArgs.test.js | 12 + src/main/UserActivityMonitor.test.js | 2 +- src/main/UserActivityMonitor.ts | 11 +- src/main/allowProtocolDialog.ts | 7 +- src/main/app/app.test.js | 156 ++++ src/main/app/app.ts | 145 ++++ src/main/app/config.test.js | 108 +++ src/main/app/config.ts | 75 ++ src/main/app/index.ts | 17 + src/main/app/initialize.test.js | 287 +++++++ src/main/app/initialize.ts | 381 +++++++++ src/main/app/intercom.test.js | 238 ++++++ src/main/app/intercom.ts | 215 +++++ src/main/app/utils.test.js | 192 +++++ src/main/app/utils.ts | 178 ++++ src/main/authManager.test.js | 33 +- src/main/authManager.ts | 23 +- src/main/certificateManager.ts | 3 + src/main/certificateStore.test.js | 16 +- src/main/certificateStore.ts | 13 +- src/main/constants.ts | 36 + src/main/cookieManager.ts | 24 - src/main/main.ts | 983 ----------------------- src/main/menus/app.ts | 2 +- src/main/menus/tray.ts | 2 +- src/main/tray/tray.ts | 1 + src/main/trustedOrigins.test.js | 15 +- src/main/trustedOrigins.ts | 16 +- src/main/views/viewManager.test.js | 16 +- src/main/views/viewManager.ts | 11 +- src/main/windows/mainWindow.test.js | 32 +- src/main/windows/mainWindow.ts | 12 +- src/main/windows/settingsWindow.ts | 7 +- src/main/windows/windowManager.test.js | 139 ++-- src/main/windows/windowManager.ts | 55 +- webpack.config.main.js | 2 +- 51 files changed, 2486 insertions(+), 1211 deletions(-) create mode 100644 src/jestSetup.js create mode 100644 src/main/AppVersionManager.test.js create mode 100644 src/main/AutoLauncher.test.js create mode 100644 src/main/ParseArgs.test.js create mode 100644 src/main/app/app.test.js create mode 100644 src/main/app/app.ts create mode 100644 src/main/app/config.test.js create mode 100644 src/main/app/config.ts create mode 100644 src/main/app/index.ts create mode 100644 src/main/app/initialize.test.js create mode 100644 src/main/app/initialize.ts create mode 100644 src/main/app/intercom.test.js create mode 100644 src/main/app/intercom.ts create mode 100644 src/main/app/utils.test.js create mode 100644 src/main/app/utils.ts create mode 100644 src/main/constants.ts delete mode 100644 src/main/cookieManager.ts delete mode 100644 src/main/main.ts diff --git a/.eslintrc.json b/.eslintrc.json index d6d083fb..3062f87e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -72,7 +72,6 @@ "scripts/check_build_config.js", "LICENSE.txt", "src/utils/util.ts", - "src/main/main.ts", "src/main/contextMenu.ts", "src/renderer/updater.tsx", "src/main/badge.ts", diff --git a/e2e/specs/startup/config.test.js b/e2e/specs/startup/config.test.js index 33a59f85..69ae045e 100644 --- a/e2e/specs/startup/config.test.js +++ b/e2e/specs/startup/config.test.js @@ -57,7 +57,7 @@ describe('config', function desc() { }); it('MM-T4402 should upgrade v0 config file', async () => { - const Config = require('../../../src/common/config').default; + const Config = require('../../../src/common/config').Config; const newConfig = new Config(env.configFilePath); const oldConfig = { url: env.mattermostURL, diff --git a/package.json b/package.json index 3278264f..5cf9c86f 100644 --- a/package.json +++ b/package.json @@ -72,10 +72,15 @@ "jsx", "json" ], + "collectCoverageFrom": [ + "src/common/**/*.ts", + "src/main/**/*.ts" + ], "testMatch": ["**/src/**/*.test.js"], "globals": { "__HASH_VERSION__": "5.0.0" - } + }, + "setupFiles": ["./src/jestSetup.js"] }, "devDependencies": { "@babel/cli": "^7.14.5", diff --git a/src/common/communication.ts b/src/common/communication.ts index 45c25e9e..e0dc0fb6 100644 --- a/src/common/communication.ts +++ b/src/common/communication.ts @@ -100,3 +100,5 @@ export const GET_VIEW_WEBCONTENTS_ID = 'get-view-webcontents-id'; export const GET_MODAL_UNCLOSEABLE = 'get-modal-uncloseable'; export const MODAL_UNCLOSEABLE = 'modal-uncloseable'; + +export const UPDATE_PATHS = 'update-paths'; diff --git a/src/common/config/RegistryConfig.test.js b/src/common/config/RegistryConfig.test.js index 61e74698..6f3d082b 100644 --- a/src/common/config/RegistryConfig.test.js +++ b/src/common/config/RegistryConfig.test.js @@ -19,6 +19,26 @@ jest.mock('winreg-utf8', () => { value: `${key}-value-2`, }, ]); + } else if (hive === 'mattermost-hive') { + if (key.endsWith('DefaultServerList')) { + fn(null, [ + { + name: 'server-1', + value: 'http://server-1.com', + }, + ]); + } else { + fn(null, [ + { + name: 'EnableServerManagement', + value: '0x1', + }, + { + name: 'EnableAutoUpdater', + value: '0x1', + }, + ]); + } } else if (hive === 'really-bad-hive') { throw new Error('This is an error'); } else { @@ -34,9 +54,33 @@ jest.mock('electron-log', () => ({ })); describe('common/config/RegistryConfig', () => { + it('should initialize correctly', async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'win32', + }); + + const registryConfig = new RegistryConfig(); + const originalFn = registryConfig.getRegistryEntryValues; + registryConfig.getRegistryEntryValues = (hive, key, name) => originalFn('mattermost-hive', key, name); + await registryConfig.init(); + + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(registryConfig.data.teams).toContainEqual({ + name: 'server-1', + url: 'http://server-1.com', + order: 0, + }); + expect(registryConfig.data.enableAutoUpdater).toBe(true); + expect(registryConfig.data.enableServerManagement).toBe(true); + }); + describe('getRegistryEntryValues', () => { + const registryConfig = new RegistryConfig(); + it('should return correct values', () => { - const registryConfig = new RegistryConfig(); expect(registryConfig.getRegistryEntryValues('correct-hive', 'correct-key')).resolves.toStrictEqual([ { name: 'correct-key-name-1', @@ -50,22 +94,18 @@ describe('common/config/RegistryConfig', () => { }); it('should return correct value by name', () => { - const registryConfig = new RegistryConfig(); expect(registryConfig.getRegistryEntryValues('correct-hive', 'correct-key', 'correct-key-name-1')).resolves.toBe('correct-key-value-1'); }); it('should return undefined with wrong name', () => { - const registryConfig = new RegistryConfig(); expect(registryConfig.getRegistryEntryValues('correct-hive', 'correct-key', 'wrong-key-name-1')).resolves.toBe(undefined); }); it('should return undefined with bad hive', () => { - const registryConfig = new RegistryConfig(); expect(registryConfig.getRegistryEntryValues('bad-hive', 'correct-key')).resolves.toBe(undefined); }); it('should call reject when an error occurs', () => { - const registryConfig = new RegistryConfig(); expect(registryConfig.getRegistryEntryValues('really-bad-hive', 'correct-key')).rejects.toThrow(new Error('This is an error')); }); }); diff --git a/src/common/config/index.test.js b/src/common/config/index.test.js index 043f28f7..067501bb 100644 --- a/src/common/config/index.test.js +++ b/src/common/config/index.test.js @@ -1,13 +1,17 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import Config from 'common/config'; +import {Config} from 'common/config'; const configPath = '/fake/config/path'; jest.mock('electron', () => ({ app: { name: 'Mattermost', + getPath: jest.fn(), + }, + ipcMain: { + on: jest.fn(), }, nativeTheme: { shouldUseDarkColors: false, @@ -122,7 +126,7 @@ describe('common/config', () => { }); describe('reload', () => { - it('should emit update and synchronize events', () => { + it('should emit update event', () => { const config = new Config(configPath); config.loadDefaultConfigData = jest.fn(); config.loadBuildConfigData = jest.fn(); @@ -135,7 +139,6 @@ describe('common/config', () => { config.reload(); expect(config.emit).toHaveBeenNthCalledWith(1, 'update', {test: 'test'}); - expect(config.emit).toHaveBeenNthCalledWith(2, 'synchronize'); }); }); @@ -170,7 +173,7 @@ describe('common/config', () => { }); describe('saveLocalConfigData', () => { - it('should emit update and synchronize events on save', () => { + it('should emit update event on save', () => { const config = new Config(configPath); config.localConfigData = {test: 'test'}; config.combinedData = {...config.localConfigData}; @@ -181,7 +184,6 @@ describe('common/config', () => { config.saveLocalConfigData(); expect(config.emit).toHaveBeenNthCalledWith(1, 'update', {test: 'test'}); - expect(config.emit).toHaveBeenNthCalledWith(2, 'synchronize'); }); it('should emit error when fs.writeSync throws an error', () => { diff --git a/src/common/config/index.ts b/src/common/config/index.ts index 2176bdd3..55af906e 100644 --- a/src/common/config/index.ts +++ b/src/common/config/index.ts @@ -20,8 +20,9 @@ import { TeamWithTabs, } from 'types/config'; -import {UPDATE_TEAMS, GET_CONFIGURATION, UPDATE_CONFIGURATION, GET_LOCAL_CONFIGURATION} from 'common/communication'; +import {UPDATE_TEAMS, GET_CONFIGURATION, UPDATE_CONFIGURATION, GET_LOCAL_CONFIGURATION, UPDATE_PATHS} from 'common/communication'; +import {configPath} from 'main/constants'; import * as Validator from 'main/Validator'; import {getDefaultTeamWithTabsFromTeam} from 'common/tabs/TabView'; import Utils from 'common/utils/util'; @@ -35,7 +36,7 @@ import RegistryConfig, {REGISTRY_READ_EVENT} from './RegistryConfig'; * Handles loading and merging all sources of configuration as well as saving user provided config */ -export default class Config extends EventEmitter { +export class Config extends EventEmitter { configFilePath: string; registryConfig: RegistryConfig; @@ -108,7 +109,6 @@ export default class Config extends EventEmitter { this.regenerateCombinedConfigData(); this.emit('update', this.combinedData); - this.emit('synchronize'); } /** @@ -186,7 +186,6 @@ export default class Config extends EventEmitter { } } this.emit('update', this.combinedData); - this.emit('synchronize'); }); } catch (error) { this.emit('error', error); @@ -266,6 +265,12 @@ export default class Config extends EventEmitter { get helpLink() { return this.combinedData?.helpLink; } + get minimizeToTray() { + return this.combinedData?.minimizeToTray; + } + get lastActiveTeam() { + return this.combinedData?.lastActiveTeam; + } // initialization/processing methods @@ -540,3 +545,13 @@ export default class Config extends EventEmitter { this.emit('darkModeChange', this.combinedData.darkMode); } } + +const config = new Config(configPath); +export default config; + +ipcMain.on(UPDATE_PATHS, () => { + config.configFilePath = configPath; + if (config.combinedData) { + config.reload(); + } +}); diff --git a/src/common/utils/util.ts b/src/common/utils/util.ts index ab9fc687..bfa01608 100644 --- a/src/common/utils/util.ts +++ b/src/common/utils/util.ts @@ -2,13 +2,11 @@ // See LICENSE.txt for license information. // Copyright (c) 2015-2016 Yuya Ochiai -import electron from 'electron'; +import {screen} from 'electron'; import {DEVELOPMENT, PRODUCTION} from './constants'; function getDisplayBoundaries() { - const {screen} = electron; - const displays = screen.getAllDisplays(); return displays.map((display) => { diff --git a/src/jestSetup.js b/src/jestSetup.js new file mode 100644 index 00000000..babf7347 --- /dev/null +++ b/src/jestSetup.js @@ -0,0 +1,13 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +jest.mock('main/constants', () => ({ + configPath: 'configPath', + allowedProtocolFile: 'allowedProtocolFile', + appVersionJson: 'appVersionJson', + certificateStorePath: 'certificateStorePath', + trustedOriginsStoreFile: 'trustedOriginsStoreFile', + boundsInfoPath: 'boundsInfoPath', + + updatePaths: jest.fn(), +})); diff --git a/src/main/AppVersionManager.test.js b/src/main/AppVersionManager.test.js new file mode 100644 index 00000000..7c40c834 --- /dev/null +++ b/src/main/AppVersionManager.test.js @@ -0,0 +1,35 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import fs from 'fs'; + +import * as Validator from 'main/Validator'; + +import {AppVersionManager} from './AppVersionManager'; + +jest.mock('electron', () => ({ + ipcMain: { + on: jest.fn(), + }, +})); + +jest.mock('fs', () => ({ + readFileSync: jest.fn(), + writeFile: jest.fn(), +})); + +jest.mock('main/Validator', () => ({ + validateAppState: jest.fn(), +})); + +describe('main/AppVersionManager', () => { + it('should wipe out JSON file when validation fails', () => { + fs.readFileSync.mockReturnValue('some bad JSON'); + Validator.validateAppState.mockReturnValue(false); + + // eslint-disable-next-line no-unused-vars + const appVersionManager = new AppVersionManager('somefilename.txt'); + + expect(fs.writeFile).toBeCalledWith('somefilename.txt', '{}', expect.any(Function)); + }); +}); diff --git a/src/main/AppVersionManager.ts b/src/main/AppVersionManager.ts index 9ede800a..47026d8a 100644 --- a/src/main/AppVersionManager.ts +++ b/src/main/AppVersionManager.ts @@ -2,22 +2,31 @@ // See LICENSE.txt for license information. // Copyright (c) 2015-2016 Yuya Ochiai +import {ipcMain} from 'electron'; + import {AppState} from 'types/appState'; -import JsonFileManager from '../common/JsonFileManager'; +import {UPDATE_PATHS} from 'common/communication'; +import JsonFileManager from 'common/JsonFileManager'; + +import {appVersionJson} from 'main/constants'; import * as Validator from './Validator'; -export default class AppVersionManager extends JsonFileManager { +export class AppVersionManager extends JsonFileManager { constructor(file: string) { super(file); + this.init(); + } + init = () => { // ensure data loaded from file is valid const validatedJSON = Validator.validateAppState(this.json); if (!validatedJSON) { this.setJson({}); } } + set lastAppVersion(version) { this.setValue('lastAppVersion', version); } @@ -46,3 +55,10 @@ export default class AppVersionManager extends JsonFileManager { return null; } } + +let appVersionManager = new AppVersionManager(appVersionJson); +export default appVersionManager; + +ipcMain.on(UPDATE_PATHS, () => { + appVersionManager = new AppVersionManager(appVersionJson); +}); diff --git a/src/main/AutoLauncher.test.js b/src/main/AutoLauncher.test.js new file mode 100644 index 00000000..ee49878d --- /dev/null +++ b/src/main/AutoLauncher.test.js @@ -0,0 +1,51 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import AutoLaunch from 'auto-launch'; + +import {AutoLauncher} from './AutoLauncher'; + +jest.mock('auto-launch', () => jest.fn()); +jest.mock('electron', () => ({ + app: { + name: 'Mattermost', + }, +})); + +jest.mock('electron-is-dev', () => false); +jest.mock('electron-log', () => ({})); + +describe('main/AutoLauncher', () => { + let autoLauncher; + const isEnabled = jest.fn(); + const enable = jest.fn(); + const disable = jest.fn(); + + beforeEach(() => { + AutoLaunch.mockImplementation(() => ({ + isEnabled, + enable, + disable, + })); + autoLauncher = new AutoLauncher(); + }); + + it('should toggle enabled/disabled', async () => { + isEnabled.mockReturnValue(true); + await autoLauncher.enable(); + expect(enable).not.toBeCalled(); + + isEnabled.mockReturnValue(false); + await autoLauncher.enable(); + expect(enable).toBeCalled(); + + isEnabled.mockReturnValue(false); + await autoLauncher.disable(); + expect(disable).not.toBeCalled(); + + isEnabled.mockReturnValue(true); + await autoLauncher.disable(); + expect(disable).toBeCalled(); + }); +}); + diff --git a/src/main/AutoLauncher.ts b/src/main/AutoLauncher.ts index b6651837..65931d0d 100644 --- a/src/main/AutoLauncher.ts +++ b/src/main/AutoLauncher.ts @@ -7,7 +7,7 @@ import {app} from 'electron'; import isDev from 'electron-is-dev'; import log from 'electron-log'; -export default class AutoLauncher { +export class AutoLauncher { appLauncher: AutoLaunch; constructor() { @@ -58,3 +58,6 @@ export default class AutoLauncher { return Promise.resolve(null); } } + +const autoLauncher = new AutoLauncher(); +export default autoLauncher; diff --git a/src/main/CriticalErrorHandler.test.js b/src/main/CriticalErrorHandler.test.js index ac153df5..5ba975ca 100644 --- a/src/main/CriticalErrorHandler.test.js +++ b/src/main/CriticalErrorHandler.test.js @@ -8,7 +8,7 @@ import path from 'path'; import {app, dialog} from 'electron'; -import CriticalErrorHandler from './CriticalErrorHandler'; +import {CriticalErrorHandler} from './CriticalErrorHandler'; jest.mock('path', () => ({ join: jest.fn().mockImplementation((...args) => args.join('/')), diff --git a/src/main/CriticalErrorHandler.ts b/src/main/CriticalErrorHandler.ts index 1a372038..a88c798d 100644 --- a/src/main/CriticalErrorHandler.ts +++ b/src/main/CriticalErrorHandler.ts @@ -38,7 +38,7 @@ function openDetachedExternal(url: string) { } } -export default class CriticalErrorHandler { +export class CriticalErrorHandler { mainWindow?: BrowserWindow; setMainWindow(mainWindow: BrowserWindow) { @@ -114,3 +114,6 @@ export default class CriticalErrorHandler { } } +const criticalErrorHandler = new CriticalErrorHandler(); +export default criticalErrorHandler; + diff --git a/src/main/ParseArgs.test.js b/src/main/ParseArgs.test.js new file mode 100644 index 00000000..9a5e763b --- /dev/null +++ b/src/main/ParseArgs.test.js @@ -0,0 +1,12 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import parse from 'main/ParseArgs'; + +describe('main/ParseArgs', () => { + it('should remove arguments following a deeplink', () => { + const args = parse(['mattermost', '--disableDevMode', 'mattermost://server-1.com', '--version']); + expect(args.disableDevMode).toBe(true); + expect(args.version).toBeUndefined(); + }); +}); diff --git a/src/main/UserActivityMonitor.test.js b/src/main/UserActivityMonitor.test.js index b0d677a9..837f8110 100644 --- a/src/main/UserActivityMonitor.test.js +++ b/src/main/UserActivityMonitor.test.js @@ -1,7 +1,7 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import UserActivityMonitor from './UserActivityMonitor'; +import {UserActivityMonitor} from './UserActivityMonitor'; describe('UserActivityMonitor', () => { describe('updateIdleTime', () => { diff --git a/src/main/UserActivityMonitor.ts b/src/main/UserActivityMonitor.ts index 2bfcda60..7178f616 100644 --- a/src/main/UserActivityMonitor.ts +++ b/src/main/UserActivityMonitor.ts @@ -3,15 +3,13 @@ import {EventEmitter} from 'events'; -import electron from 'electron'; +import {app, powerMonitor} from 'electron'; import log from 'electron-log'; -const {app} = electron; - /** * Monitors system idle time, listens for system events and fires status updates as needed */ -export default class UserActivityMonitor extends EventEmitter { +export class UserActivityMonitor extends EventEmitter { isActive: boolean; idleTime: number; lastSetActive?: number; @@ -70,7 +68,7 @@ export default class UserActivityMonitor extends EventEmitter { // Node typings don't map Timeout to number, but then clearInterval requires a number? this.systemIdleTimeIntervalID = setInterval(() => { try { - this.updateIdleTime(electron.powerMonitor.getSystemIdleTime()); + this.updateIdleTime(powerMonitor.getSystemIdleTime()); } catch (err) { log.error('Error getting system idle time:', err); } @@ -138,3 +136,6 @@ export default class UserActivityMonitor extends EventEmitter { }); } } + +const userActivityMonitor = new UserActivityMonitor(); +export default userActivityMonitor; diff --git a/src/main/allowProtocolDialog.ts b/src/main/allowProtocolDialog.ts index 68f6839b..14531800 100644 --- a/src/main/allowProtocolDialog.ts +++ b/src/main/allowProtocolDialog.ts @@ -5,17 +5,14 @@ import fs from 'fs'; -import path from 'path'; - -import {app, dialog, shell} from 'electron'; +import {dialog, shell} from 'electron'; import log from 'electron-log'; import {protocols} from '../../electron-builder.json'; import * as Validator from './Validator'; import WindowManager from './windows/windowManager'; - -const allowedProtocolFile = path.resolve(app.getPath('userData'), 'allowedProtocols.json'); +import {allowedProtocolFile} from './constants'; export class AllowProtocolDialog { allowedProtocols: string[]; diff --git a/src/main/app/app.test.js b/src/main/app/app.test.js new file mode 100644 index 00000000..74cc02c4 --- /dev/null +++ b/src/main/app/app.test.js @@ -0,0 +1,156 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {app, dialog} from 'electron'; + +import CertificateStore from 'main/certificateStore'; +import WindowManager from 'main/windows/windowManager'; + +import {handleAppWillFinishLaunching, handleAppCertificateError, certificateErrorCallbacks} from 'main/app/app'; +import {getDeeplinkingURL, openDeepLink} from 'main/app/utils'; + +jest.mock('electron', () => ({ + app: { + on: jest.fn(), + once: jest.fn(), + isReady: jest.fn(), + }, + dialog: { + showMessageBox: jest.fn(), + }, +})); + +jest.mock('electron-log', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +})); + +jest.mock('main/app/utils', () => ({ + getDeeplinkingURL: jest.fn(), + openDeepLink: jest.fn(), +})); + +jest.mock('main/certificateStore', () => ({ + isExplicitlyUntrusted: jest.fn(), + isTrusted: jest.fn(), + isExisting: jest.fn(), + add: jest.fn(), + save: jest.fn(), +})); +jest.mock('main/tray/tray', () => ({})); +jest.mock('main/windows/windowManager', () => ({ + getMainWindow: jest.fn(), +})); + +describe('main/app/app', () => { + describe('handleAppWillFinishLaunching', () => { + const deepLinkURL = 'mattermost://server-1.com'; + const testURL = 'http://server-1.com'; + + beforeEach(() => { + app.on.mockImplementation((event, cb) => { + if (event === 'open-url') { + cb({preventDefault: jest.fn()}, deepLinkURL); + } + }); + getDeeplinkingURL.mockReturnValue(testURL); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should open deep link if app is ready', () => { + app.isReady.mockReturnValue(true); + handleAppWillFinishLaunching(); + expect(openDeepLink).toHaveBeenCalledWith(testURL); + }); + + it('should wait until app is ready to open deep link', () => { + let callback; + app.once.mockImplementation((event, cb) => { + if (event === 'ready') { + callback = cb; + } + }); + app.isReady.mockReturnValue(false); + handleAppWillFinishLaunching(); + expect(openDeepLink).not.toHaveBeenCalled(); + callback({preventDefault: jest.fn()}, deepLinkURL); + expect(openDeepLink).toHaveBeenCalledWith(testURL); + }); + }); + + describe('handleAppCertificateError', () => { + const testURL = 'http://server-1.com'; + const callback = jest.fn(); + const event = {preventDefault: jest.fn()}; + const webContents = {loadURL: jest.fn()}; + const mainWindow = {}; + const promise = Promise.resolve({}); + const certificate = {}; + + beforeEach(() => { + WindowManager.getMainWindow.mockReturnValue(mainWindow); + }); + + afterEach(() => { + jest.resetAllMocks(); + certificateErrorCallbacks.clear(); + dialog.showMessageBox.mockReturnValue(promise); + }); + + it('should not trust if explicitly untrusted by CertificateStore', () => { + CertificateStore.isExplicitlyUntrusted.mockReturnValue(true); + handleAppCertificateError(event, webContents, testURL, 'error-1', certificate, callback); + expect(event.preventDefault).toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith(false); + }); + + it('should trust if trusted by CertificateStore', () => { + CertificateStore.isExplicitlyUntrusted.mockReturnValue(false); + CertificateStore.isTrusted.mockReturnValue(true); + handleAppCertificateError(event, webContents, testURL, 'error-1', certificate, callback); + expect(event.preventDefault).toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith(true); + }); + + it('should not show additional dialogs if certificate error has already been logged', () => { + certificateErrorCallbacks.set('http://server-1.com:error-1', callback); + handleAppCertificateError(event, webContents, testURL, 'error-1', certificate, callback); + expect(dialog.showMessageBox).not.toHaveBeenCalled(); + }); + + it('should set callback if one is not already set', () => { + handleAppCertificateError(event, webContents, testURL, 'error-1', certificate, callback); + expect(certificateErrorCallbacks.has('http://server-1.com:error-1')).toBe(true); + }); + + it('should remove callback and not add certificate if user selects Cancel', async () => { + dialog.showMessageBox.mockResolvedValue({response: 1}); + await handleAppCertificateError(event, webContents, testURL, 'error-1', certificate, callback); + expect(callback).toHaveBeenCalledWith(false); + expect(certificateErrorCallbacks.has('http://server-1.com:error-1')).toBe(false); + expect(CertificateStore.add).not.toHaveBeenCalled(); + }); + + it('should remove callback and add certificate if user selects More Details and Trust', async () => { + dialog.showMessageBox.mockResolvedValue({response: 0}); + await handleAppCertificateError(event, webContents, testURL, 'error-1', certificate, callback); + expect(callback).toHaveBeenCalledWith(true); + expect(certificateErrorCallbacks.has('http://server-1.com:error-1')).toBe(false); + expect(CertificateStore.add).toHaveBeenCalledWith('http://server-1.com', certificate); + expect(CertificateStore.save).toHaveBeenCalled(); + }); + + it('should explicitly untrust if user selects More Details and then cancel with the checkbox checked', async () => { + dialog.showMessageBox.mockResolvedValueOnce({response: 0}).mockResolvedValueOnce({response: 1, checkboxChecked: true}); + await handleAppCertificateError(event, webContents, testURL, 'error-1', certificate, callback); + expect(callback).toHaveBeenCalledWith(false); + expect(certificateErrorCallbacks.has('http://server-1.com:error-1')).toBe(false); + expect(CertificateStore.add).toHaveBeenCalledWith('http://server-1.com', certificate, true); + expect(CertificateStore.save).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/app/app.ts b/src/main/app/app.ts new file mode 100644 index 00000000..bcb5ed22 --- /dev/null +++ b/src/main/app/app.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {app, BrowserWindow, Event, dialog, WebContents, Certificate} from 'electron'; +import log from 'electron-log'; + +import urlUtils from 'common/utils/url'; + +import CertificateStore from 'main/certificateStore'; +import {destroyTray} from 'main/tray/tray'; +import WindowManager from 'main/windows/windowManager'; + +import {getDeeplinkingURL, openDeepLink, resizeScreen} from './utils'; + +export const certificateErrorCallbacks = new Map(); + +// +// app event handlers +// + +// activate first app instance, subsequent instances will quit themselves +export function handleAppSecondInstance(event: Event, argv: string[]) { + // Protocol handler for win32 + // argv: An array of the second instance’s (command line / deep linked) arguments + const deeplinkingUrl = getDeeplinkingURL(argv); + WindowManager.showMainWindow(deeplinkingUrl); +} + +export function handleAppWindowAllClosed() { + // On OS X it is common for applications and their menu bar + // to stay active until the user quits explicitly with Cmd + Q + if (process.platform !== 'darwin') { + app.quit(); + } +} + +export function handleAppBrowserWindowCreated(event: Event, newWindow: BrowserWindow) { + // Screen cannot be required before app is ready + resizeScreen(newWindow); +} + +export function handleAppWillFinishLaunching() { + // Protocol handler for osx + app.on('open-url', (event, url) => { + log.info(`Handling deeplinking url: ${url}`); + event.preventDefault(); + const deeplinkingUrl = getDeeplinkingURL([url]); + if (deeplinkingUrl) { + if (app.isReady() && deeplinkingUrl) { + openDeepLink(deeplinkingUrl); + } else { + app.once('ready', () => openDeepLink(deeplinkingUrl)); + } + } + }); +} + +export function handleAppBeforeQuit() { + // Make sure tray icon gets removed if the user exits via CTRL-Q + destroyTray(); + global.willAppQuit = true; +} + +export async function handleAppCertificateError(event: Event, webContents: WebContents, url: string, error: string, certificate: Certificate, callback: (isTrusted: boolean) => void) { + const parsedURL = urlUtils.parseURL(url); + if (!parsedURL) { + return; + } + const origin = parsedURL.origin; + if (CertificateStore.isExplicitlyUntrusted(origin)) { + event.preventDefault(); + log.warn(`Ignoring previously untrusted certificate for ${origin}`); + callback(false); + } else if (CertificateStore.isTrusted(origin, certificate)) { + event.preventDefault(); + callback(true); + } else { + // update the callback + const errorID = `${origin}:${error}`; + + // if we are already showing that error, don't add more dialogs + if (certificateErrorCallbacks.has(errorID)) { + log.warn(`Ignoring already shown dialog for ${errorID}`); + certificateErrorCallbacks.set(errorID, callback); + return; + } + const extraDetail = CertificateStore.isExisting(origin) ? 'Certificate is different from previous one.\n\n' : ''; + const detail = `${extraDetail}origin: ${origin}\nError: ${error}`; + + certificateErrorCallbacks.set(errorID, callback); + + // TODO: should we move this to window manager or provide a handler for dialogs? + const mainWindow = WindowManager.getMainWindow(); + if (!mainWindow) { + return; + } + + try { + let result = await dialog.showMessageBox(mainWindow, { + title: 'Certificate Error', + message: 'There is a configuration issue with this Mattermost server, or someone is trying to intercept your connection. You also may need to sign into the Wi-Fi you are connected to using your web browser.', + type: 'error', + detail, + buttons: ['More Details', 'Cancel Connection'], + cancelId: 1, + }); + + if (result.response === 0) { + result = await dialog.showMessageBox(mainWindow, { + title: 'Certificate Not Trusted', + message: `Certificate from "${certificate.issuerName}" is not trusted.`, + detail: extraDetail, + type: 'error', + buttons: ['Trust Insecure Certificate', 'Cancel Connection'], + cancelId: 1, + checkboxChecked: false, + checkboxLabel: "Don't ask again", + }); + } else { + result = {response: result.response, checkboxChecked: false}; + } + + if (result.response === 0) { + CertificateStore.add(origin, certificate); + CertificateStore.save(); + certificateErrorCallbacks.get(errorID)(true); + webContents.loadURL(url); + } else { + if (result.checkboxChecked) { + CertificateStore.add(origin, certificate, true); + CertificateStore.save(); + } + certificateErrorCallbacks.get(errorID)(false); + } + } catch (dialogError) { + log.error(`There was an error with the Certificate Error dialog: ${dialogError}`); + } + + certificateErrorCallbacks.delete(errorID); + } +} + +export function handleAppGPUProcessCrashed(event: Event, killed: boolean) { + log.error(`The GPU process has crashed (killed = ${killed})`); +} diff --git a/src/main/app/config.test.js b/src/main/app/config.test.js new file mode 100644 index 00000000..1a018282 --- /dev/null +++ b/src/main/app/config.test.js @@ -0,0 +1,108 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {app} from 'electron'; + +import {RELOAD_CONFIGURATION} from 'common/communication'; +import Config from 'common/config'; + +import {handleConfigUpdate} from 'main/app/config'; +import {handleNewServerModal} from 'main/app/intercom'; +import WindowManager from 'main/windows/windowManager'; +import AutoLauncher from 'main/AutoLauncher'; + +jest.mock('electron', () => ({ + app: { + isReady: jest.fn(), + setPath: jest.fn(), + }, + ipcMain: { + emit: jest.fn(), + on: jest.fn(), + }, +})); +jest.mock('electron-log', () => ({ + info: jest.fn(), + error: jest.fn(), +})); + +jest.mock('main/app/utils', () => ({ + handleUpdateMenuEvent: jest.fn(), + updateSpellCheckerLocales: jest.fn(), + updateServerInfos: jest.fn(), +})); +jest.mock('main/app/intercom', () => ({ + handleNewServerModal: jest.fn(), +})); +jest.mock('main/AutoLauncher', () => ({ + enable: jest.fn(), + disable: jest.fn(), +})); +jest.mock('main/badge', () => ({ + setUnreadBadgeSetting: jest.fn(), +})); +jest.mock('main/tray/tray', () => ({})); +jest.mock('main/windows/windowManager', () => ({ + handleUpdateConfig: jest.fn(), + sendToRenderer: jest.fn(), + initializeCurrentServerName: jest.fn(), +})); + +describe('main/app/config', () => { + describe('handleConfigUpdate', () => { + beforeEach(() => { + AutoLauncher.enable.mockResolvedValue({}); + AutoLauncher.disable.mockResolvedValue({}); + }); + + afterEach(() => { + delete Config.registryConfigData; + }); + + it('should reload renderer config only when app is ready', () => { + handleConfigUpdate({}); + expect(WindowManager.sendToRenderer).not.toBeCalled(); + + app.isReady.mockReturnValue(true); + handleConfigUpdate({}); + expect(WindowManager.sendToRenderer).toBeCalledWith(RELOAD_CONFIGURATION); + }); + + it('should set download path if applicable', () => { + handleConfigUpdate({downloadLocation: '/a/download/location'}); + expect(app.setPath).toHaveBeenCalledWith('downloads', '/a/download/location'); + }); + + it('should enable/disable auto launch on windows/linux', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'win32', + }); + + handleConfigUpdate({}); + expect(AutoLauncher.disable).toHaveBeenCalled(); + + handleConfigUpdate({autostart: true}); + expect(AutoLauncher.enable).toHaveBeenCalled(); + + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + }); + + it('should recheck teams after config update if registry data is pulled in', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'win32', + }); + Config.registryConfigData = {}; + + handleConfigUpdate({teams: []}); + expect(handleNewServerModal).toHaveBeenCalled(); + + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + }); + }); +}); diff --git a/src/main/app/config.ts b/src/main/app/config.ts new file mode 100644 index 00000000..29b6790d --- /dev/null +++ b/src/main/app/config.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {app, ipcMain} from 'electron'; +import log from 'electron-log'; + +import {CombinedConfig} from 'types/config'; + +import {DARK_MODE_CHANGE, EMIT_CONFIGURATION, RELOAD_CONFIGURATION} from 'common/communication'; +import Config from 'common/config'; + +import AutoLauncher from 'main/AutoLauncher'; +import {setUnreadBadgeSetting} from 'main/badge'; +import {refreshTrayImages} from 'main/tray/tray'; +import WindowManager from 'main/windows/windowManager'; + +import {handleNewServerModal} from './intercom'; +import {handleUpdateMenuEvent, updateServerInfos, updateSpellCheckerLocales} from './utils'; + +let didCheckForAddServerModal = false; + +// +// config event handlers +// + +export function handleConfigUpdate(newConfig: CombinedConfig) { + if (!newConfig) { + return; + } + + WindowManager.handleUpdateConfig(); + if (app.isReady()) { + WindowManager.sendToRenderer(RELOAD_CONFIGURATION); + } + + setUnreadBadgeSetting(newConfig && newConfig.showUnreadBadge); + updateSpellCheckerLocales(); + + if (newConfig.downloadLocation) { + try { + app.setPath('downloads', newConfig.downloadLocation); + } catch (e) { + log.error(`There was a problem trying to set the default download path: ${e}`); + } + } + + if (process.platform === 'win32' || process.platform === 'linux') { + const autoStartTask = newConfig.autostart ? AutoLauncher.enable() : AutoLauncher.disable(); + autoStartTask.then(() => { + log.info('config.autostart has been configured:', newConfig.autostart); + }).catch((err) => { + log.error('error:', err); + }); + } + + if (process.platform === 'win32' && !didCheckForAddServerModal && typeof Config.registryConfigData !== 'undefined') { + didCheckForAddServerModal = true; + updateServerInfos(newConfig.teams); + WindowManager.initializeCurrentServerName(); + if (newConfig.teams.length === 0) { + handleNewServerModal(); + } + } + + handleUpdateMenuEvent(); + ipcMain.emit(EMIT_CONFIGURATION, true, newConfig); +} + +export function handleDarkModeChange(darkMode: boolean) { + refreshTrayImages(Config.trayIconTheme); + WindowManager.sendToRenderer(DARK_MODE_CHANGE, darkMode); + WindowManager.updateLoadingScreenDarkMode(darkMode); + + ipcMain.emit(EMIT_CONFIGURATION, true, Config.data); +} diff --git a/src/main/app/index.ts b/src/main/app/index.ts new file mode 100644 index 00000000..48aceeb8 --- /dev/null +++ b/src/main/app/index.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* istanbul ignore file */ + +import {initialize} from './initialize'; + +if (process.env.NODE_ENV !== 'production' && module.hot) { + module.hot.accept(); +} + +// attempt to initialize the application +try { + initialize(); +} catch (error) { + throw new Error(`App initialization failed: ${error.toString()}`); +} diff --git a/src/main/app/initialize.test.js b/src/main/app/initialize.test.js new file mode 100644 index 00000000..0c2afbbb --- /dev/null +++ b/src/main/app/initialize.test.js @@ -0,0 +1,287 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import path from 'path'; + +import {app, session} from 'electron'; + +import Config from 'common/config'; +import urlUtils from 'common/utils/url'; + +import parseArgs from 'main/ParseArgs'; +import WindowManager from 'main/windows/windowManager'; + +import {initialize} from './initialize'; +import {clearAppCache, getDeeplinkingURL, wasUpdated} from './utils'; + +jest.mock('fs', () => ({ + unlinkSync: jest.fn(), +})); + +jest.mock('path', () => { + const original = jest.requireActual('path'); + return { + ...original, + resolve: jest.fn(), + }; +}); + +jest.mock('electron', () => ({ + app: { + on: jest.fn(), + exit: jest.fn(), + getPath: jest.fn(), + setPath: jest.fn(), + disableHardwareAcceleration: jest.fn(), + enableSandbox: jest.fn(), + requestSingleInstanceLock: jest.fn(), + setAsDefaultProtocolClient: jest.fn(), + setAppUserModelId: jest.fn(), + getVersion: jest.fn(), + whenReady: jest.fn(), + }, + ipcMain: { + on: jest.fn(), + handle: jest.fn(), + emit: jest.fn(), + }, + session: { + defaultSession: { + setSpellCheckerDictionaryDownloadURL: jest.fn(), + setPermissionRequestHandler: jest.fn(), + on: jest.fn(), + }, + }, +})); + +jest.mock('electron-devtools-installer', () => { + return () => ({ + REACT_DEVELOPER_TOOLS: 'react-developer-tools', + }); +}); + +const isDev = false; +jest.mock('electron-is-dev', () => isDev); + +jest.mock('electron-log', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +})); + +jest.mock('../../../electron-builder.json', () => ([ + { + name: 'Mattermost', + schemes: [ + 'mattermost', + ], + }, +])); + +jest.mock('common/config', () => ({ + once: jest.fn(), + on: jest.fn(), + init: jest.fn(), +})); + +jest.mock('common/utils/url', () => ({ + isTrustedURL: jest.fn(), +})); + +jest.mock('main/allowProtocolDialog', () => ({ + init: jest.fn(), +})); +jest.mock('main/app/app', () => ({})); +jest.mock('main/app/config', () => ({ + handleConfigUpdate: jest.fn(), +})); +jest.mock('main/app/intercom', () => ({})); +jest.mock('main/app/utils', () => ({ + clearAppCache: jest.fn(), + getDeeplinkingURL: jest.fn(), + handleUpdateMenuEvent: jest.fn(), + shouldShowTrayIcon: jest.fn(), + updateServerInfos: jest.fn(), + updateSpellCheckerLocales: jest.fn(), + wasUpdated: jest.fn(), + initCookieManager: jest.fn(), +})); +jest.mock('main/AppVersionManager', () => ({})); +jest.mock('main/authManager', () => ({})); +jest.mock('main/AutoLauncher', () => ({ + upgradeAutoLaunch: jest.fn(), +})); +jest.mock('main/badge', () => ({ + setupBadge: jest.fn(), +})); +jest.mock('main/certificateManager', () => ({})); +jest.mock('main/CriticalErrorHandler', () => ({ + processUncaughtExceptionHandler: jest.fn(), + setMainWindow: jest.fn(), +})); +jest.mock('main/notifications', () => ({ + displayDownloadCompleted: jest.fn(), +})); +jest.mock('main/ParseArgs', () => jest.fn()); +jest.mock('main/tray/tray', () => ({ + refreshTrayImages: jest.fn(), + setupTray: jest.fn(), +})); +jest.mock('main/trustedOrigins', () => ({ + load: jest.fn(), +})); +jest.mock('main/UserActivityMonitor', () => ({ + on: jest.fn(), + startMonitoring: jest.fn(), +})); +jest.mock('main/windows/windowManager', () => ({ + getMainWindow: jest.fn(), + showMainWindow: jest.fn(), + sendToMattermostViews: jest.fn(), +})); + +describe('main/app/initialize', () => { + beforeEach(() => { + parseArgs.mockReturnValue({}); + Config.once.mockImplementation((event, cb) => { + if (event === 'update') { + cb(); + } + }); + Config.data = {}; + Config.teams = []; + app.whenReady.mockResolvedValue(); + app.requestSingleInstanceLock.mockReturnValue(true); + app.getPath.mockImplementation((p) => `/basedir/${p}`); + }); + + afterEach(() => { + jest.resetAllMocks(); + delete Config.data; + }); + + it('should initialize without errors', async () => { + await initialize(); + }); + + describe('initializeArgs', () => { + it('should set datadir when specified', async () => { + path.resolve.mockImplementation((p) => `/basedir${p}`); + parseArgs.mockReturnValue({ + dataDir: '/some/dir', + }); + await initialize(); + expect(app.setPath).toHaveBeenCalledWith('userData', '/basedir/some/dir'); + }); + + it('should show version and exit when specified', async () => { + jest.spyOn(process.stdout, 'write').mockImplementation(() => {}); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {}); + parseArgs.mockReturnValue({ + version: true, + }); + await initialize(); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + }); + + describe('initializeConfig', () => { + it('should disable hardware acceleration when specified', async () => { + Config.enableHardwareAcceleration = false; + await initialize(); + expect(app.disableHardwareAcceleration).toHaveBeenCalled(); + }); + }); + + describe('initializeBeforeAppReady', () => { + it('should exit the app when single instance lock fails', () => { + app.requestSingleInstanceLock.mockReturnValue(false); + }); + }); + + describe('initializeAfterAppReady', () => { + it('should set spell checker URL if applicable', async () => { + Config.spellCheckerURL = 'http://server-1.com'; + await initialize(); + expect(session.defaultSession.setSpellCheckerDictionaryDownloadURL).toHaveBeenCalledWith('http://server-1.com/'); + }); + + it('should clear app cache if last version opened was older', async () => { + wasUpdated.mockReturnValue(true); + await initialize(); + expect(clearAppCache).toHaveBeenCalled(); + }); + + it('should perform deeplink on win32', async () => { + getDeeplinkingURL.mockReturnValue('mattermost://server-1.com'); + const originalPlatform = process.platform; + Object.defineProperty(process, 'argv', { + value: ['mattermost', 'mattermost://server-1.com'], + }); + Object.defineProperty(process, 'platform', { + value: 'win32', + }); + + await initialize(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + + expect(WindowManager.showMainWindow).toHaveBeenCalledWith('mattermost://server-1.com'); + }); + + it('should setup save dialog correctly', async () => { + const item = { + getFilename: () => 'filename.txt', + on: jest.fn(), + setSaveDialogOptions: jest.fn(), + }; + Config.downloadLocation = '/some/dir'; + path.resolve.mockImplementation((base, p) => `${base}/${p}`); + session.defaultSession.on.mockImplementation((event, cb) => { + if (event === 'will-download') { + cb(null, item, {id: 0}); + } + }); + + await initialize(); + expect(item.setSaveDialogOptions).toHaveBeenCalledWith(expect.objectContaining({ + title: 'filename.txt', + defaultPath: '/some/dir/filename.txt', + })); + }); + + it('should allow permission requests for supported types from trusted URLs', async () => { + let callback = jest.fn(); + session.defaultSession.setPermissionRequestHandler.mockImplementation((cb) => { + cb({id: 1, getURL: () => 'http://server-1.com'}, 'bad-permission', callback); + }); + await initialize(); + expect(callback).toHaveBeenCalledWith(false); + + callback = jest.fn(); + WindowManager.getMainWindow.mockReturnValue({webContents: {id: 1}}); + session.defaultSession.setPermissionRequestHandler.mockImplementation((cb) => { + cb({id: 1, getURL: () => 'http://server-1.com'}, 'openExternal', callback); + }); + await initialize(); + expect(callback).toHaveBeenCalledWith(true); + + urlUtils.isTrustedURL.mockImplementation((url) => url === 'http://server-1.com'); + + callback = jest.fn(); + session.defaultSession.setPermissionRequestHandler.mockImplementation((cb) => { + cb({id: 2, getURL: () => 'http://server-1.com'}, 'openExternal', callback); + }); + await initialize(); + expect(callback).toHaveBeenCalledWith(true); + + callback = jest.fn(); + session.defaultSession.setPermissionRequestHandler.mockImplementation((cb) => { + cb({id: 2, getURL: () => 'http://server-2.com'}, 'openExternal', callback); + }); + await initialize(); + expect(callback).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts new file mode 100644 index 00000000..d94cdee8 --- /dev/null +++ b/src/main/app/initialize.ts @@ -0,0 +1,381 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import path from 'path'; + +import {app, ipcMain, session} from 'electron'; +import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer'; +import isDev from 'electron-is-dev'; +import log from 'electron-log'; + +import { + SWITCH_SERVER, + FOCUS_BROWSERVIEW, + QUIT, + DOUBLE_CLICK_ON_WINDOW, + SHOW_NEW_SERVER_MODAL, + WINDOW_CLOSE, + WINDOW_MAXIMIZE, + WINDOW_MINIMIZE, + WINDOW_RESTORE, + NOTIFY_MENTION, + GET_DOWNLOAD_LOCATION, + SHOW_SETTINGS_WINDOW, + RELOAD_CONFIGURATION, + SWITCH_TAB, + CLOSE_TAB, + OPEN_TAB, + SHOW_EDIT_SERVER_MODAL, + SHOW_REMOVE_SERVER_MODAL, + UPDATE_SHORTCUT_MENU, + UPDATE_LAST_ACTIVE, + GET_AVAILABLE_SPELL_CHECKER_LANGUAGES, + USER_ACTIVITY_UPDATE, +} from 'common/communication'; +import Config from 'common/config'; +import urlUtils from 'common/utils/url'; + +import AllowProtocolDialog from 'main/allowProtocolDialog'; +import AppVersionManager from 'main/AppVersionManager'; +import AuthManager from 'main/authManager'; +import AutoLauncher from 'main/AutoLauncher'; +import {setupBadge} from 'main/badge'; +import CertificateManager from 'main/certificateManager'; +import {updatePaths} from 'main/constants'; +import CriticalErrorHandler from 'main/CriticalErrorHandler'; +import {displayDownloadCompleted} from 'main/notifications'; +import parseArgs from 'main/ParseArgs'; +import TrustedOriginsStore from 'main/trustedOrigins'; +import {refreshTrayImages, setupTray} from 'main/tray/tray'; +import UserActivityMonitor from 'main/UserActivityMonitor'; +import WindowManager from 'main/windows/windowManager'; + +import {protocols} from '../../../electron-builder.json'; + +import { + handleAppBeforeQuit, + handleAppBrowserWindowCreated, + handleAppCertificateError, + handleAppGPUProcessCrashed, + handleAppSecondInstance, + handleAppWillFinishLaunching, + handleAppWindowAllClosed, +} from './app'; +import {handleConfigUpdate, handleDarkModeChange} from './config'; +import { + handleAppVersion, + handleCloseTab, + handleEditServerModal, + handleMentionNotification, + handleNewServerModal, + handleOpenAppMenu, + handleOpenTab, + handleQuit, + handleReloadConfig, + handleRemoveServerModal, + handleSelectDownload, + handleSwitchServer, + handleSwitchTab, + handleUpdateLastActive, +} from './intercom'; +import { + clearAppCache, + getDeeplinkingURL, + handleUpdateMenuEvent, + shouldShowTrayIcon, + updateServerInfos, + updateSpellCheckerLocales, + wasUpdated, + initCookieManager, +} from './utils'; + +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)); + global.willAppQuit = false; + + // initialization that can run before the app is ready + initializeArgs(); + await initializeConfig(); + initializeAppEventListeners(); + initializeBeforeAppReady(); + + // wait for registry config data to load and app ready event + await Promise.all([ + app.whenReady(), + ]); + + // no need to continue initializing if app is quitting + if (global.willAppQuit) { + return; + } + + // initialization that should run once the app is ready + initializeInterCommunicationEventListeners(); + initializeAfterAppReady(); +} + +// +// initialization sub functions +// + +function initializeArgs() { + global.args = parseArgs(process.argv.slice(1)); + + // output the application version via cli when requested (-v or --version) + if (global.args.version) { + process.stdout.write(`v.${app.getVersion()}\n`); + process.exit(0); // eslint-disable-line no-process-exit + } + + global.isDev = isDev && !global.args.disableDevMode; // this doesn't seem to be right and isn't used as the single source of truth + + if (global.args.dataDir) { + app.setPath('userData', path.resolve(global.args.dataDir)); + updatePaths(true); + } +} + +async function initializeConfig() { + return new Promise((resolve) => { + Config.once('update', (configData) => { + Config.on('update', handleConfigUpdate); + Config.on('darkModeChange', handleDarkModeChange); + Config.on('error', (error) => { + log.error(error); + }); + handleConfigUpdate(configData); + + // can only call this before the app is ready + if (Config.enableHardwareAcceleration === false) { + app.disableHardwareAcceleration(); + } + + resolve(); + }); + Config.init(); + }); +} + +function initializeAppEventListeners() { + app.on('second-instance', handleAppSecondInstance); + app.on('window-all-closed', handleAppWindowAllClosed); + app.on('browser-window-created', handleAppBrowserWindowCreated); + app.on('activate', () => WindowManager.showMainWindow()); + app.on('before-quit', handleAppBeforeQuit); + app.on('certificate-error', handleAppCertificateError); + app.on('select-client-certificate', CertificateManager.handleSelectCertificate); + app.on('gpu-process-crashed', handleAppGPUProcessCrashed); + app.on('login', AuthManager.handleAppLogin); + app.on('will-finish-launching', handleAppWillFinishLaunching); +} + +function initializeBeforeAppReady() { + if (!Config.data) { + log.error('No config loaded'); + return; + } + if (process.env.NODE_ENV !== 'test') { + app.enableSandbox(); + } + TrustedOriginsStore.load(); + + // prevent using a different working directory, which happens on windows running after installation. + const expectedPath = path.dirname(process.execPath); + if (process.cwd() !== expectedPath && !isDev) { + log.warn(`Current working directory is ${process.cwd()}, changing into ${expectedPath}`); + process.chdir(expectedPath); + } + + refreshTrayImages(Config.trayIconTheme); + + // If there is already an instance, quit this one + const gotTheLock = app.requestSingleInstanceLock(); + if (!gotTheLock) { + app.exit(); + global.willAppQuit = true; + } + + AllowProtocolDialog.init(); + + if (isDev && process.env.NODE_ENV !== 'test') { + log.info('In development mode, deeplinking is disabled'); + } else if (mainProtocol) { + app.setAsDefaultProtocolClient(mainProtocol); + } +} + +function initializeInterCommunicationEventListeners() { + ipcMain.on(RELOAD_CONFIGURATION, handleReloadConfig); + ipcMain.on(NOTIFY_MENTION, handleMentionNotification); + ipcMain.handle('get-app-version', handleAppVersion); + ipcMain.on(UPDATE_SHORTCUT_MENU, handleUpdateMenuEvent); + ipcMain.on(FOCUS_BROWSERVIEW, WindowManager.focusBrowserView); + ipcMain.on(UPDATE_LAST_ACTIVE, handleUpdateLastActive); + + if (process.platform !== 'darwin') { + ipcMain.on('open-app-menu', handleOpenAppMenu); + } + + ipcMain.on(SWITCH_SERVER, handleSwitchServer); + ipcMain.on(SWITCH_TAB, handleSwitchTab); + ipcMain.on(CLOSE_TAB, handleCloseTab); + ipcMain.on(OPEN_TAB, handleOpenTab); + + ipcMain.on(QUIT, handleQuit); + + ipcMain.on(DOUBLE_CLICK_ON_WINDOW, WindowManager.handleDoubleClick); + + ipcMain.on(SHOW_NEW_SERVER_MODAL, handleNewServerModal); + ipcMain.on(SHOW_EDIT_SERVER_MODAL, handleEditServerModal); + ipcMain.on(SHOW_REMOVE_SERVER_MODAL, handleRemoveServerModal); + ipcMain.on(WINDOW_CLOSE, WindowManager.close); + 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.handle(GET_AVAILABLE_SPELL_CHECKER_LANGUAGES, () => session.defaultSession.availableSpellCheckerLanguages); + ipcMain.handle(GET_DOWNLOAD_LOCATION, handleSelectDownload); +} + +function initializeAfterAppReady() { + updateServerInfos(Config.teams); + app.setAppUserModelId('Mattermost.Desktop'); // Use explicit AppUserModelID + const defaultSession = session.defaultSession; + + if (process.platform !== 'darwin') { + defaultSession.on('spellcheck-dictionary-download-failure', (event, lang) => { + if (Config.spellCheckerURL) { + log.error(`There was an error while trying to load the dictionary definitions for ${lang} fromfully the specified url. Please review you have access to the needed files. Url used was ${Config.spellCheckerURL}`); + } else { + log.warn(`There was an error while trying to download the dictionary definitions for ${lang}, spellchecking might not work properly.`); + } + }); + + if (Config.spellCheckerURL) { + const spellCheckerURL = Config.spellCheckerURL.endsWith('/') ? Config.spellCheckerURL : `${Config.spellCheckerURL}/`; + log.info(`Configuring spellchecker using download URL: ${spellCheckerURL}`); + defaultSession.setSpellCheckerDictionaryDownloadURL(spellCheckerURL); + + defaultSession.on('spellcheck-dictionary-download-success', (event, lang) => { + log.info(`Dictionary definitions downloaded successfully for ${lang}`); + }); + } + updateSpellCheckerLocales(); + } + + if (wasUpdated(AppVersionManager.lastAppVersion)) { + clearAppCache(); + } + AppVersionManager.lastAppVersion = app.getVersion(); + + if (!global.isDev) { + AutoLauncher.upgradeAutoLaunch(); + } + + if (global.isDev) { + installExtension(REACT_DEVELOPER_TOOLS). + then((name) => log.info(`Added Extension: ${name}`)). + catch((err) => log.error('An error occurred: ', err)); + } + + let deeplinkingURL; + + // Protocol handler for win32 + if (process.platform === 'win32') { + const args = process.argv.slice(1); + if (Array.isArray(args) && args.length > 0) { + deeplinkingURL = getDeeplinkingURL(args); + } + } + + initCookieManager(defaultSession); + + WindowManager.showMainWindow(deeplinkingURL); + + CriticalErrorHandler.setMainWindow(WindowManager.getMainWindow()!); + + // listen for status updates and pass on to renderer + UserActivityMonitor.on('status', (status) => { + WindowManager.sendToMattermostViews(USER_ACTIVITY_UPDATE, status); + }); + + // start monitoring user activity (needs to be started after the app is ready) + UserActivityMonitor.startMonitoring(); + + if (shouldShowTrayIcon()) { + setupTray(Config.trayIconTheme); + } + setupBadge(); + + defaultSession.on('will-download', (event, item, webContents) => { + const filename = item.getFilename(); + const fileElements = filename.split('.'); + const filters = []; + if (fileElements.length > 1) { + filters.push({ + name: 'All files', + extensions: ['*'], + }); + } + item.setSaveDialogOptions({ + title: filename, + defaultPath: path.resolve(Config.downloadLocation, filename), + filters, + }); + + item.on('done', (doneEvent, state) => { + if (state === 'completed') { + displayDownloadCompleted(filename, item.savePath, WindowManager.getServerNameByWebContentsId(webContents.id) || ''); + } + }); + }); + + handleUpdateMenuEvent(); + + ipcMain.emit('update-dict'); + + // supported permission types + const supportedPermissionTypes = [ + 'media', + 'geolocation', + 'notifications', + 'fullscreen', + 'openExternal', + ]; + + // handle permission requests + // - approve if a supported permission type and the request comes from the renderer or one of the defined servers + defaultSession.setPermissionRequestHandler((webContents, permission, callback) => { + // is the requested permission type supported? + if (!supportedPermissionTypes.includes(permission)) { + callback(false); + return; + } + + // is the request coming from the renderer? + const mainWindow = WindowManager.getMainWindow(); + if (mainWindow && webContents.id === mainWindow.webContents.id) { + callback(true); + return; + } + + const requestingURL = webContents.getURL(); + + // is the requesting url trusted? + callback(urlUtils.isTrustedURL(requestingURL, Config.teams)); + }); + + // only check for non-Windows, as with Windows we have to wait for GPO teams + if (process.platform !== 'win32' || typeof Config.registryConfigData !== 'undefined') { + if (Config.teams.length === 0) { + setTimeout(() => { + handleNewServerModal(); + }, 200); + } + } +} diff --git a/src/main/app/intercom.test.js b/src/main/app/intercom.test.js new file mode 100644 index 00000000..41fe847a --- /dev/null +++ b/src/main/app/intercom.test.js @@ -0,0 +1,238 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Config from 'common/config'; +import {getDefaultTeamWithTabsFromTeam} from 'common/tabs/TabView'; + +import {getLocalURLString, getLocalPreload} from 'main/utils'; +import ModalManager from 'main/views/modalManager'; +import WindowManager from 'main/windows/windowManager'; + +import { + handleOpenTab, + handleCloseTab, + handleNewServerModal, + handleEditServerModal, + handleRemoveServerModal, +} from './intercom'; + +jest.mock('common/config', () => ({ + set: jest.fn(), +})); +jest.mock('common/tabs/TabView', () => ({ + getDefaultTeamWithTabsFromTeam: jest.fn(), +})); +jest.mock('main/notifications', () => ({})); +jest.mock('main/utils', () => ({ + getLocalPreload: jest.fn(), + getLocalURLString: jest.fn(), +})); +jest.mock('main/views/modalManager', () => ({ + addModal: jest.fn(), +})); +jest.mock('main/windows/windowManager', () => ({ + getMainWindow: jest.fn(), + switchServer: jest.fn(), + switchTab: jest.fn(), +})); + +jest.mock('./app', () => ({})); +jest.mock('./utils', () => ({ + updateServerInfos: jest.fn(), +})); + +const tabs = [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + { + name: 'tab-3', + order: 1, + isOpen: true, + }, +]; +const teams = [ + { + name: 'server-1', + url: 'http://server-1.com', + tabs, + }, +]; + +describe('main/app/intercom', () => { + describe('handleCloseTab', () => { + beforeEach(() => { + Config.set.mockImplementation((name, value) => { + Config[name] = value; + }); + Config.teams = JSON.parse(JSON.stringify(teams)); + }); + + afterEach(() => { + delete Config.teams; + }); + + it('should close the specified tab and switch to the next open tab', () => { + handleCloseTab(null, 'server-1', 'tab-3'); + expect(WindowManager.switchTab).toBeCalledWith('server-1', 'tab-2'); + expect(Config.teams.find((team) => team.name === 'server-1').tabs.find((tab) => tab.name === 'tab-3').isOpen).toBe(false); + }); + }); + + describe('handleOpenTab', () => { + beforeEach(() => { + Config.set.mockImplementation((name, value) => { + Config[name] = value; + }); + Config.teams = JSON.parse(JSON.stringify(teams)); + }); + + afterEach(() => { + delete Config.teams; + }); + + it('should open the specified tab', () => { + handleOpenTab(null, 'server-1', 'tab-1'); + expect(WindowManager.switchTab).toBeCalledWith('server-1', 'tab-1'); + expect(Config.teams.find((team) => team.name === 'server-1').tabs.find((tab) => tab.name === 'tab-1').isOpen).toBe(true); + }); + }); + + describe('handleNewServerModal', () => { + beforeEach(() => { + getLocalURLString.mockReturnValue('/some/index.html'); + getLocalPreload.mockReturnValue('/some/preload.js'); + WindowManager.getMainWindow.mockReturnValue({}); + + Config.set.mockImplementation((name, value) => { + Config[name] = value; + }); + Config.teams = JSON.parse(JSON.stringify(teams)); + + getDefaultTeamWithTabsFromTeam.mockImplementation((team) => ({ + ...team, + tabs, + })); + }); + + afterEach(() => { + delete Config.teams; + }); + + it('should add new team to the config', async () => { + const promise = Promise.resolve({ + name: 'new-team', + url: 'http://new-team.com', + }); + ModalManager.addModal.mockReturnValue(promise); + + handleNewServerModal(); + await promise; + expect(Config.teams).toContainEqual(expect.objectContaining({ + name: 'new-team', + url: 'http://new-team.com', + tabs, + })); + expect(WindowManager.switchServer).toBeCalledWith('new-team', true); + }); + }); + + describe('handleEditServerModal', () => { + beforeEach(() => { + getLocalURLString.mockReturnValue('/some/index.html'); + getLocalPreload.mockReturnValue('/some/preload.js'); + WindowManager.getMainWindow.mockReturnValue({}); + + Config.set.mockImplementation((name, value) => { + Config[name] = value; + }); + Config.teams = JSON.parse(JSON.stringify(teams)); + }); + + afterEach(() => { + delete Config.teams; + }); + + it('should do nothing when the server cannot be found', () => { + handleEditServerModal(null, 'bad-server'); + expect(ModalManager.addModal).not.toBeCalled(); + }); + + it('should edit the existing team', async () => { + const promise = Promise.resolve({ + name: 'new-team', + url: 'http://new-team.com', + }); + ModalManager.addModal.mockReturnValue(promise); + + handleEditServerModal(null, 'server-1'); + await promise; + expect(Config.teams).not.toContainEqual(expect.objectContaining({ + name: 'server-1', + url: 'http://server-1.com', + tabs, + })); + expect(Config.teams).toContainEqual(expect.objectContaining({ + name: 'new-team', + url: 'http://new-team.com', + tabs, + })); + }); + }); + + describe('handleRemoveServerModal', () => { + beforeEach(() => { + getLocalURLString.mockReturnValue('/some/index.html'); + getLocalPreload.mockReturnValue('/some/preload.js'); + WindowManager.getMainWindow.mockReturnValue({}); + + Config.set.mockImplementation((name, value) => { + Config[name] = value; + }); + Config.teams = JSON.parse(JSON.stringify(teams)); + }); + + afterEach(() => { + delete Config.teams; + }); + + it('should remove the existing team', async () => { + const promise = Promise.resolve(true); + ModalManager.addModal.mockReturnValue(promise); + + handleRemoveServerModal(null, 'server-1'); + await promise; + expect(Config.teams).not.toContainEqual(expect.objectContaining({ + name: 'server-1', + url: 'http://server-1.com', + tabs, + })); + }); + + it('should not remove the existing team when clicking Cancel', async () => { + const promise = Promise.resolve(false); + ModalManager.addModal.mockReturnValue(promise); + + expect(Config.teams).toContainEqual(expect.objectContaining({ + name: 'server-1', + url: 'http://server-1.com', + tabs, + })); + + handleRemoveServerModal(null, 'server-1'); + await promise; + expect(Config.teams).toContainEqual(expect.objectContaining({ + name: 'server-1', + url: 'http://server-1.com', + tabs, + })); + }); + }); +}); diff --git a/src/main/app/intercom.ts b/src/main/app/intercom.ts new file mode 100644 index 00000000..d68b370b --- /dev/null +++ b/src/main/app/intercom.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {app, dialog, IpcMainEvent, IpcMainInvokeEvent, Menu} from 'electron'; +import log from 'electron-log'; + +import {Team} from 'types/config'; +import {MentionData} from 'types/notification'; + +import Config from 'common/config'; +import {getDefaultTeamWithTabsFromTeam} from 'common/tabs/TabView'; + +import {displayMention} from 'main/notifications'; +import {getLocalPreload, getLocalURLString} from 'main/utils'; +import ModalManager from 'main/views/modalManager'; +import WindowManager from 'main/windows/windowManager'; + +import {handleAppBeforeQuit} from './app'; +import {updateServerInfos} from './utils'; + +export function handleReloadConfig() { + Config.reload(); + WindowManager.handleUpdateConfig(); +} + +export function handleAppVersion() { + return { + name: app.getName(), + version: app.getVersion(), + }; +} + +export function handleQuit(e: IpcMainEvent, reason: string, stack: string) { + log.error(`Exiting App. Reason: ${reason}`); + log.info(`Stacktrace:\n${stack}`); + handleAppBeforeQuit(); + app.quit(); +} + +export function handleSwitchServer(event: IpcMainEvent, serverName: string) { + WindowManager.switchServer(serverName); +} + +export function handleSwitchTab(event: IpcMainEvent, serverName: string, tabName: string) { + WindowManager.switchTab(serverName, tabName); +} + +export function handleCloseTab(event: IpcMainEvent, serverName: string, tabName: string) { + const teams = Config.teams; + teams.forEach((team) => { + if (team.name === serverName) { + team.tabs.forEach((tab) => { + if (tab.name === tabName) { + tab.isOpen = false; + } + }); + } + }); + const nextTab = teams.find((team) => team.name === serverName)!.tabs.filter((tab) => tab.isOpen)[0].name; + WindowManager.switchTab(serverName, nextTab); + Config.set('teams', teams); +} + +export function handleOpenTab(event: IpcMainEvent, serverName: string, tabName: string) { + const teams = Config.teams; + teams.forEach((team) => { + if (team.name === serverName) { + team.tabs.forEach((tab) => { + if (tab.name === tabName) { + tab.isOpen = true; + } + }); + } + }); + WindowManager.switchTab(serverName, tabName); + Config.set('teams', teams); +} + +export function handleNewServerModal() { + const html = getLocalURLString('newServer.html'); + + const modalPreload = getLocalPreload('modalPreload.js'); + + const mainWindow = WindowManager.getMainWindow(); + if (!mainWindow) { + return; + } + const modalPromise = ModalManager.addModal('newServer', html, modalPreload, {}, mainWindow, Config.teams.length === 0); + if (modalPromise) { + modalPromise.then((data) => { + const teams = Config.teams; + const order = teams.length; + const newTeam = getDefaultTeamWithTabsFromTeam({...data, order}); + teams.push(newTeam); + Config.set('teams', teams); + updateServerInfos([newTeam]); + WindowManager.switchServer(newTeam.name, true); + }).catch((e) => { + // e is undefined for user cancellation + if (e) { + log.error(`there was an error in the new server modal: ${e}`); + } + }); + } else { + log.warn('There is already a new server modal'); + } +} + +export function handleEditServerModal(e: IpcMainEvent, name: string) { + const html = getLocalURLString('editServer.html'); + + const modalPreload = getLocalPreload('modalPreload.js'); + + const mainWindow = WindowManager.getMainWindow(); + if (!mainWindow) { + return; + } + const serverIndex = Config.teams.findIndex((team) => team.name === name); + if (serverIndex < 0) { + return; + } + const modalPromise = ModalManager.addModal('editServer', html, modalPreload, Config.teams[serverIndex], mainWindow); + if (modalPromise) { + modalPromise.then((data) => { + const teams = Config.teams; + teams[serverIndex].name = data.name; + teams[serverIndex].url = data.url; + Config.set('teams', teams); + }).catch((e) => { + // e is undefined for user cancellation + if (e) { + log.error(`there was an error in the edit server modal: ${e}`); + } + }); + } else { + log.warn('There is already an edit server modal'); + } +} + +export function handleRemoveServerModal(e: IpcMainEvent, name: string) { + const html = getLocalURLString('removeServer.html'); + + const modalPreload = getLocalPreload('modalPreload.js'); + + const mainWindow = WindowManager.getMainWindow(); + if (!mainWindow) { + return; + } + const modalPromise = ModalManager.addModal('removeServer', html, modalPreload, name, mainWindow); + if (modalPromise) { + modalPromise.then((remove) => { + if (remove) { + const teams = Config.teams; + const removedTeam = teams.findIndex((team) => team.name === name); + if (removedTeam < 0) { + return; + } + const removedOrder = teams[removedTeam].order; + teams.splice(removedTeam, 1); + teams.forEach((value) => { + if (value.order > removedOrder) { + value.order--; + } + }); + Config.set('teams', teams); + } + }).catch((e) => { + // e is undefined for user cancellation + if (e) { + log.error(`there was an error in the edit server modal: ${e}`); + } + }); + } else { + log.warn('There is already an edit server modal'); + } +} + +export function handleMentionNotification(event: IpcMainEvent, title: string, body: string, channel: {id: string}, teamId: string, url: string, silent: boolean, data: MentionData) { + displayMention(title, body, channel, teamId, url, silent, event.sender, data); +} + +export function handleOpenAppMenu() { + const windowMenu = Menu.getApplicationMenu(); + if (!windowMenu) { + log.error('No application menu found'); + return; + } + windowMenu.popup({ + window: WindowManager.getMainWindow(), + x: 18, + y: 18, + }); +} + +export async function handleSelectDownload(event: IpcMainInvokeEvent, startFrom: string) { + const message = 'Specify the folder where files will download'; + const result = await dialog.showOpenDialog({defaultPath: startFrom || Config.downloadLocation, + message, + properties: + ['openDirectory', 'createDirectory', 'dontAddToRecent', 'promptToCreate']}); + return result.filePaths[0]; +} + +export function handleUpdateLastActive(event: IpcMainEvent, serverName: string, viewName: string) { + const teams = Config.teams; + teams.forEach((team) => { + if (team.name === serverName) { + const viewOrder = team?.tabs.find((tab) => tab.name === viewName)?.order || 0; + team.lastActiveTab = viewOrder; + } + }); + Config.set('teams', teams); + Config.set('lastActiveTeam', teams.find((team) => team.name === serverName)?.order || 0); +} + diff --git a/src/main/app/utils.test.js b/src/main/app/utils.test.js new file mode 100644 index 00000000..12b14846 --- /dev/null +++ b/src/main/app/utils.test.js @@ -0,0 +1,192 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Config from 'common/config'; +import {TAB_MESSAGING, TAB_FOCALBOARD, TAB_PLAYBOOKS} from 'common/tabs/TabView'; +import Utils from 'common/utils/util'; + +import {ServerInfo} from 'main/server/serverInfo'; + +import {getDeeplinkingURL, updateServerInfos, resizeScreen} from './utils'; + +jest.mock('electron-log', () => ({ + info: jest.fn(), +})); + +jest.mock('common/config', () => ({ + set: jest.fn(), +})); +jest.mock('common/utils/util', () => ({ + isVersionGreaterThanOrEqualTo: jest.fn(), + getDisplayBoundaries: jest.fn(), +})); + +jest.mock('main/menus/app', () => ({})); +jest.mock('main/menus/tray', () => ({})); +jest.mock('main/server/serverInfo', () => ({ + ServerInfo: jest.fn(), +})); +jest.mock('main/tray/tray', () => ({})); +jest.mock('main/windows/windowManager', () => ({})); + +jest.mock('./initialize', () => ({ + mainProtocol: 'mattermost', +})); + +describe('main/app/utils', () => { + describe('updateServerInfos', () => { + const tabs = [ + { + name: TAB_MESSAGING, + order: 0, + isOpen: true, + }, + { + name: TAB_FOCALBOARD, + order: 2, + }, + { + name: TAB_PLAYBOOKS, + order: 1, + }, + ]; + const teams = [ + { + name: 'server-1', + url: 'http://server-1.com', + tabs, + }, + ]; + + beforeEach(() => { + Utils.isVersionGreaterThanOrEqualTo.mockImplementation((version) => version === '6.0.0'); + Config.set.mockImplementation((name, value) => { + Config[name] = value; + }); + const teamsCopy = JSON.parse(JSON.stringify(teams)); + Config.teams = teamsCopy; + }); + + afterEach(() => { + delete Config.teams; + }); + + it('should open all tabs', async () => { + ServerInfo.mockReturnValue({promise: { + name: 'server-1', + serverVersion: '6.0.0', + hasPlaybooks: true, + hasFocalboard: true, + }}); + + updateServerInfos(Config.teams); + await new Promise(setImmediate); // workaround since Promise.all seems to not let me wait here + + expect(Config.teams.find((team) => team.name === 'server-1').tabs.find((tab) => tab.name === TAB_PLAYBOOKS).isOpen).toBe(true); + expect(Config.teams.find((team) => team.name === 'server-1').tabs.find((tab) => tab.name === TAB_FOCALBOARD).isOpen).toBe(true); + }); + + it('should open only playbooks', async () => { + ServerInfo.mockReturnValue({promise: { + name: 'server-1', + serverVersion: '6.0.0', + hasPlaybooks: true, + hasFocalboard: false, + }}); + + updateServerInfos(Config.teams); + await new Promise(setImmediate); // workaround since Promise.all seems to not let me wait here + + expect(Config.teams.find((team) => team.name === 'server-1').tabs.find((tab) => tab.name === TAB_PLAYBOOKS).isOpen).toBe(true); + expect(Config.teams.find((team) => team.name === 'server-1').tabs.find((tab) => tab.name === TAB_FOCALBOARD).isOpen).toBeUndefined(); + }); + + it('should open none when server version is too old', async () => { + ServerInfo.mockReturnValue({promise: { + name: 'server-1', + serverVersion: '5.0.0', + hasPlaybooks: true, + hasFocalboard: true, + }}); + + updateServerInfos(Config.teams); + await new Promise(setImmediate); // workaround since Promise.all seems to not let me wait here + + expect(Config.teams.find((team) => team.name === 'server-1').tabs.find((tab) => tab.name === TAB_PLAYBOOKS).isOpen).toBeUndefined(); + expect(Config.teams.find((team) => team.name === 'server-1').tabs.find((tab) => tab.name === TAB_FOCALBOARD).isOpen).toBeUndefined(); + }); + }); + + describe('getDeeplinkingURL', () => { + it('should return undefined if deeplinking URL is not last argument', () => { + expect(getDeeplinkingURL(['mattermost', 'mattermost://server-1.com', '--oops'])).toBeUndefined(); + }); + + it('should return undefined if deeplinking URL is not valid', () => { + expect(getDeeplinkingURL(['mattermost', 'mattermost://,a { + expect(getDeeplinkingURL(['mattermost', 'mattermost://server-1.com'])).toBe('mattermost://server-1.com'); + }); + }); + + describe('resizeScreen', () => { + beforeEach(() => { + Utils.getDisplayBoundaries.mockReturnValue([{ + minX: 400, + minY: 300, + maxX: 2320, + maxY: 1380, + width: 1920, + height: 1080, + }]); + }); + it('should keep the same position if it is within a display', () => { + const browserWindow = { + getPosition: () => [500, 400], + getSize: () => [1280, 720], + setPosition: jest.fn(), + center: jest.fn(), + on: jest.fn(), + }; + resizeScreen(browserWindow); + expect(browserWindow.setPosition).toHaveBeenCalledWith(500, 400); + }); + + it('should keep the same position if it is halfway within a display', () => { + let browserWindow = { + getPosition: () => [1680, 400], + getSize: () => [1280, 720], + setPosition: jest.fn(), + center: jest.fn(), + on: jest.fn(), + }; + resizeScreen(browserWindow); + expect(browserWindow.setPosition).toHaveBeenCalledWith(1680, 400); + + browserWindow = { + getPosition: () => [500, 1020], + getSize: () => [1280, 720], + setPosition: jest.fn(), + center: jest.fn(), + on: jest.fn(), + }; + resizeScreen(browserWindow); + expect(browserWindow.setPosition).toHaveBeenCalledWith(500, 1020); + }); + + it('should center if it is outside a display', () => { + const browserWindow = { + getPosition: () => [2400, 2000], + getSize: () => [1280, 720], + setPosition: jest.fn(), + center: jest.fn(), + on: jest.fn(), + }; + resizeScreen(browserWindow); + expect(browserWindow.setPosition).not.toHaveBeenCalled(); + expect(browserWindow.center).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/app/utils.ts b/src/main/app/utils.ts new file mode 100644 index 00000000..9b5af2cf --- /dev/null +++ b/src/main/app/utils.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {app, BrowserWindow, Menu, Rectangle, Session, session} from 'electron'; +import log from 'electron-log'; + +import {TeamWithTabs} from 'types/config'; +import {RemoteInfo} from 'types/server'; +import {Boundaries} from 'types/utils'; + +import Config from 'common/config'; +import {MattermostServer} from 'common/servers/MattermostServer'; +import {TAB_FOCALBOARD, TAB_MESSAGING, TAB_PLAYBOOKS} from 'common/tabs/TabView'; +import urlUtils from 'common/utils/url'; +import Utils from 'common/utils/util'; + +import {createMenu as createAppMenu} from 'main/menus/app'; +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 {mainProtocol} from './initialize'; + +export function openDeepLink(deeplinkingUrl: string) { + try { + WindowManager.showMainWindow(deeplinkingUrl); + } catch (err) { + log.error(`There was an error opening the deeplinking url: ${err}`); + } +} + +export function updateSpellCheckerLocales() { + if (Config.data?.spellCheckerLocales.length && app.isReady()) { + session.defaultSession.setSpellCheckerLanguages(Config.data?.spellCheckerLocales); + } +} + +export function updateServerInfos(teams: TeamWithTabs[]) { + const serverInfos: Array> = []; + teams.forEach((team) => { + const serverInfo = new ServerInfo(new MattermostServer(team.name, team.url)); + serverInfos.push(serverInfo.promise); + }); + Promise.all(serverInfos).then((data: Array) => { + const teams = Config.teams; + teams.forEach((team) => openExtraTabs(data, team)); + Config.set('teams', teams); + }).catch((reason: any) => { + log.error('Error getting server infos', reason); + }); +} + +function openExtraTabs(data: Array, team: TeamWithTabs) { + const remoteInfo = data.find((info) => info && typeof info !== 'string' && info.name === team.name) as RemoteInfo; + if (remoteInfo) { + team.tabs.forEach((tab) => { + if (tab.name !== TAB_MESSAGING && remoteInfo.serverVersion && Utils.isVersionGreaterThanOrEqualTo(remoteInfo.serverVersion, '6.0.0')) { + if (tab.name === TAB_PLAYBOOKS && remoteInfo.hasPlaybooks && tab.isOpen !== false) { + log.info(`opening ${team.name}___${tab.name} on hasPlaybooks`); + tab.isOpen = true; + } + if (tab.name === TAB_FOCALBOARD && remoteInfo.hasFocalboard && tab.isOpen !== false) { + log.info(`opening ${team.name}___${tab.name} on hasFocalboard`); + tab.isOpen = true; + } + } + }); + } +} + +export function handleUpdateMenuEvent() { + const aMenu = createAppMenu(Config); + Menu.setApplicationMenu(aMenu); + aMenu.addListener('menu-will-close', WindowManager.focusBrowserView); + + // set up context menu for tray icon + if (shouldShowTrayIcon()) { + const tMenu = createTrayMenu(Config.data!); + setTrayMenu(tMenu); + } +} + +export function getDeeplinkingURL(args: string[]) { + if (Array.isArray(args) && args.length) { + // deeplink urls should always be the last argument, but may not be the first (i.e. Windows with the app already running) + const url = args[args.length - 1]; + if (url && mainProtocol && url.startsWith(mainProtocol) && urlUtils.isValidURI(url)) { + return url; + } + } + return undefined; +} + +export function shouldShowTrayIcon() { + return Config.showTrayIcon || process.platform === 'win32'; +} + +export function wasUpdated(lastAppVersion?: string) { + return lastAppVersion !== app.getVersion(); +} + +export function clearAppCache() { + // TODO: clear cache on browserviews, not in the renderer. + const mainWindow = WindowManager.getMainWindow(); + if (mainWindow) { + mainWindow.webContents.session.clearCache().then(mainWindow.reload); + } else { + //Wait for mainWindow + setTimeout(clearAppCache, 100); + } +} + +function isWithinDisplay(state: Rectangle, display: Boundaries) { + const startsWithinDisplay = !(state.x > display.maxX || state.y > display.maxY || state.x < display.minX || state.y < display.minY); + if (!startsWithinDisplay) { + return false; + } + + // is half the screen within the display? + const midX = state.x + (state.width / 2); + const midY = state.y + (state.height / 2); + return !(midX > display.maxX || midY > display.maxY); +} + +function getValidWindowPosition(state: Rectangle) { + // Check if the previous position is out of the viewable area + // (e.g. because the screen has been plugged off) + const boundaries = Utils.getDisplayBoundaries(); + const display = boundaries.find((boundary) => { + return isWithinDisplay(state, boundary); + }); + + if (typeof display === 'undefined') { + return {}; + } + return {x: state.x, y: state.y}; +} + +export function resizeScreen(browserWindow: BrowserWindow) { + function handle() { + const position = browserWindow.getPosition(); + const size = browserWindow.getSize(); + const validPosition = getValidWindowPosition({ + x: position[0], + y: position[1], + width: size[0], + height: size[1], + }); + if (typeof validPosition.x !== 'undefined' || typeof validPosition.y !== 'undefined') { + browserWindow.setPosition(validPosition.x || 0, validPosition.y || 0); + } else { + browserWindow.center(); + } + } + + browserWindow.on('restore', handle); + handle(); +} + +function flushCookiesStore(session: Session) { + session.cookies.flushStore().catch((err) => { + log.error(`There was a problem flushing cookies:\n${err}`); + }); +} + +export function initCookieManager(session: Session) { + // Somehow cookies are not immediately saved to disk. + // So manually flush cookie store to disk on closing the app. + // https://github.com/electron/electron/issues/8416 + app.on('before-quit', () => { + flushCookiesStore(session); + }); + + app.on('browser-window-blur', () => { + flushCookiesStore(session); + }); +} diff --git a/src/main/authManager.test.js b/src/main/authManager.test.js index 855569c6..6fbd7fd5 100644 --- a/src/main/authManager.test.js +++ b/src/main/authManager.test.js @@ -25,10 +25,27 @@ jest.mock('common/utils/url', () => { }; }); +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(), + }, + ipcMain: { + on: jest.fn(), + }, +})); + jest.mock('electron-log', () => ({ error: jest.fn(), })); +jest.mock('main/trustedOrigins', () => ({ + addPermission: jest.fn(), + checkPermission: (url) => { + return url.toString() === 'http://haspermissionurl.com/'; + }, + save: jest.fn(), +})); + jest.mock('main/windows/windowManager', () => ({ getMainWindow: jest.fn().mockImplementation(() => ({})), })); @@ -90,17 +107,9 @@ const config = { }], }; -const trustedOriginsStore = { - addPermission: jest.fn(), - checkPermission: (url) => { - return url.toString() === 'http://haspermissionurl.com/'; - }, - save: jest.fn(), -}; - describe('main/authManager', () => { describe('handleAppLogin', () => { - const authManager = new AuthManager(config, trustedOriginsStore); + const authManager = new AuthManager(config); authManager.popLoginModal = jest.fn(); authManager.popPermissionModal = jest.fn(); @@ -148,7 +157,7 @@ describe('main/authManager', () => { }); describe('popLoginModal', () => { - const authManager = new AuthManager(config, trustedOriginsStore); + const authManager = new AuthManager(config); it('should not pop modal when no main window exists', () => { WindowManager.getMainWindow.mockImplementationOnce(() => null); @@ -216,7 +225,7 @@ describe('main/authManager', () => { }); describe('popPermissionModal', () => { - const authManager = new AuthManager(config, trustedOriginsStore); + const authManager = new AuthManager(config); it('should not pop modal when no main window exists', () => { WindowManager.getMainWindow.mockImplementationOnce(() => null); @@ -261,7 +270,7 @@ describe('main/authManager', () => { }); describe('handleLoginCredentialsEvent', () => { - const authManager = new AuthManager(config, trustedOriginsStore); + const authManager = new AuthManager(config); const callback = jest.fn(); beforeEach(() => { diff --git a/src/main/authManager.ts b/src/main/authManager.ts index f9a117cd..93057e7c 100644 --- a/src/main/authManager.ts +++ b/src/main/authManager.ts @@ -3,10 +3,10 @@ import {AuthenticationResponseDetails, AuthInfo, WebContents} from 'electron'; import log from 'electron-log'; -import {CombinedConfig} from 'types/config'; import {PermissionType} from 'types/trustedOrigin'; import {LoginModalData} from 'types/auth'; +import Config from 'common/config'; import {BASIC_AUTH_PERMISSION} from 'common/permissions'; import urlUtils from 'common/utils/url'; @@ -25,33 +25,25 @@ type LoginModalResult = { }; export class AuthManager { - config: CombinedConfig; - trustedOriginsStore: TrustedOriginsStore; loginCallbackMap: Map void) | undefined>; - constructor(config: CombinedConfig, trustedOriginsStore: TrustedOriginsStore) { - this.config = config; - this.trustedOriginsStore = trustedOriginsStore; + constructor() { this.loginCallbackMap = new Map(); } - handleConfigUpdate = (newConfig: CombinedConfig) => { - this.config = newConfig; - } - handleAppLogin = (event: Event, webContents: WebContents, request: AuthenticationResponseDetails, authInfo: AuthInfo, callback?: (username?: string, password?: string) => void) => { event.preventDefault(); const parsedURL = urlUtils.parseURL(request.url); if (!parsedURL) { return; } - const server = urlUtils.getView(parsedURL, this.config.teams); + const server = urlUtils.getView(parsedURL, Config.teams); if (!server) { return; } this.loginCallbackMap.set(request.url, callback); // if callback is undefined set it to null instead so we know we have set it up with no value - if (urlUtils.isTrustedURL(request.url, this.config.teams) || urlUtils.isCustomLoginURL(parsedURL, server, this.config.teams) || this.trustedOriginsStore.checkPermission(request.url, BASIC_AUTH_PERMISSION)) { + if (urlUtils.isTrustedURL(request.url, Config.teams) || urlUtils.isCustomLoginURL(parsedURL, server, Config.teams) || TrustedOriginsStore.checkPermission(request.url, BASIC_AUTH_PERMISSION)) { this.popLoginModal(request, authInfo); } else { this.popPermissionModal(request, authInfo, BASIC_AUTH_PERMISSION); @@ -114,7 +106,10 @@ export class AuthManager { } handlePermissionGranted(url: string, permission: PermissionType) { - this.trustedOriginsStore.addPermission(url, permission); - this.trustedOriginsStore.save(); + TrustedOriginsStore.addPermission(url, permission); + TrustedOriginsStore.save(); } } + +const authManager = new AuthManager(); +export default authManager; diff --git a/src/main/certificateManager.ts b/src/main/certificateManager.ts index 3e47c6ac..7fe27cc5 100644 --- a/src/main/certificateManager.ts +++ b/src/main/certificateManager.ts @@ -73,3 +73,6 @@ export class CertificateManager { this.certificateRequestCallbackMap.delete(server); } } + +const certificateManager = new CertificateManager(); +export default certificateManager; diff --git a/src/main/certificateStore.test.js b/src/main/certificateStore.test.js index 81badda5..9c143302 100644 --- a/src/main/certificateStore.test.js +++ b/src/main/certificateStore.test.js @@ -6,13 +6,27 @@ import fs from 'fs'; import {validateCertificateStore} from './Validator'; -import CertificateStore from './certificateStore'; +import {CertificateStore} from './certificateStore'; + +jest.mock('path', () => ({ + resolve: jest.fn(), +})); + +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(), + }, + ipcMain: { + on: jest.fn(), + }, +})); jest.mock('./Validator', () => ({ validateCertificateStore: jest.fn(), })); jest.mock('fs', () => ({ + existsSync: jest.fn(), readFileSync: jest.fn(), })); diff --git a/src/main/certificateStore.ts b/src/main/certificateStore.ts index 271ea700..986904dd 100644 --- a/src/main/certificateStore.ts +++ b/src/main/certificateStore.ts @@ -5,13 +5,15 @@ import fs from 'fs'; -import {Certificate} from 'electron'; +import {Certificate, ipcMain} from 'electron'; import {ComparableCertificate} from 'types/certificate'; +import {UPDATE_PATHS} from 'common/communication'; import urlUtils from 'common/utils/url'; import * as Validator from './Validator'; +import {certificateStorePath} from './constants'; function comparableCertificate(certificate: Certificate, dontTrust = false): ComparableCertificate { return { @@ -31,7 +33,7 @@ function areEqual(certificate0: ComparableCertificate, certificate1: ComparableC return true; } -export default class CertificateStore { +export class CertificateStore { storeFile: string; data: Record; @@ -78,3 +80,10 @@ export default class CertificateStore { return dontTrust === undefined ? false : dontTrust; } } + +let certificateStore = new CertificateStore(certificateStorePath); +export default certificateStore; + +ipcMain.on(UPDATE_PATHS, () => { + certificateStore = new CertificateStore(certificateStorePath); +}); diff --git a/src/main/constants.ts b/src/main/constants.ts new file mode 100644 index 00000000..f59c594d --- /dev/null +++ b/src/main/constants.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* istanbul ignore file */ + +import path from 'path'; + +import {app, ipcMain} from 'electron'; + +import {UPDATE_PATHS} from 'common/communication'; + +let userDataPath; + +export let configPath = ''; +export let allowedProtocolFile = ''; +export let appVersionJson = ''; +export let certificateStorePath = ''; +export let trustedOriginsStoreFile = ''; +export let boundsInfoPath = ''; + +export function updatePaths(emit = false) { + userDataPath = app.getPath('userData'); + + configPath = `${userDataPath}/config.json`; + allowedProtocolFile = path.resolve(userDataPath, 'allowedProtocols.json'); + appVersionJson = path.join(userDataPath, 'app-state.json'); + certificateStorePath = path.resolve(userDataPath, 'certificate.json'); + trustedOriginsStoreFile = path.resolve(userDataPath, 'trustedOrigins.json'); + boundsInfoPath = path.join(userDataPath, 'bounds-info.json'); + + if (emit) { + ipcMain.emit(UPDATE_PATHS); + } +} + +updatePaths(); diff --git a/src/main/cookieManager.ts b/src/main/cookieManager.ts deleted file mode 100644 index 868bc12e..00000000 --- a/src/main/cookieManager.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2015-2016 Yuya Ochiai -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. -import {app, Session} from 'electron'; -import log from 'electron-log'; - -function flushCookiesStore(session: Session) { - session.cookies.flushStore().catch((err) => { - log.error(`There was a problem flushing cookies:\n${err}`); - }); -} - -export default function initCookieManager(session: Session) { - // Somehow cookies are not immediately saved to disk. - // So manually flush cookie store to disk on closing the app. - // https://github.com/electron/electron/issues/8416 - app.on('before-quit', () => { - flushCookiesStore(session); - }); - - app.on('browser-window-blur', () => { - flushCookiesStore(session); - }); -} diff --git a/src/main/main.ts b/src/main/main.ts deleted file mode 100644 index c0f74a6e..00000000 --- a/src/main/main.ts +++ /dev/null @@ -1,983 +0,0 @@ -// Copyright (c) 2015-2016 Yuya Ochiai -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -/* eslint-disable max-lines */ -import fs from 'fs'; - -import path from 'path'; - -import electron, {BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, Rectangle} from 'electron'; -import isDev from 'electron-is-dev'; -import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer'; -import log from 'electron-log'; -import 'airbnb-js-shims/target/es2015'; - -import {CombinedConfig, Team, TeamWithTabs} from 'types/config'; -import {MentionData} from 'types/notification'; -import {RemoteInfo} from 'types/server'; -import {Boundaries} from 'types/utils'; - -import { - SWITCH_SERVER, - FOCUS_BROWSERVIEW, - QUIT, - DARK_MODE_CHANGE, - DOUBLE_CLICK_ON_WINDOW, - SHOW_NEW_SERVER_MODAL, - WINDOW_CLOSE, - WINDOW_MAXIMIZE, - WINDOW_MINIMIZE, - WINDOW_RESTORE, - NOTIFY_MENTION, - GET_DOWNLOAD_LOCATION, - SHOW_SETTINGS_WINDOW, - RELOAD_CONFIGURATION, - USER_ACTIVITY_UPDATE, - EMIT_CONFIGURATION, - SWITCH_TAB, - CLOSE_TAB, - OPEN_TAB, - SHOW_EDIT_SERVER_MODAL, - SHOW_REMOVE_SERVER_MODAL, - UPDATE_SHORTCUT_MENU, - UPDATE_LAST_ACTIVE, - GET_AVAILABLE_SPELL_CHECKER_LANGUAGES, -} from 'common/communication'; -import Config from 'common/config'; -import {MattermostServer} from 'common/servers/MattermostServer'; -import {getDefaultTeamWithTabsFromTeam, TAB_FOCALBOARD, TAB_MESSAGING, TAB_PLAYBOOKS} from 'common/tabs/TabView'; -import Utils from 'common/utils/util'; - -import urlUtils from 'common/utils/url'; - -import {protocols} from '../../electron-builder.json'; - -import AutoLauncher from './AutoLauncher'; -import CriticalErrorHandler from './CriticalErrorHandler'; -import CertificateStore from './certificateStore'; -import TrustedOriginsStore from './trustedOrigins'; -import {createMenu as createAppMenu} from './menus/app'; -import {createMenu as createTrayMenu} from './menus/tray'; -import allowProtocolDialog from './allowProtocolDialog'; -import AppVersionManager from './AppVersionManager'; -import initCookieManager from './cookieManager'; -import UserActivityMonitor from './UserActivityMonitor'; -import WindowManager from './windows/windowManager'; -import {displayMention, displayDownloadCompleted} from './notifications'; - -import parseArgs from './ParseArgs'; -import modalManager from './views/modalManager'; -import {getLocalURLString, getLocalPreload} from './utils'; -import {destroyTray, refreshTrayImages, setTrayMenu, setupTray} from './tray/tray'; -import {AuthManager} from './authManager'; -import {CertificateManager} from './certificateManager'; -import {setupBadge, setUnreadBadgeSetting} from './badge'; -import {ServerInfo} from './server/serverInfo'; - -if (process.env.NODE_ENV !== 'production' && module.hot) { - module.hot.accept(); -} - -// pull out required electron components like this -// as not all components can be referenced before the app is ready -const { - app, - Menu, - ipcMain, - dialog, - session, -} = electron; -const criticalErrorHandler = new CriticalErrorHandler(); -const userActivityMonitor = new UserActivityMonitor(); -const autoLauncher = new AutoLauncher(); -const certificateErrorCallbacks = new Map(); - -// Keep a global reference of the window object, if you don't, the window will -// be closed automatically when the JavaScript object is garbage collected. -let certificateStore: CertificateStore; -let trustedOriginsStore; -let scheme: string; -let appVersion = null; -let config: Config; -let authManager: AuthManager; -let certificateManager: CertificateManager; -let didCheckForAddServerModal = false; - -/** - * Main entry point for the application, ensures that everything initializes in the proper order - */ -async function initialize() { - process.on('uncaughtException', criticalErrorHandler.processUncaughtExceptionHandler.bind(criticalErrorHandler)); - global.willAppQuit = false; - - // initialization that can run before the app is ready - initializeArgs(); - await initializeConfig(); - initializeAppEventListeners(); - initializeBeforeAppReady(); - - // wait for registry config data to load and app ready event - await Promise.all([ - app.whenReady(), - ]); - - // no need to continue initializing if app is quitting - if (global.willAppQuit) { - return; - } - - // initialization that should run once the app is ready - initializeInterCommunicationEventListeners(); - initializeAfterAppReady(); -} - -// attempt to initialize the application -try { - initialize(); -} catch (error) { - throw new Error(`App initialization failed: ${error.toString()}`); -} - -// -// initialization sub functions -// - -function initializeArgs() { - global.args = parseArgs(process.argv.slice(1)); - - // output the application version via cli when requested (-v or --version) - if (global.args.version) { - process.stdout.write(`v.${app.getVersion()}\n`); - process.exit(0); // eslint-disable-line no-process-exit - } - - global.isDev = isDev && !global.args.disableDevMode; // this doesn't seem to be right and isn't used as the single source of truth - - if (global.args.dataDir) { - app.setPath('userData', path.resolve(global.args.dataDir)); - } -} - -async function initializeConfig() { - const loadConfig = new Promise((resolve) => { - config = new Config(app.getPath('userData') + '/config.json'); - config.once('update', (configData) => { - config.on('update', handleConfigUpdate); - config.on('synchronize', handleConfigSynchronize); - config.on('darkModeChange', handleDarkModeChange); - config.on('error', (error) => { - log.error(error); - }); - handleConfigUpdate(configData); - - // can only call this before the app is ready - if (config.enableHardwareAcceleration === false) { - app.disableHardwareAcceleration(); - } - - resolve(); - }); - config.init(); - }); - - return loadConfig; -} - -function initializeAppEventListeners() { - app.on('second-instance', handleAppSecondInstance); - app.on('window-all-closed', handleAppWindowAllClosed); - app.on('browser-window-created', handleAppBrowserWindowCreated); - app.on('activate', handleAppActivate); - app.on('before-quit', handleAppBeforeQuit); - app.on('certificate-error', handleAppCertificateError); - app.on('select-client-certificate', handleSelectCertificate); - app.on('gpu-process-crashed', handleAppGPUProcessCrashed); - app.on('login', handleAppLogin); - app.on('will-finish-launching', handleAppWillFinishLaunching); -} - -function initializeBeforeAppReady() { - if (!config || !config.data) { - log.error('No config loaded'); - return; - } - if (process.env.NODE_ENV !== 'test') { - app.enableSandbox(); - } - certificateStore = new CertificateStore(path.resolve(app.getPath('userData'), 'certificate.json')); - trustedOriginsStore = new TrustedOriginsStore(path.resolve(app.getPath('userData'), 'trustedOrigins.json')); - trustedOriginsStore.load(); - - // prevent using a different working directory, which happens on windows running after installation. - const expectedPath = path.dirname(process.execPath); - if (process.cwd() !== expectedPath && !isDev) { - log.warn(`Current working directory is ${process.cwd()}, changing into ${expectedPath}`); - process.chdir(expectedPath); - } - - refreshTrayImages(config.trayIconTheme); - - // If there is already an instance, quit this one - const gotTheLock = app.requestSingleInstanceLock(); - if (!gotTheLock) { - app.exit(); - global.willAppQuit = true; - } - - allowProtocolDialog.init(); - - authManager = new AuthManager(config.data, trustedOriginsStore); - certificateManager = new CertificateManager(); - - if (isDev && process.env.NODE_ENV !== 'test') { - log.info('In development mode, deeplinking is disabled'); - } else if (protocols && protocols[0] && protocols[0].schemes && protocols[0].schemes[0]) { - scheme = protocols[0].schemes[0]; - app.setAsDefaultProtocolClient(scheme); - } -} - -function initializeInterCommunicationEventListeners() { - ipcMain.on(RELOAD_CONFIGURATION, handleReloadConfig); - ipcMain.on(NOTIFY_MENTION, handleMentionNotification); - ipcMain.handle('get-app-version', handleAppVersion); - ipcMain.on('update-menu', handleUpdateMenuEvent); - ipcMain.on(UPDATE_SHORTCUT_MENU, handleUpdateShortcutMenuEvent); - ipcMain.on(FOCUS_BROWSERVIEW, WindowManager.focusBrowserView); - ipcMain.on(UPDATE_LAST_ACTIVE, handleUpdateLastActive); - - if (process.platform !== 'darwin') { - ipcMain.on('open-app-menu', handleOpenAppMenu); - } - - ipcMain.on(SWITCH_SERVER, handleSwitchServer); - ipcMain.on(SWITCH_TAB, handleSwitchTab); - ipcMain.on(CLOSE_TAB, handleCloseTab); - ipcMain.on(OPEN_TAB, handleOpenTab); - - ipcMain.on(QUIT, handleQuit); - - ipcMain.on(DOUBLE_CLICK_ON_WINDOW, WindowManager.handleDoubleClick); - - ipcMain.on(SHOW_NEW_SERVER_MODAL, handleNewServerModal); - ipcMain.on(SHOW_EDIT_SERVER_MODAL, handleEditServerModal); - ipcMain.on(SHOW_REMOVE_SERVER_MODAL, handleRemoveServerModal); - ipcMain.on(WINDOW_CLOSE, WindowManager.close); - 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.handle(GET_AVAILABLE_SPELL_CHECKER_LANGUAGES, handleGetAvailableSpellCheckerLanguages); - ipcMain.handle(GET_DOWNLOAD_LOCATION, handleSelectDownload); -} - -// -// config event handlers -// - -function handleConfigUpdate(newConfig: CombinedConfig) { - if (!newConfig) { - return; - } - if (process.platform === 'win32' || process.platform === 'linux') { - const autoStartTask = config.autostart ? autoLauncher.enable() : autoLauncher.disable(); - autoStartTask.then(() => { - log.info('config.autostart has been configured:', newConfig.autostart); - }).catch((err) => { - log.error('error:', err); - }); - WindowManager.setConfig(newConfig); - if (authManager) { - authManager.handleConfigUpdate(newConfig); - } - setUnreadBadgeSetting(newConfig && newConfig.showUnreadBadge); - updateSpellCheckerLocales(); - } - - ipcMain.emit('update-menu', true, config); - ipcMain.emit(EMIT_CONFIGURATION, true, newConfig); -} - -function handleConfigSynchronize() { - if (!config.data) { - return; - } - - // TODO: send this to server manager - WindowManager.setConfig(config.data); - setUnreadBadgeSetting(config.data.showUnreadBadge); - if (config.data.downloadLocation) { - try { - app.setPath('downloads', config.data.downloadLocation); - } catch (e) { - log.error(`There was a problem trying to set the default download path: ${e}`); - } - } - if (app.isReady()) { - WindowManager.sendToRenderer(RELOAD_CONFIGURATION); - } - - if (process.platform === 'win32' && !didCheckForAddServerModal && typeof config.registryConfigData !== 'undefined') { - didCheckForAddServerModal = true; - updateServerInfos(config.teams); - WindowManager.initializeCurrentServerName(); - if (config.teams.length === 0) { - handleNewServerModal(); - } - } -} - -function handleReloadConfig() { - config.reload(); - WindowManager.setConfig(config.data!); -} - -function handleAppVersion() { - return { - name: app.getName(), - version: app.getVersion(), - }; -} - -function handleDarkModeChange(darkMode: boolean) { - refreshTrayImages(config.trayIconTheme); - WindowManager.sendToRenderer(DARK_MODE_CHANGE, darkMode); - WindowManager.updateLoadingScreenDarkMode(darkMode); - - ipcMain.emit(EMIT_CONFIGURATION, true, config.data); -} - -// -// app event handlers -// - -// activate first app instance, subsequent instances will quit themselves -function handleAppSecondInstance(event: Event, argv: string[]) { - // Protocol handler for win32 - // argv: An array of the second instance’s (command line / deep linked) arguments - const deeplinkingUrl = getDeeplinkingURL(argv); - WindowManager.showMainWindow(deeplinkingUrl); -} - -function handleAppWindowAllClosed() { - // On OS X it is common for applications and their menu bar - // to stay active until the user quits explicitly with Cmd + Q - if (process.platform !== 'darwin') { - app.quit(); - } -} - -function handleAppBrowserWindowCreated(event: Event, newWindow: BrowserWindow) { - // Screen cannot be required before app is ready - resizeScreen(newWindow); -} - -function handleAppActivate() { - WindowManager.showMainWindow(); -} - -function handleAppBeforeQuit() { - // Make sure tray icon gets removed if the user exits via CTRL-Q - destroyTray(); - global.willAppQuit = true; -} - -function handleQuit(e: IpcMainEvent, reason: string, stack: string) { - log.error(`Exiting App. Reason: ${reason}`); - log.info(`Stacktrace:\n${stack}`); - handleAppBeforeQuit(); - app.quit(); -} - -function handleSelectCertificate(event: electron.Event, webContents: electron.WebContents, url: string, list: electron.Certificate[], callback: (certificate?: electron.Certificate | undefined) => void) { - certificateManager.handleSelectCertificate(event, webContents, url, list, callback); -} - -function handleAppCertificateError(event: electron.Event, webContents: electron.WebContents, url: string, error: string, certificate: electron.Certificate, callback: (isTrusted: boolean) => void) { - const parsedURL = new URL(url); - if (!parsedURL) { - return; - } - const origin = parsedURL.origin; - if (certificateStore.isExplicitlyUntrusted(origin)) { - event.preventDefault(); - log.warn(`Ignoring previously untrusted certificate for ${origin}`); - callback(false); - } else if (certificateStore.isTrusted(origin, certificate)) { - event.preventDefault(); - callback(true); - } else { - // update the callback - const errorID = `${origin}:${error}`; - - // if we are already showing that error, don't add more dialogs - if (certificateErrorCallbacks.has(errorID)) { - log.warn(`Ignoring already shown dialog for ${errorID}`); - certificateErrorCallbacks.set(errorID, callback); - return; - } - const extraDetail = certificateStore.isExisting(origin) ? 'Certificate is different from previous one.\n\n' : ''; - const detail = `${extraDetail}origin: ${origin}\nError: ${error}`; - - certificateErrorCallbacks.set(errorID, callback); - - // TODO: should we move this to window manager or provide a handler for dialogs? - const mainWindow = WindowManager.getMainWindow(); - if (!mainWindow) { - return; - } - dialog.showMessageBox(mainWindow, { - title: 'Certificate Error', - message: 'There is a configuration issue with this Mattermost server, or someone is trying to intercept your connection. You also may need to sign into the Wi-Fi you are connected to using your web browser.', - type: 'error', - detail, - buttons: ['More Details', 'Cancel Connection'], - cancelId: 1, - }).then( - ({response}) => { - if (response === 0) { - return dialog.showMessageBox(mainWindow, { - title: 'Certificate Not Trusted', - message: `Certificate from "${certificate.issuerName}" is not trusted.`, - detail: extraDetail, - type: 'error', - buttons: ['Trust Insecure Certificate', 'Cancel Connection'], - cancelId: 1, - checkboxChecked: false, - checkboxLabel: "Don't ask again", - }); - } - return {response, checkboxChecked: false}; - }).then( - ({response: responseTwo, checkboxChecked}) => { - if (responseTwo === 0) { - certificateStore.add(origin, certificate); - certificateStore.save(); - certificateErrorCallbacks.get(errorID)(true); - certificateErrorCallbacks.delete(errorID); - webContents.loadURL(url); - } else { - if (checkboxChecked) { - certificateStore.add(origin, certificate, true); - certificateStore.save(); - } - certificateErrorCallbacks.get(errorID)(false); - certificateErrorCallbacks.delete(errorID); - } - }).catch( - (dialogError) => { - log.error(`There was an error with the Certificate Error dialog: ${dialogError}`); - certificateErrorCallbacks.delete(errorID); - }); - } -} - -function handleAppLogin(event: electron.Event, webContents: electron.WebContents, request: electron.AuthenticationResponseDetails, authInfo: electron.AuthInfo, callback: (username?: string | undefined, password?: string | undefined) => void) { - authManager.handleAppLogin(event, webContents, request, authInfo, callback); -} - -function handleAppGPUProcessCrashed(event: electron.Event, killed: boolean) { - log.error(`The GPU process has crashed (killed = ${killed})`); -} - -function openDeepLink(deeplinkingUrl: string) { - try { - WindowManager.showMainWindow(deeplinkingUrl); - } catch (err) { - log.error(`There was an error opening the deeplinking url: ${err}`); - } -} - -function handleAppWillFinishLaunching() { - // Protocol handler for osx - app.on('open-url', (event, url) => { - log.info(`Handling deeplinking url: ${url}`); - event.preventDefault(); - const deeplinkingUrl = getDeeplinkingURL([url]); - if (deeplinkingUrl) { - if (app.isReady() && deeplinkingUrl) { - openDeepLink(deeplinkingUrl); - } else { - app.once('ready', () => openDeepLink(deeplinkingUrl)); - } - } - }); -} - -function handleSwitchServer(event: IpcMainEvent, serverName: string) { - WindowManager.switchServer(serverName); -} - -function handleSwitchTab(event: IpcMainEvent, serverName: string, tabName: string) { - WindowManager.switchTab(serverName, tabName); -} - -function handleCloseTab(event: IpcMainEvent, serverName: string, tabName: string) { - const teams = config.teams; - teams.forEach((team) => { - if (team.name === serverName) { - team.tabs.forEach((tab) => { - if (tab.name === tabName) { - tab.isOpen = false; - } - }); - } - }); - const nextTab = teams.find((team) => team.name === serverName)!.tabs.filter((tab) => tab.isOpen)[0].name; - WindowManager.switchTab(serverName, nextTab); - config.set('teams', teams); -} - -function handleOpenTab(event: IpcMainEvent, serverName: string, tabName: string) { - const teams = config.teams; - teams.forEach((team) => { - if (team.name === serverName) { - team.tabs.forEach((tab) => { - if (tab.name === tabName) { - tab.isOpen = true; - } - }); - } - }); - WindowManager.switchTab(serverName, tabName); - config.set('teams', teams); -} - -function handleNewServerModal() { - const html = getLocalURLString('newServer.html'); - - const modalPreload = getLocalPreload('modalPreload.js'); - - const mainWindow = WindowManager.getMainWindow(); - if (!mainWindow) { - return; - } - const modalPromise = modalManager.addModal('newServer', html, modalPreload, {}, mainWindow, config.teams.length === 0); - if (modalPromise) { - modalPromise.then((data) => { - const teams = config.teams; - const order = teams.length; - const newTeam = getDefaultTeamWithTabsFromTeam({...data, order}); - teams.push(newTeam); - config.set('teams', teams); - updateServerInfos([newTeam]); - WindowManager.switchServer(newTeam.name, true); - }).catch((e) => { - // e is undefined for user cancellation - if (e) { - log.error(`there was an error in the new server modal: ${e}`); - } - }); - } else { - log.warn('There is already a new server modal'); - } -} - -function handleEditServerModal(e: IpcMainEvent, name: string) { - const html = getLocalURLString('editServer.html'); - - const modalPreload = getLocalPreload('modalPreload.js'); - - const mainWindow = WindowManager.getMainWindow(); - if (!mainWindow) { - return; - } - const serverIndex = config.teams.findIndex((team) => team.name === name); - if (serverIndex < 0) { - return; - } - const modalPromise = modalManager.addModal('editServer', html, modalPreload, config.teams[serverIndex], mainWindow); - if (modalPromise) { - modalPromise.then((data) => { - const teams = config.teams; - teams[serverIndex].name = data.name; - teams[serverIndex].url = data.url; - config.set('teams', teams); - }).catch((e) => { - // e is undefined for user cancellation - if (e) { - log.error(`there was an error in the edit server modal: ${e}`); - } - }); - } else { - log.warn('There is already an edit server modal'); - } -} - -function handleRemoveServerModal(e: IpcMainEvent, name: string) { - const html = getLocalURLString('removeServer.html'); - - const modalPreload = getLocalPreload('modalPreload.js'); - - const mainWindow = WindowManager.getMainWindow(); - if (!mainWindow) { - return; - } - const modalPromise = modalManager.addModal('removeServer', html, modalPreload, name, mainWindow); - if (modalPromise) { - modalPromise.then((remove) => { - if (remove) { - const teams = config.teams; - const removedTeam = teams.findIndex((team) => team.name === name); - if (removedTeam < 0) { - return; - } - const removedOrder = teams[removedTeam].order; - teams.splice(removedTeam, 1); - teams.forEach((value) => { - if (value.order > removedOrder) { - value.order--; - } - }); - config.set('teams', teams); - } - }).catch((e) => { - // e is undefined for user cancellation - if (e) { - log.error(`there was an error in the edit server modal: ${e}`); - } - }); - } else { - log.warn('There is already an edit server modal'); - } -} - -function updateSpellCheckerLocales() { - if (config.data?.spellCheckerLocales.length && app.isReady()) { - session.defaultSession.setSpellCheckerLanguages(config.data?.spellCheckerLocales); - } -} - -function initializeAfterAppReady() { - updateServerInfos(config.teams); - app.setAppUserModelId('Mattermost.Desktop'); // Use explicit AppUserModelID - const defaultSession = session.defaultSession; - - if (process.platform !== 'darwin') { - defaultSession.on('spellcheck-dictionary-download-failure', (event, lang) => { - if (config.spellCheckerURL) { - log.error(`There was an error while trying to load the dictionary definitions for ${lang} fromfully the specified url. Please review you have access to the needed files. Url used was ${config.spellCheckerURL}`); - } else { - log.warn(`There was an error while trying to download the dictionary definitions for ${lang}, spellchecking might not work properly.`); - } - }); - - if (config.spellCheckerURL) { - const spellCheckerURL = config.spellCheckerURL.endsWith('/') ? config.spellCheckerURL : `${config.spellCheckerURL}/`; - log.info(`Configuring spellchecker using download URL: ${spellCheckerURL}`); - defaultSession.setSpellCheckerDictionaryDownloadURL(spellCheckerURL); - - defaultSession.on('spellcheck-dictionary-download-success', (event, lang) => { - log.info(`Dictionary definitions downloaded successfully for ${lang}`); - }); - } - updateSpellCheckerLocales(); - } - - const appVersionJson = path.join(app.getPath('userData'), 'app-state.json'); - appVersion = new AppVersionManager(appVersionJson); - if (wasUpdated(appVersion.lastAppVersion)) { - clearAppCache(); - } - appVersion.lastAppVersion = app.getVersion(); - - if (!global.isDev) { - autoLauncher.upgradeAutoLaunch(); - } - - if (global.isDev) { - installExtension(REACT_DEVELOPER_TOOLS). - then((name) => log.info(`Added Extension: ${name}`)). - catch((err) => log.error('An error occurred: ', err)); - } - - // Workaround for MM-22193 - // From this post: https://github.com/electron/electron/issues/19468#issuecomment-549593139 - // Electron 6 has a bug that affects users on Windows 10 using dark mode, causing the app to hang - // This workaround deletes a file that stops that from happening - if (process.platform === 'win32') { - const appUserDataPath = app.getPath('userData'); - const devToolsExtensionsPath = path.join(appUserDataPath, 'DevTools Extensions'); - try { - fs.unlinkSync(devToolsExtensionsPath); - } catch (_) { - // don't complain if the file doesn't exist - } - } - - let deeplinkingURL; - - // Protocol handler for win32 - if (process.platform === 'win32') { - const args = process.argv.slice(1); - if (Array.isArray(args) && args.length > 0) { - deeplinkingURL = getDeeplinkingURL(args); - } - } - - initCookieManager(defaultSession); - - WindowManager.showMainWindow(deeplinkingURL); - - criticalErrorHandler.setMainWindow(WindowManager.getMainWindow()!); - - // listen for status updates and pass on to renderer - userActivityMonitor.on('status', (status) => { - WindowManager.sendToMattermostViews(USER_ACTIVITY_UPDATE, status); - }); - - // start monitoring user activity (needs to be started after the app is ready) - userActivityMonitor.startMonitoring(); - - if (shouldShowTrayIcon()) { - setupTray(config.trayIconTheme); - } - setupBadge(); - - defaultSession.on('will-download', (event, item, webContents) => { - const filename = item.getFilename(); - const fileElements = filename.split('.'); - const filters = []; - if (fileElements.length > 1) { - filters.push({ - name: 'All files', - extensions: ['*'], - }); - } - item.setSaveDialogOptions({ - title: filename, - defaultPath: path.resolve(config.downloadLocation, filename), - filters, - }); - - item.on('done', (doneEvent, state) => { - if (state === 'completed') { - displayDownloadCompleted(filename, item.savePath, WindowManager.getServerNameByWebContentsId(webContents.id) || ''); - } - }); - }); - - ipcMain.emit('update-menu', true, config); - - ipcMain.emit('update-dict'); - - // supported permission types - const supportedPermissionTypes = [ - 'media', - 'geolocation', - 'notifications', - 'fullscreen', - 'openExternal', - ]; - - // handle permission requests - // - approve if a supported permission type and the request comes from the renderer or one of the defined servers - defaultSession.setPermissionRequestHandler((webContents, permission, callback) => { - // is the requested permission type supported? - if (!supportedPermissionTypes.includes(permission)) { - callback(false); - return; - } - - // is the request coming from the renderer? - const mainWindow = WindowManager.getMainWindow(); - if (mainWindow && webContents.id === mainWindow.webContents.id) { - callback(true); - return; - } - - const requestingURL = webContents.getURL(); - - // is the requesting url trusted? - callback(urlUtils.isTrustedURL(requestingURL, config.teams)); - }); - - // only check for non-Windows, as with Windows we have to wait for GPO teams - if (process.platform !== 'win32' || typeof config.registryConfigData !== 'undefined') { - if (config.teams.length === 0) { - setTimeout(() => { - handleNewServerModal(); - }, 200); - } - } -} - -// -// ipc communication event handlers -// - -function handleMentionNotification(event: IpcMainEvent, title: string, body: string, channel: {id: string}, teamId: string, url: string, silent: boolean, data: MentionData) { - displayMention(title, body, channel, teamId, url, silent, event.sender, data); -} - -function updateServerInfos(teams: TeamWithTabs[]) { - const serverInfos: Array> = []; - teams.forEach((team) => { - const serverInfo = new ServerInfo(new MattermostServer(team.name, team.url)); - serverInfos.push(serverInfo.promise); - }); - Promise.all(serverInfos).then((data: Array) => { - const teams = config.teams; - teams.forEach((team) => openExtraTabs(data, team)); - config.set('teams', teams); - }).catch((reason: any) => { - log.error('Error getting server infos', reason); - }); -} - -function openExtraTabs(data: Array, team: TeamWithTabs) { - const remoteInfo = data.find((info) => info && typeof info !== 'string' && info.name === team.name) as RemoteInfo; - if (remoteInfo) { - team.tabs.forEach((tab) => { - if (tab.name !== TAB_MESSAGING && remoteInfo.serverVersion && Utils.isVersionGreaterThanOrEqualTo(remoteInfo.serverVersion, '6.0.0')) { - if (tab.name === TAB_PLAYBOOKS && remoteInfo.hasPlaybooks && tab.isOpen !== false) { - log.info(`opening ${team.name}___${tab.name} on hasPlaybooks`); - tab.isOpen = true; - } - if (tab.name === TAB_FOCALBOARD && remoteInfo.hasFocalboard && tab.isOpen !== false) { - log.info(`opening ${team.name}___${tab.name} on hasFocalboard`); - tab.isOpen = true; - } - } - }); - } -} - -function handleOpenAppMenu() { - const windowMenu = Menu.getApplicationMenu(); - if (!windowMenu) { - log.error('No application menu found'); - return; - } - windowMenu.popup({ - window: WindowManager.getMainWindow(), - x: 18, - y: 18, - }); -} - -function handleCloseAppMenu() { - WindowManager.focusBrowserView(); -} - -function handleUpdateMenuEvent(event: IpcMainEvent, menuConfig: Config) { - const aMenu = createAppMenu(menuConfig); - Menu.setApplicationMenu(aMenu); - aMenu.addListener('menu-will-close', handleCloseAppMenu); - - // set up context menu for tray icon - if (shouldShowTrayIcon()) { - const tMenu = createTrayMenu(menuConfig.data!); - setTrayMenu(tMenu); - } -} - -function handleUpdateShortcutMenuEvent(event: IpcMainEvent) { - handleUpdateMenuEvent(event, config); -} - -async function handleSelectDownload(event: IpcMainInvokeEvent, startFrom: string) { - const message = 'Specify the folder where files will download'; - const result = await dialog.showOpenDialog({defaultPath: startFrom || config.downloadLocation, - message, - properties: - ['openDirectory', 'createDirectory', 'dontAddToRecent', 'promptToCreate']}); - return result.filePaths[0]; -} - -// -// helper functions -// - -function getDeeplinkingURL(args: string[]) { - if (Array.isArray(args) && args.length) { - // deeplink urls should always be the last argument, but may not be the first (i.e. Windows with the app already running) - const url = args[args.length - 1]; - if (url && scheme && url.startsWith(scheme) && urlUtils.isValidURI(url)) { - return url; - } - } - return undefined; -} - -function shouldShowTrayIcon() { - return config.showTrayIcon || process.platform === 'win32'; -} - -function wasUpdated(lastAppVersion?: string) { - return lastAppVersion !== app.getVersion(); -} - -function clearAppCache() { - // TODO: clear cache on browserviews, not in the renderer. - const mainWindow = WindowManager.getMainWindow(); - if (mainWindow) { - mainWindow.webContents.session.clearCache().then(mainWindow.reload); - } else { - //Wait for mainWindow - setTimeout(clearAppCache, 100); - } -} - -function isWithinDisplay(state: Rectangle, display: Boundaries) { - const startsWithinDisplay = !(state.x > display.maxX || state.y > display.maxY || state.x < display.minX || state.y < display.minY); - if (!startsWithinDisplay) { - return false; - } - - // is half the screen within the display? - const midX = state.x + (state.width / 2); - const midY = state.y + (state.height / 2); - return !(midX > display.maxX || midY > display.maxY); -} - -function getValidWindowPosition(state: Rectangle) { - // Check if the previous position is out of the viewable area - // (e.g. because the screen has been plugged off) - const boundaries = Utils.getDisplayBoundaries(); - const display = boundaries.find((boundary) => { - return isWithinDisplay(state, boundary); - }); - - if (typeof display === 'undefined') { - return {}; - } - return {x: state.x, y: state.y}; -} - -function resizeScreen(browserWindow: BrowserWindow) { - function handle() { - const position = browserWindow.getPosition(); - const size = browserWindow.getSize(); - const validPosition = getValidWindowPosition({ - x: position[0], - y: position[1], - width: size[0], - height: size[1], - }); - if (typeof validPosition.x !== 'undefined' || typeof validPosition.y !== 'undefined') { - browserWindow.setPosition(validPosition.x || 0, validPosition.y || 0); - } else { - browserWindow.center(); - } - } - - browserWindow.on('restore', handle); - handle(); -} -function handleUpdateLastActive(event: IpcMainEvent, serverName: string, viewName: string) { - const teams = config.teams; - teams.forEach((team) => { - if (team.name === serverName) { - const viewOrder = team?.tabs.find((tab) => tab.name === viewName)?.order || 0; - team.lastActiveTab = viewOrder; - } - }); - config.set('teams', teams); - config.set('lastActiveTeam', teams.find((team) => team.name === serverName)?.order || 0); -} - -function handleGetAvailableSpellCheckerLanguages() { - return session.defaultSession.availableSpellCheckerLanguages; -} diff --git a/src/main/menus/app.ts b/src/main/menus/app.ts index 9c1c70a0..1b337c9e 100644 --- a/src/main/menus/app.ts +++ b/src/main/menus/app.ts @@ -6,7 +6,7 @@ import {app, ipcMain, Menu, MenuItemConstructorOptions, MenuItem, session, shell, WebContents, webContents} from 'electron'; import {SHOW_NEW_SERVER_MODAL} from 'common/communication'; -import Config from 'common/config'; +import {Config} from 'common/config'; import {TabType, getTabDisplayName} from 'common/tabs/TabView'; import WindowManager from 'main/windows/windowManager'; diff --git a/src/main/menus/tray.ts b/src/main/menus/tray.ts index cf27a2d5..e4445791 100644 --- a/src/main/menus/tray.ts +++ b/src/main/menus/tray.ts @@ -6,7 +6,7 @@ import {Menu, MenuItem, MenuItemConstructorOptions} from 'electron'; import {CombinedConfig} from 'types/config'; -import WindowManager from '../windows/windowManager'; +import WindowManager from 'main/windows/windowManager'; export function createTemplate(config: CombinedConfig) { const teams = config.teams; diff --git a/src/main/tray/tray.ts b/src/main/tray/tray.ts index 704011cf..914766d9 100644 --- a/src/main/tray/tray.ts +++ b/src/main/tray/tray.ts @@ -16,6 +16,7 @@ let trayIcon: Tray; let lastStatus = 'normal'; let lastMessage = app.name; +/* istanbul ignore next */ export function refreshTrayImages(trayIconTheme: string) { const winTheme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'; diff --git a/src/main/trustedOrigins.test.js b/src/main/trustedOrigins.test.js index 0455c501..cdd3bb73 100644 --- a/src/main/trustedOrigins.test.js +++ b/src/main/trustedOrigins.test.js @@ -2,9 +2,22 @@ // See LICENSE.txt for license information. 'use strict'; -import TrustedOriginsStore from 'main/trustedOrigins'; +import {TrustedOriginsStore} from 'main/trustedOrigins'; import {BASIC_AUTH_PERMISSION} from 'common/permissions'; +jest.mock('path', () => ({ + resolve: jest.fn(), +})); + +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(), + }, + ipcMain: { + on: jest.fn(), + }, +})); + jest.mock('electron-log', () => ({ error: jest.fn(), })); diff --git a/src/main/trustedOrigins.ts b/src/main/trustedOrigins.ts index c098ac4a..376e8b4f 100644 --- a/src/main/trustedOrigins.ts +++ b/src/main/trustedOrigins.ts @@ -5,14 +5,18 @@ import fs from 'fs'; +import {ipcMain} from 'electron'; import log from 'electron-log'; import {TrustedOrigin, PermissionType} from 'types/trustedOrigin'; +import {UPDATE_PATHS} from 'common/communication'; import urlUtils from 'common/utils/url'; import * as Validator from './Validator'; -export default class TrustedOriginsStore { +import {trustedOriginsStoreFile} from './constants'; + +export class TrustedOriginsStore { storeFile: string; data?: Map; @@ -108,3 +112,13 @@ export default class TrustedOriginsStore { return urlPermissions ? urlPermissions[permission] : undefined; } } + +const trustedOriginsStore = new TrustedOriginsStore(trustedOriginsStoreFile); +export default trustedOriginsStore; + +ipcMain.on(UPDATE_PATHS, () => { + trustedOriginsStore.storeFile = trustedOriginsStoreFile; + if (trustedOriginsStore.data) { + trustedOriginsStore.load(); + } +}); diff --git a/src/main/views/viewManager.test.js b/src/main/views/viewManager.test.js index 91f91852..f17ebb59 100644 --- a/src/main/views/viewManager.test.js +++ b/src/main/views/viewManager.test.js @@ -20,6 +20,7 @@ jest.mock('electron', () => ({ }, ipcMain: { emit: jest.fn(), + on: jest.fn(), }, })); @@ -63,7 +64,7 @@ jest.mock('./webContentEvents', () => ({})); describe('main/views/viewManager', () => { describe('loadView', () => { - const viewManager = new ViewManager({}, {}); + const viewManager = new ViewManager({}); const onceFn = jest.fn(); const loadFn = jest.fn(); @@ -108,7 +109,7 @@ describe('main/views/viewManager', () => { }); describe('reloadViewIfNeeded', () => { - const viewManager = new ViewManager({}, {}); + const viewManager = new ViewManager({}); afterEach(() => { jest.resetAllMocks(); @@ -168,7 +169,7 @@ describe('main/views/viewManager', () => { }); describe('reloadConfiguration', () => { - const viewManager = new ViewManager({}, {}); + const viewManager = new ViewManager({}); beforeEach(() => { viewManager.loadView = jest.fn(); @@ -379,7 +380,8 @@ describe('main/views/viewManager', () => { }, ], }]; - const viewManager = new ViewManager({teams}, {}); + const viewManager = new ViewManager({}); + viewManager.configServers = teams.concat(); beforeEach(() => { viewManager.showByName = jest.fn(); @@ -499,7 +501,7 @@ describe('main/views/viewManager', () => { }); describe('showByName', () => { - const viewManager = new ViewManager({}, {}); + const viewManager = new ViewManager({}); const baseView = { isReady: jest.fn(), show: jest.fn(), @@ -591,7 +593,7 @@ describe('main/views/viewManager', () => { setTopBrowserView: jest.fn(), addBrowserView: jest.fn(), }; - const viewManager = new ViewManager({}, window); + const viewManager = new ViewManager(window); const loadingScreen = {webContents: {send: jest.fn()}}; beforeEach(() => { @@ -623,7 +625,7 @@ describe('main/views/viewManager', () => { }); describe('handleDeepLink', () => { - const viewManager = new ViewManager({}, {}); + const viewManager = new ViewManager({}); const baseView = { resetLoadingStatus: jest.fn(), load: jest.fn(), diff --git a/src/main/views/viewManager.ts b/src/main/views/viewManager.ts index 80c3e228..7588364b 100644 --- a/src/main/views/viewManager.ts +++ b/src/main/views/viewManager.ts @@ -4,7 +4,7 @@ import log from 'electron-log'; import {BrowserView, BrowserWindow, dialog, ipcMain} from 'electron'; import {BrowserViewConstructorOptions} from 'electron/main'; -import {CombinedConfig, Tab, TeamWithTabs} from 'types/config'; +import {Tab, TeamWithTabs} from 'types/config'; import {SECOND} from 'common/utils/constants'; import { @@ -19,6 +19,7 @@ import { BROWSER_HISTORY_PUSH, UPDATE_LAST_ACTIVE, } from 'common/communication'; +import Config from 'common/config'; import urlUtils from 'common/utils/url'; import Utils from 'common/utils/util'; import {MattermostServer} from 'common/servers/MattermostServer'; @@ -47,10 +48,10 @@ export class ViewManager { mainWindow: BrowserWindow; loadingScreen?: BrowserView; - constructor(config: CombinedConfig, mainWindow: BrowserWindow) { - this.configServers = config.teams; - this.lastActiveServer = config.lastActiveTeam; - this.viewOptions = {webPreferences: {spellcheck: config.useSpellChecker}}; + constructor(mainWindow: BrowserWindow) { + this.configServers = Config.teams.concat(); + 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(); diff --git a/src/main/windows/mainWindow.test.js b/src/main/windows/mainWindow.test.js index 90e32846..878af08e 100644 --- a/src/main/windows/mainWindow.test.js +++ b/src/main/windows/mainWindow.test.js @@ -8,6 +8,7 @@ import path from 'path'; import {BrowserWindow, screen, app, globalShortcut} 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'; @@ -37,6 +38,11 @@ jest.mock('electron', () => ({ }, })); +jest.mock('common/config', () => ({})); +jest.mock('common/utils/util', () => ({ + isVersionGreaterThanOrEqualTo: jest.fn(), +})); + jest.mock('electron-log', () => ({})); jest.mock('global', () => ({ @@ -101,7 +107,7 @@ describe('main/windows/mainWindow', () => { }); it('should set window size using bounds read from file', () => { - createMainWindow({}, {}); + createMainWindow({}); expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ x: 400, y: 300, @@ -114,7 +120,7 @@ describe('main/windows/mainWindow', () => { it('should set default window size when failing to read bounds from file', () => { fs.readFileSync.mockImplementation(() => 'just a bunch of garbage'); - createMainWindow({}, {}); + createMainWindow({}); expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT, @@ -124,7 +130,7 @@ 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({}, {}); + createMainWindow({}); expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT, @@ -136,7 +142,7 @@ describe('main/windows/mainWindow', () => { Object.defineProperty(process, 'platform', { value: 'linux', }); - createMainWindow({}, {linuxAppIcon: 'linux-icon.png'}); + createMainWindow({linuxAppIcon: 'linux-icon.png'}); Object.defineProperty(process, 'platform', { value: originalPlatform, }); @@ -156,7 +162,7 @@ describe('main/windows/mainWindow', () => { }; BrowserWindow.mockImplementation(() => window); fs.readFileSync.mockImplementation(() => '{"x":400,"y":300,"width":1280,"height":700,"maximized":true,"fullscreen":false}'); - createMainWindow({}, {}); + createMainWindow({}); expect(window.webContents.zoomLevel).toStrictEqual(0); expect(window.maximize).toBeCalled(); }); @@ -191,7 +197,7 @@ describe('main/windows/mainWindow', () => { }), }; BrowserWindow.mockImplementation(() => window); - createMainWindow({}, {}); + createMainWindow({}); Object.defineProperty(process, 'platform', { value: originalPlatform, }); @@ -212,7 +218,9 @@ describe('main/windows/mainWindow', () => { }), }; BrowserWindow.mockImplementation(() => window); - createMainWindow({minimizeToTray: true}, {}); + Config.minimizeToTray = true; + createMainWindow({}); + Config.minimizeToTray = false; Object.defineProperty(process, 'platform', { value: originalPlatform, }); @@ -233,7 +241,7 @@ describe('main/windows/mainWindow', () => { }), }; BrowserWindow.mockImplementation(() => window); - createMainWindow({}, {}); + createMainWindow({}); Object.defineProperty(process, 'platform', { value: originalPlatform, }); @@ -254,7 +262,7 @@ describe('main/windows/mainWindow', () => { }), }; BrowserWindow.mockImplementation(() => window); - createMainWindow({}, {}); + createMainWindow({}); Object.defineProperty(process, 'platform', { value: originalPlatform, }); @@ -282,7 +290,7 @@ describe('main/windows/mainWindow', () => { }), }; BrowserWindow.mockImplementation(() => window); - createMainWindow({}, {}); + createMainWindow({}); Object.defineProperty(process, 'platform', { value: originalPlatform, }); @@ -309,7 +317,7 @@ describe('main/windows/mainWindow', () => { }, }; BrowserWindow.mockImplementation(() => window); - createMainWindow({}, {}); + createMainWindow({}); Object.defineProperty(process, 'platform', { value: originalPlatform, }); @@ -331,7 +339,7 @@ describe('main/windows/mainWindow', () => { }), }; BrowserWindow.mockImplementation(() => window); - createMainWindow({}, {}); + createMainWindow({}); Object.defineProperty(process, 'platform', { value: originalPlatform, }); diff --git a/src/main/windows/mainWindow.ts b/src/main/windows/mainWindow.ts index 938a6ba7..1643bbe2 100644 --- a/src/main/windows/mainWindow.ts +++ b/src/main/windows/mainWindow.ts @@ -3,19 +3,20 @@ import fs from 'fs'; -import path from 'path'; import os from 'os'; import {app, BrowserWindow, BrowserWindowConstructorOptions, globalShortcut, ipcMain, screen} from 'electron'; import log from 'electron-log'; -import {CombinedConfig} from 'types/config'; import {SavedWindowState} from 'types/mainWindow'; import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB, GET_FULL_SCREEN_STATUS, OPEN_TEAMS_DROPDOWN} 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'; +import {boundsInfoPath} from 'main/constants'; + import * as Validator from '../Validator'; import ContextMenu from '../contextMenu'; import {getLocalPreload, getLocalURLString} from '../utils'; @@ -42,10 +43,9 @@ function isFramelessWindow() { return os.platform() === 'darwin' || (os.platform() === 'win32' && Utils.isVersionGreaterThanOrEqualTo(os.release(), '6.2')); } -function createMainWindow(config: CombinedConfig, options: {linuxAppIcon: string}) { +function createMainWindow(options: {linuxAppIcon: string}) { // Create the browser window. const preload = getLocalPreload('mainWindow.js'); - const boundsInfoPath = path.join(app.getPath('userData'), 'bounds-info.json'); let savedWindowState; try { savedWindowState = JSON.parse(fs.readFileSync(boundsInfoPath, 'utf-8')); @@ -64,7 +64,7 @@ function createMainWindow(config: CombinedConfig, options: {linuxAppIcon: string const {maximized: windowIsMaximized} = savedWindowState; - const spellcheck = (typeof config.useSpellChecker === 'undefined' ? true : config.useSpellChecker); + const spellcheck = (typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker); const windowOptions: BrowserWindowConstructorOptions = Object.assign({}, savedWindowState, { title: app.name, @@ -144,7 +144,7 @@ function createMainWindow(config: CombinedConfig, options: {linuxAppIcon: string hideWindow(mainWindow); break; case 'linux': - if (config.minimizeToTray) { + if (Config.minimizeToTray) { hideWindow(mainWindow); } else { mainWindow.minimize(); diff --git a/src/main/windows/settingsWindow.ts b/src/main/windows/settingsWindow.ts index e81db2d9..4eab8a79 100644 --- a/src/main/windows/settingsWindow.ts +++ b/src/main/windows/settingsWindow.ts @@ -3,14 +3,15 @@ import {BrowserWindow} from 'electron'; import log from 'electron-log'; -import {CombinedConfig} from 'types/config'; + +import Config from 'common/config'; import ContextMenu from '../contextMenu'; import {getLocalPreload, getLocalURLString} from '../utils'; -export function createSettingsWindow(mainWindow: BrowserWindow, config: CombinedConfig, withDevTools: boolean) { +export function createSettingsWindow(mainWindow: BrowserWindow, withDevTools: boolean) { const preload = getLocalPreload('mainWindow.js'); - const spellcheck = (typeof config.useSpellChecker === 'undefined' ? true : config.useSpellChecker); + const spellcheck = (typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker); const settingsWindow = new BrowserWindow({ parent: mainWindow, title: 'Desktop App Settings', diff --git a/src/main/windows/windowManager.test.js b/src/main/windows/windowManager.test.js index a41fd97d..51c5e66b 100644 --- a/src/main/windows/windowManager.test.js +++ b/src/main/windows/windowManager.test.js @@ -6,6 +6,7 @@ import {app, systemPreferences} from 'electron'; +import Config from 'common/config'; import {getTabViewName, TAB_MESSAGING} from 'common/tabs/TabView'; import urlUtils from 'common/utils/url'; @@ -44,6 +45,8 @@ jest.mock('electron-log', () => ({ info: jest.fn(), })); +jest.mock('common/config', () => ({})); + jest.mock('common/utils/url', () => ({ isTeamUrl: jest.fn(), isAdminUrl: jest.fn(), @@ -67,7 +70,7 @@ jest.mock('./settingsWindow', () => ({ jest.mock('./mainWindow', () => jest.fn()); describe('main/windows/windowManager', () => { - describe('setConfig', () => { + describe('handleUpdateConfig', () => { const windowManager = new WindowManager(); beforeEach(() => { @@ -76,15 +79,14 @@ describe('main/windows/windowManager', () => { }; }); - it('should reload config on set', () => { - windowManager.setConfig({some: 'config item'}); + it('should reload config', () => { + windowManager.handleUpdateConfig(); expect(windowManager.viewManager.reloadConfiguration).toHaveBeenCalled(); }); }); describe('showSettingsWindow', () => { const windowManager = new WindowManager(); - windowManager.config = {}; windowManager.showMainWindow = jest.fn(); afterEach(() => { @@ -119,7 +121,6 @@ describe('main/windows/windowManager', () => { describe('showMainWindow', () => { const windowManager = new WindowManager(); - windowManager.config = {}; windowManager.viewManager = { handleDeepLink: jest.fn(), updateMainWindow: jest.fn(), @@ -327,9 +328,13 @@ describe('main/windows/windowManager', () => { flashFrame: jest.fn(), }; + beforeEach(() => { + Config.notifications = {}; + }); + afterEach(() => { jest.resetAllMocks(); - delete windowManager.config; + Config.notifications = {}; }); it('linux/windows - should not flash frame when config item is not set', () => { @@ -346,10 +351,8 @@ describe('main/windows/windowManager', () => { }); it('linux/windows - should flash frame when config item is set', () => { - windowManager.config = { - notifications: { - flashWindow: true, - }, + Config.notifications = { + flashWindow: true, }; const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { @@ -376,11 +379,9 @@ describe('main/windows/windowManager', () => { }); it('mac - should bounce icon when config item is set', () => { - windowManager.config = { - notifications: { - bounceIcon: true, - bounceIconType: 'critical', - }, + Config.notifications = { + bounceIcon: true, + bounceIconType: 'critical', }; const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { @@ -469,8 +470,15 @@ describe('main/windows/windowManager', () => { describe('switchServer', () => { const windowManager = new WindowManager(); - windowManager.config = { - teams: [ + windowManager.viewManager = { + showByName: jest.fn(), + }; + + beforeEach(() => { + jest.useFakeTimers(); + getTabViewName.mockImplementation((server, tab) => `${server}_${tab}`); + + Config.teams = [ { name: 'server-1', order: 1, @@ -513,26 +521,20 @@ describe('main/windows/windowManager', () => { ], lastActiveTab: 2, }, - ], - }; - windowManager.viewManager = { - showByName: jest.fn(), - }; - const map = windowManager.config.teams.reduce((arr, item) => { - item.tabs.forEach((tab) => { - arr.push([`${item.name}_${tab.name}`, {}]); - }); - return arr; - }, []); - windowManager.viewManager.views = new Map(map); + ]; - beforeEach(() => { - jest.useFakeTimers(); - getTabViewName.mockImplementation((server, tab) => `${server}_${tab}`); + const map = Config.teams.reduce((arr, item) => { + item.tabs.forEach((tab) => { + arr.push([`${item.name}_${tab.name}`, {}]); + }); + return arr; + }, []); + windowManager.viewManager.views = new Map(map); }); afterEach(() => { jest.resetAllMocks(); + Config.teams = []; }); it('should do nothing if cannot find the server', () => { @@ -625,8 +627,13 @@ describe('main/windows/windowManager', () => { describe('selectTab', () => { const windowManager = new WindowManager(); - windowManager.config = { - teams: [ + windowManager.viewManager = { + getCurrentView: jest.fn(), + }; + windowManager.switchTab = jest.fn(); + + beforeEach(() => { + Config.teams = [ { name: 'server-1', order: 1, @@ -648,15 +655,12 @@ describe('main/windows/windowManager', () => { }, ], }, - ], - }; - windowManager.viewManager = { - getCurrentView: jest.fn(), - }; - windowManager.switchTab = jest.fn(); + ]; + }); afterEach(() => { jest.resetAllMocks(); + Config.teams = []; }); it('should select next server when open', () => { @@ -696,7 +700,6 @@ describe('main/windows/windowManager', () => { type: 'tab-2', }, }); - windowManager.selectTab((order) => order + 1); expect(windowManager.switchTab).toBeCalledWith('server-1', 'tab-3'); }); @@ -704,32 +707,6 @@ describe('main/windows/windowManager', () => { describe('handleBrowserHistoryPush', () => { const windowManager = new WindowManager(); - windowManager.config = { - teams: [ - { - name: 'server-1', - url: 'http://server-1.com', - order: 0, - tabs: [ - { - name: 'tab-messaging', - order: 0, - isOpen: true, - }, - { - name: 'other_type_1', - order: 2, - isOpen: true, - }, - { - name: 'other_type_2', - order: 1, - isOpen: false, - }, - ], - }, - ], - }; const view1 = { name: 'server-1_tab-messaging', isLoggedIn: true, @@ -783,8 +760,36 @@ describe('main/windows/windowManager', () => { showByName: jest.fn(), }; + beforeEach(() => { + Config.teams = [ + { + name: 'server-1', + url: 'http://server-1.com', + order: 0, + tabs: [ + { + name: 'tab-messaging', + order: 0, + isOpen: true, + }, + { + name: 'other_type_1', + order: 2, + isOpen: true, + }, + { + name: 'other_type_2', + order: 1, + isOpen: false, + }, + ], + }, + ]; + }); + afterEach(() => { jest.resetAllMocks(); + Config.teams = []; }); it('should open closed view if pushing to it', () => { diff --git a/src/main/windows/windowManager.ts b/src/main/windows/windowManager.ts index bcafa724..dac7f8a8 100644 --- a/src/main/windows/windowManager.ts +++ b/src/main/windows/windowManager.ts @@ -6,8 +6,6 @@ import path from 'path'; import {app, BrowserWindow, nativeImage, systemPreferences, ipcMain, IpcMainEvent, IpcMainInvokeEvent} from 'electron'; import log from 'electron-log'; -import {CombinedConfig} from 'types/config'; - import { MAXIMIZE_CHANGE, HISTORY, @@ -24,7 +22,7 @@ import { APP_LOGGED_OUT, } from 'common/communication'; import urlUtils from 'common/utils/url'; - +import Config from 'common/config'; import {getTabViewName, TAB_MESSAGING} from 'common/tabs/TabView'; import {getAdjustedWindowBoundaries} from '../utils'; @@ -44,7 +42,6 @@ export class WindowManager { mainWindow?: BrowserWindow; settingsWindow?: BrowserWindow; - config?: CombinedConfig; viewManager?: ViewManager; teamDropdown?: TeamDropdownView; currentServerName?: string; @@ -64,12 +61,9 @@ export class WindowManager { ipcMain.handle(GET_VIEW_WEBCONTENTS_ID, this.handleGetWebContentsId); } - setConfig = (data: CombinedConfig) => { - if (data) { - this.config = data; - } - if (this.viewManager && this.config) { - this.viewManager.reloadConfiguration(this.config.teams || []); + handleUpdateConfig = () => { + if (this.viewManager) { + this.viewManager.reloadConfiguration(Config.teams || []); } } @@ -82,10 +76,7 @@ export class WindowManager { } const withDevTools = Boolean(process.env.MM_DEBUG_SETTINGS) || false; - if (!this.config) { - return; - } - this.settingsWindow = createSettingsWindow(this.mainWindow!, this.config, withDevTools); + this.settingsWindow = createSettingsWindow(this.mainWindow!, withDevTools); this.settingsWindow.on('closed', () => { delete this.settingsWindow; }); @@ -100,10 +91,7 @@ export class WindowManager { this.mainWindow.show(); } } else { - if (!this.config) { - return; - } - this.mainWindow = createMainWindow(this.config, { + this.mainWindow = createMainWindow({ linuxAppIcon: path.join(this.assetsDir, 'linux', 'app_icon.png'), }); @@ -119,9 +107,8 @@ export class WindowManager { delete this.mainWindow; }); this.mainWindow.on('unresponsive', () => { - const criticalErrorHandler = new CriticalErrorHandler(); - criticalErrorHandler.setMainWindow(this.mainWindow!); - criticalErrorHandler.windowUnresponsiveHandler(); + CriticalErrorHandler.setMainWindow(this.mainWindow!); + CriticalErrorHandler.windowUnresponsiveHandler(); }); this.mainWindow.on('maximize', this.handleMaximizeMainWindow); this.mainWindow.on('unmaximize', this.handleUnmaximizeMainWindow); @@ -138,7 +125,7 @@ export class WindowManager { this.viewManager.updateMainWindow(this.mainWindow); } - this.teamDropdown = new TeamDropdownView(this.mainWindow, this.config.teams, this.config.darkMode, this.config.enableServerManagement); + this.teamDropdown = new TeamDropdownView(this.mainWindow, Config.teams, Config.darkMode, Config.enableServerManagement); } this.initializeViewManager(); @@ -251,7 +238,7 @@ export class WindowManager { flashFrame = (flash: boolean) => { if (process.platform === 'linux' || process.platform === 'win32') { - if (this.config?.notifications.flashWindow) { + if (Config.notifications.flashWindow) { this.mainWindow?.flashFrame(flash); if (this.settingsWindow) { // main might be hidden behind the settings @@ -259,8 +246,8 @@ export class WindowManager { } } } - if (process.platform === 'darwin' && this.config?.notifications.bounceIcon) { - app.dock.bounce(this.config?.notifications.bounceIconType); + if (process.platform === 'darwin' && Config.notifications.bounceIcon) { + app.dock.bounce(Config.notifications.bounceIconType); } } @@ -358,8 +345,8 @@ export class WindowManager { } initializeViewManager = () => { - if (!this.viewManager && this.config && this.mainWindow) { - this.viewManager = new ViewManager(this.config, this.mainWindow); + if (!this.viewManager && Config && this.mainWindow) { + this.viewManager = new ViewManager(this.mainWindow); this.viewManager.load(); this.viewManager.showInitial(); this.initializeCurrentServerName(); @@ -367,14 +354,14 @@ export class WindowManager { } initializeCurrentServerName = () => { - if (this.config && !this.currentServerName) { - this.currentServerName = (this.config.teams.find((team) => team.order === this.config?.lastActiveTeam) || this.config.teams.find((team) => team.order === 0))?.name; + if (!this.currentServerName) { + this.currentServerName = (Config.teams.find((team) => team.order === Config.lastActiveTeam) || Config.teams.find((team) => team.order === 0))?.name; } } switchServer = (serverName: string, waitForViewToExist = false) => { this.showMainWindow(); - const server = this.config?.teams.find((team) => team.name === serverName); + const server = Config.teams.find((team) => team.name === serverName); if (!server) { log.error('Cannot find server in config'); return; @@ -428,7 +415,7 @@ export class WindowManager { handleLoadingScreenDataRequest = () => { return { - darkMode: this.config?.darkMode || false, + darkMode: Config.darkMode || false, }; } @@ -529,7 +516,7 @@ export class WindowManager { return; } - const currentTeamTabs = this.config?.teams.find((team) => team.name === currentView.tab.server.name)?.tabs; + const currentTeamTabs = Config.teams.find((team) => team.name === currentView.tab.server.name)?.tabs; const filteredTabs = currentTeamTabs?.filter((tab) => tab.isOpen); const currentTab = currentTeamTabs?.find((tab) => tab.name === currentView.tab.type); if (!currentTeamTabs || !currentTab || !filteredTabs) { @@ -549,12 +536,12 @@ export class WindowManager { } handleGetDarkMode = () => { - return this.config?.darkMode; + return Config.darkMode; } handleBrowserHistoryPush = (e: IpcMainEvent, viewName: string, pathName: string) => { const currentView = this.viewManager?.views.get(viewName); - const redirectedViewName = urlUtils.getView(`${currentView?.tab.server.url}${pathName}`, this.config!.teams)?.name || viewName; + const redirectedViewName = urlUtils.getView(`${currentView?.tab.server.url}${pathName}`, Config.teams)?.name || viewName; if (this.viewManager?.closedViews.has(redirectedViewName)) { this.viewManager.openClosedTab(redirectedViewName, `${currentView?.tab.server.url}${pathName}`); } diff --git a/webpack.config.main.js b/webpack.config.main.js index efd2f53f..05e55f96 100644 --- a/webpack.config.main.js +++ b/webpack.config.main.js @@ -16,7 +16,7 @@ const base = require('./webpack.config.base'); module.exports = merge(base, { entry: { - index: './src/main/main.ts', + index: './src/main/app/index.ts', mainWindow: './src/main/preload/mainWindow.js', dropdown: './src/main/preload/dropdown.js', preload: './src/main/preload/mattermost.js',