diff --git a/src/main/AutoLauncher.ts b/src/main/AutoLauncher.ts index 284b4562..b6651837 100644 --- a/src/main/AutoLauncher.ts +++ b/src/main/AutoLauncher.ts @@ -17,35 +17,44 @@ export default class AutoLauncher { }); } - isEnabled() { - return this.appLauncher.isEnabled(); + async upgradeAutoLaunch() { + if (process.platform === 'darwin') { + return; + } + const appLauncher = new AutoLaunch({ + name: 'Mattermost', + }); + const enabled = await appLauncher.isEnabled(); + if (enabled) { + await appLauncher.enable(); + } } - async blankPromise() { - return null; + isEnabled() { + return this.appLauncher.isEnabled(); } async enable() { if (isDev) { log.warn('In development mode, autostart config never effects'); - return this.blankPromise(); + return Promise.resolve(null); } const enabled = await this.isEnabled(); if (!enabled) { return this.appLauncher.enable(); } - return this.blankPromise(); + return Promise.resolve(null); } async disable() { if (isDev) { log.warn('In development mode, autostart config never effects'); - return this.blankPromise(); + return Promise.resolve(null); } const enabled = await this.isEnabled(); if (enabled) { return this.appLauncher.disable(); } - return this.blankPromise(); + return Promise.resolve(null); } } diff --git a/src/main/CriticalErrorHandler.test.js b/src/main/CriticalErrorHandler.test.js new file mode 100644 index 00000000..ac153df5 --- /dev/null +++ b/src/main/CriticalErrorHandler.test.js @@ -0,0 +1,107 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +'use strict'; + +import {spawn} from 'child_process'; + +import path from 'path'; + +import {app, dialog} from 'electron'; + +import CriticalErrorHandler from './CriticalErrorHandler'; + +jest.mock('path', () => ({ + join: jest.fn().mockImplementation((...args) => args.join('/')), +})); + +jest.mock('electron', () => ({ + app: { + name: 'Mattermost', + getVersion: () => '5.0.0', + getPath: (folder) => `/${folder}`, + relaunch: jest.fn(), + isReady: jest.fn(), + exit: jest.fn(), + }, + dialog: { + showMessageBox: jest.fn(), + }, +})); + +jest.mock('electron-log', () => ({ + error: jest.fn(), +})); + +jest.mock('fs', () => ({ + writeFileSync: jest.fn(), +})); + +jest.mock('child_process', () => ({ + spawn: jest.fn(), +})); + +describe('main/CriticalErrorHandler', () => { + const criticalErrorHandler = new CriticalErrorHandler(); + beforeEach(() => { + criticalErrorHandler.setMainWindow({}); + }); + + describe('windowUnresponsiveHandler', () => { + it('should do nothing when mainWindow is null', () => { + criticalErrorHandler.setMainWindow(null); + criticalErrorHandler.windowUnresponsiveHandler(); + expect(dialog.showMessageBox).not.toBeCalled(); + }); + + it('should call app.relaunch when user elects not to wait', async () => { + const promise = Promise.resolve({response: 0}); + dialog.showMessageBox.mockImplementation(() => promise); + criticalErrorHandler.windowUnresponsiveHandler(); + await promise; + expect(app.relaunch).toBeCalled(); + }); + }); + + describe('processUncaughtExceptionHandler', () => { + beforeEach(() => { + app.isReady.mockImplementation(() => true); + criticalErrorHandler.setMainWindow({isVisible: true}); + }); + + it('should throw error if app is not ready', () => { + app.isReady.mockImplementation(() => false); + expect(() => { + criticalErrorHandler.processUncaughtExceptionHandler(new Error('test')); + }).toThrow(Error); + expect(dialog.showMessageBox).not.toBeCalled(); + }); + + it('should do nothing if main window is null or not visible', () => { + criticalErrorHandler.setMainWindow(null); + criticalErrorHandler.processUncaughtExceptionHandler(new Error('test')); + expect(dialog.showMessageBox).not.toBeCalled(); + + criticalErrorHandler.setMainWindow({isVisible: false}); + criticalErrorHandler.processUncaughtExceptionHandler(new Error('test')); + expect(dialog.showMessageBox).not.toBeCalled(); + }); + + it('should open external file on Show Details', async () => { + path.join.mockImplementation(() => 'testfile.txt'); + const promise = Promise.resolve({response: process.platform === 'darwin' ? 2 : 0}); + dialog.showMessageBox.mockImplementation(() => promise); + criticalErrorHandler.processUncaughtExceptionHandler(new Error('test')); + await promise; + expect(spawn).toBeCalledWith(expect.any(String), expect.arrayContaining(['testfile.txt']), expect.any(Object)); + }); + + it('should restart app on Reopen', async () => { + path.join.mockImplementation(() => 'testfile.txt'); + const promise = Promise.resolve({response: process.platform === 'darwin' ? 0 : 2}); + dialog.showMessageBox.mockImplementation(() => promise); + criticalErrorHandler.processUncaughtExceptionHandler(new Error('test')); + await promise; + expect(app.relaunch).toBeCalled(); + }); + }); +}); diff --git a/src/main/CriticalErrorHandler.ts b/src/main/CriticalErrorHandler.ts index a6bb0567..44f3eba5 100644 --- a/src/main/CriticalErrorHandler.ts +++ b/src/main/CriticalErrorHandler.ts @@ -57,7 +57,8 @@ export default class CriticalErrorHandler { defaultId: 0, }).then(({response}) => { if (response === 0) { - throw new Error('BrowserWindow \'unresponsive\' event has been emitted'); + log.error('BrowserWindow \'unresponsive\' event has been emitted'); + app.relaunch(); } }); } diff --git a/src/main/Validator.test.js b/src/main/Validator.test.js new file mode 100644 index 00000000..d1480297 --- /dev/null +++ b/src/main/Validator.test.js @@ -0,0 +1,205 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +'use strict'; + +import * as Validator from './Validator'; + +jest.mock('electron-log', () => ({ + error: jest.fn(), +})); + +describe('main/Validator', () => { + describe('validateV0ConfigData', () => { + const config = {url: 'http://server-1.com'}; + + it('should return null when not provided object', () => { + expect(Validator.validateV0ConfigData('notanobject')).toBe(null); + }); + + it('should return complete object when it is valid', () => { + expect(Validator.validateV0ConfigData(config)).toStrictEqual(config); + }); + + it('should remove fields that arent part of the schema', () => { + const modifiedConfig = {...config, anotherField: 'value'}; + expect(Validator.validateV0ConfigData(modifiedConfig)).toStrictEqual(config); + }); + }); + + describe('validateV1ConfigData', () => { + const config = { + autostart: true, + enableHardwareAcceleration: true, + minimizeToTray: false, + showTrayIcon: false, + showUnreadBadge: true, + spellCheckerLocale: 'en-US', + teams: [ + { + name: 'server-1', + url: 'http://server-1.com', + }, + ], + trayIconTheme: 'light', + useSpellChecker: true, + version: 1, + }; + + it('should remove invalid urls', () => { + const modifiedConfig = { + ...config, + teams: [ + ...config.teams, + { + name: 'server-2', + url: 'a-bad>url', + }, + ], + }; + expect(Validator.validateV1ConfigData(modifiedConfig)).toStrictEqual(config); + }); + + it('should clean URLs with backslashes', () => { + const modifiedConfig = { + ...config, + teams: [ + ...config.teams, + { + name: 'server-2', + url: 'http:\\\\server-2.com\\subpath', + }, + ], + }; + expect(Validator.validateV1ConfigData(modifiedConfig)).toStrictEqual({ + ...config, + teams: [ + ...config.teams, + { + name: 'server-2', + url: 'http://server-2.com/subpath', + }, + ], + }); + }); + + it('should invalidate bad spell checker locales', () => { + const modifiedConfig = { + ...config, + spellCheckerLocale: 'not-a-locale', + }; + expect(Validator.validateV1ConfigData(modifiedConfig)).toStrictEqual(null); + }); + }); + + describe('validateV2ConfigData', () => { + const config = { + autostart: true, + darkMode: false, + enableHardwareAcceleration: true, + minimizeToTray: false, + showTrayIcon: false, + showUnreadBadge: true, + spellCheckerLocale: 'en-US', + spellCheckerURL: 'http://spellcheckerservice.com', + teams: [ + { + name: 'server-1', + url: 'http://server-1.com', + order: 1, + }, + ], + trayIconTheme: 'light', + useSpellChecker: true, + version: 2, + }; + + it('should remove invalid spellchecker URLs', () => { + const modifiedConfig = { + ...config, + spellCheckerURL: 'a-bad>url', + }; + expect(Validator.validateV2ConfigData(modifiedConfig)).not.toHaveProperty('spellCheckerURL'); + }); + }); + + describe('validateV3ConfigData', () => { + const config = { + autostart: true, + darkMode: false, + enableHardwareAcceleration: true, + lastActiveTeam: 0, + minimizeToTray: false, + showTrayIcon: false, + showUnreadBadge: true, + spellCheckerLocales: ['en-US'], + spellCheckerURL: 'http://spellcheckerservice.com', + teams: [ + { + lastActiveTab: 0, + name: 'server-1', + url: 'http://server-1.com', + order: 1, + tabs: [ + { + name: 'tab-1', + isOpen: true, + }, + ], + }, + ], + trayIconTheme: 'light', + useSpellChecker: true, + version: 3, + }; + + it('should ensure messaging tab is open', () => { + const modifiedConfig = { + ...config, + teams: [ + { + ...config.teams[0], + tabs: [ + ...config.teams[0].tabs, + { + name: 'TAB_MESSAGING', + isOpen: false, + }, + ], + }, + ], + }; + expect(Validator.validateV3ConfigData(modifiedConfig)).toStrictEqual({ + ...config, + teams: [ + { + ...config.teams[0], + tabs: [ + ...config.teams[0].tabs, + { + name: 'TAB_MESSAGING', + isOpen: true, + }, + ], + }, + ], + }); + }); + }); + + describe('validateAllowedProtocols', () => { + const allowedProtocols = [ + 'spotify:', + 'steam:', + 'mattermost:', + ]; + + it('should accept valid protocols', () => { + expect(Validator.validateAllowedProtocols(allowedProtocols)).toStrictEqual(allowedProtocols); + }); + + it('should reject invalid protocols', () => { + expect(Validator.validateAllowedProtocols([...allowedProtocols, 'not-a-protocol'])).toStrictEqual(null); + }); + }); +}); diff --git a/src/main/Validator.ts b/src/main/Validator.ts index dd24b4fb..434e9a2c 100644 --- a/src/main/Validator.ts +++ b/src/main/Validator.ts @@ -5,7 +5,7 @@ import log from 'electron-log'; import Joi from '@hapi/joi'; import {Args} from 'types/args'; -import {ConfigV0, ConfigV1, ConfigV2, ConfigV3} from 'types/config'; +import {ConfigV0, ConfigV1, ConfigV2, ConfigV3, TeamWithTabs} from 'types/config'; import {SavedWindowState} from 'types/mainWindow'; import {AppState} from 'types/appState'; import {ComparableCertificate} from 'types/certificate'; @@ -168,27 +168,6 @@ export function validateV0ConfigData(data: ConfigV0) { return validateAgainstSchema(data, configDataSchemaV0); } -// validate v.1 config.json -export function validateV1ConfigData(data: ConfigV1) { - if (Array.isArray(data.teams) && data.teams.length) { - // first replace possible backslashes with forward slashes - let teams = data.teams.map(({name, url}) => { - let updatedURL = url; - if (updatedURL.includes('\\')) { - updatedURL = updatedURL.toLowerCase().replace(/\\/gi, '/'); - } - return {name, url: updatedURL}; - }); - - // next filter out urls that are still invalid so all is not lost - teams = teams.filter(({url}) => urlUtils.isValidURL(url)); - - // replace original teams - data.teams = teams; - } - return validateAgainstSchema(data, configDataSchemaV1); -} - function cleanURL(url: string): string { let updatedURL = url; if (updatedURL.includes('\\')) { @@ -197,22 +176,45 @@ function cleanURL(url: string): string { return updatedURL; } -export function validateV2ConfigData(data: ConfigV2) { - if (Array.isArray(data.teams) && data.teams.length) { - // first replace possible backslashes with forward slashes - let teams = data.teams.map((team) => { +function cleanTeam(team: T) { + return { + ...team, + url: cleanURL(team.url), + }; +} + +function cleanTeamWithTabs(team: TeamWithTabs) { + return { + ...cleanTeam(team), + tabs: team.tabs.map((tab) => { return { - ...team, - url: cleanURL(team.url), + ...tab, + isOpen: tab.name === TAB_MESSAGING ? true : tab.isOpen, }; - }); + }), + }; +} + +function cleanTeams(teams: T[], func: (team: T) => T) { + let newTeams = teams; + if (Array.isArray(newTeams) && newTeams.length) { + // first replace possible backslashes with forward slashes + newTeams = newTeams.map((team) => func(team)); // next filter out urls that are still invalid so all is not lost - teams = teams.filter(({url}) => urlUtils.isValidURL(url)); - - // replace original teams - data.teams = teams; + newTeams = newTeams.filter(({url}) => urlUtils.isValidURL(url)); } + return newTeams; +} + +// validate v.1 config.json +export function validateV1ConfigData(data: ConfigV1) { + data.teams = cleanTeams(data.teams, cleanTeam); + return validateAgainstSchema(data, configDataSchemaV1); +} + +export function validateV2ConfigData(data: ConfigV2) { + data.teams = cleanTeams(data.teams, cleanTeam); if (data.spellCheckerURL && !urlUtils.isValidURL(data.spellCheckerURL)) { log.error('Invalid download location for spellchecker dictionary, removing from config'); delete data.spellCheckerURL; @@ -221,29 +223,7 @@ export function validateV2ConfigData(data: ConfigV2) { } export function validateV3ConfigData(data: ConfigV3) { - if (Array.isArray(data.teams) && data.teams.length) { - // first replace possible backslashes with forward slashes - let teams = data.teams.map((team) => { - return { - ...team, - url: cleanURL(team.url), - - // Force messaging to stay open regardless of user config - tabs: team.tabs.map((tab) => { - return { - ...tab, - isOpen: tab.name === TAB_MESSAGING ? true : tab.isOpen, - }; - }), - }; - }); - - // next filter out urls that are still invalid so all is not lost - teams = teams.filter(({url}) => urlUtils.isValidURL(url)); - - // replace original teams - data.teams = teams; - } + data.teams = cleanTeams(data.teams, cleanTeamWithTabs); if (data.spellCheckerURL && !urlUtils.isValidURL(data.spellCheckerURL)) { log.error('Invalid download location for spellchecker dictionary, removing from config'); delete data.spellCheckerURL; diff --git a/src/main/autoLaunch.ts b/src/main/autoLaunch.ts deleted file mode 100644 index 4614ad09..00000000 --- a/src/main/autoLaunch.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2015-2016 Yuya Ochiai -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. -import AutoLaunch from 'auto-launch'; - -async function upgradeAutoLaunch() { - if (process.platform === 'darwin') { - return; - } - const appLauncher = new AutoLaunch({ - name: 'Mattermost', - }); - const enabled = await appLauncher.isEnabled(); - if (enabled) { - await appLauncher.enable(); - } -} - -export default upgradeAutoLaunch; diff --git a/src/main/badge.test.js b/src/main/badge.test.js new file mode 100644 index 00000000..7a2c71bb --- /dev/null +++ b/src/main/badge.test.js @@ -0,0 +1,79 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +'use strict'; + +import {app} from 'electron'; + +import * as Badge from './badge'; + +import {setOverlayIcon} from './windows/windowManager'; + +jest.mock('electron', () => ({ + app: { + dock: { + setBadge: jest.fn(), + }, + }, +})); + +jest.mock('./appState', () => ({ + updateBadge: jest.fn(), +})); + +jest.mock('./windows/windowManager', () => ({ + setOverlayIcon: jest.fn(), +})); + +describe('main/badge', () => { + describe('showBadgeWindows', () => { + it('should show dot when session expired', () => { + Badge.showBadgeWindows(true, 7, false); + expect(setOverlayIcon).toBeCalledWith('•', expect.any(String), expect.any(Boolean)); + }); + + it('should show mention count when has mention count', () => { + Badge.showBadgeWindows(false, 50, false); + expect(setOverlayIcon).toBeCalledWith('50', expect.any(String), false); + }); + + it('should show 99+ when has mention count over 99', () => { + Badge.showBadgeWindows(false, 200, false); + expect(setOverlayIcon).toBeCalledWith('99+', expect.any(String), true); + }); + + it('should not show dot when has unreads but setting is off', () => { + Badge.showBadgeWindows(false, 0, true); + expect(setOverlayIcon).not.toBeCalledWith('•', expect.any(String), expect.any(Boolean)); + }); + + it('should show dot when has unreads', () => { + Badge.setUnreadBadgeSetting(true); + Badge.showBadgeWindows(false, 0, true); + expect(setOverlayIcon).toBeCalledWith('•', expect.any(String), expect.any(Boolean)); + Badge.setUnreadBadgeSetting(false); + }); + }); + + describe('showBadgeOSX', () => { + it('should show dot when session expired', () => { + Badge.showBadgeOSX(true, 7, false); + expect(app.dock.setBadge).toBeCalledWith('•'); + }); + + it('should show mention count when has mention count', () => { + Badge.showBadgeOSX(false, 50, false); + expect(app.dock.setBadge).toBeCalledWith('50'); + }); + + it('should not show dot when has unreads but setting is off', () => { + Badge.showBadgeOSX(false, 0, true); + expect(app.dock.setBadge).not.toBeCalledWith('•'); + }); + + it('should show dot when has unreads', () => { + Badge.setUnreadBadgeSetting(true); + Badge.showBadgeOSX(false, 0, true); + expect(app.dock.setBadge).toBeCalledWith('•'); + }); + }); +}); diff --git a/src/main/badge.ts b/src/main/badge.ts index 426a91b0..9f12a71a 100644 --- a/src/main/badge.ts +++ b/src/main/badge.ts @@ -13,7 +13,7 @@ const MAX_WIN_COUNT = 99; let showUnreadBadgeSetting: boolean; -function showBadgeWindows(sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean) { +export function showBadgeWindows(sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean) { let description = 'You have no unread messages'; let text; if (sessionExpired) { @@ -29,7 +29,7 @@ function showBadgeWindows(sessionExpired: boolean, mentionCount: number, showUnr WindowManager.setOverlayIcon(text, description, mentionCount > 99); } -function showBadgeOSX(sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean) { +export function showBadgeOSX(sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean) { let badge = ''; if (sessionExpired) { badge = '•'; diff --git a/src/main/certificateStore.test.js b/src/main/certificateStore.test.js new file mode 100644 index 00000000..81badda5 --- /dev/null +++ b/src/main/certificateStore.test.js @@ -0,0 +1,102 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +'use strict'; + +import fs from 'fs'; + +import {validateCertificateStore} from './Validator'; + +import CertificateStore from './certificateStore'; + +jest.mock('./Validator', () => ({ + validateCertificateStore: jest.fn(), +})); + +jest.mock('fs', () => ({ + readFileSync: jest.fn(), +})); + +const certificateData = { + 'https://server-1.com': { + data: 'somerandomdata', + issuerName: 'someissuer', + dontTrust: false, + }, + 'https://server-2.com': { + data: 'somerandomdata', + issuerName: 'someissuer', + dontTrust: true, + }, +}; + +describe('main/certificateStore', () => { + it('should fail gracefully when loaded data is malformed', () => { + validateCertificateStore.mockImplementation(() => null); + + let certificateStore; + expect(() => { + certificateStore = new CertificateStore('somefilename'); + }).not.toThrow(Error); + expect(certificateStore.data).toStrictEqual({}); + }); + + describe('isTrusted', () => { + let certificateStore; + beforeAll(() => { + validateCertificateStore.mockImplementation((data) => JSON.parse(data)); + fs.readFileSync.mockImplementation(() => JSON.stringify(certificateData)); + certificateStore = new CertificateStore('somefilename'); + }); + + it('should return true for stored matching certificate', () => { + certificateStore = new CertificateStore('somefilename'); + + expect(certificateStore.isTrusted('https://server-1.com', { + data: 'somerandomdata', + issuerName: 'someissuer', + })).toBe(true); + }); + + it('should return false for missing url', () => { + expect(certificateStore.isTrusted('https://server-3.com', { + data: 'somerandomdata', + issuerName: 'someissuer', + })).toBe(false); + }); + + it('should return false for unmatching cert', () => { + expect(certificateStore.isTrusted('https://server-1.com', { + data: 'someotherrandomdata', + issuerName: 'someissuer', + })).toBe(false); + + expect(certificateStore.isTrusted('https://server-1.com', { + data: 'somerandomdata', + issuerName: 'someotherissuer', + })).toBe(false); + }); + }); + + describe('isExplicitlyUntrusted', () => { + let certificateStore; + beforeAll(() => { + validateCertificateStore.mockImplementation((data) => JSON.parse(data)); + fs.readFileSync.mockImplementation(() => JSON.stringify(certificateData)); + certificateStore = new CertificateStore('somefilename'); + }); + + it('should return true for explicitly untrusted cert', () => { + expect(certificateStore.isExplicitlyUntrusted('https://server-2.com', { + data: 'somerandomdata', + issuerName: 'someissuer', + })).toBe(true); + }); + + it('should return false for trusted cert', () => { + expect(certificateStore.isExplicitlyUntrusted('https://server-1.com', { + data: 'somerandomdata', + issuerName: 'someissuer', + })).toBe(false); + }); + }); +}); diff --git a/src/main/contextMenu.test.js b/src/main/contextMenu.test.js new file mode 100644 index 00000000..15d73d66 --- /dev/null +++ b/src/main/contextMenu.test.js @@ -0,0 +1,82 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +'use strict'; + +import ContextMenu from './contextMenu'; + +jest.mock('electron-context-menu', () => { + return () => jest.fn(); +}); + +describe('main/contextMenu', () => { + describe('shouldShowMenu', () => { + const contextMenu = new ContextMenu(); + + it('should not show menu on internal link', () => { + expect(contextMenu.menuOptions.shouldShowMenu(null, { + mediaType: 'none', + linkURL: 'http://server-1.com/subpath#', + pageURL: 'http://server-1.com/subpath', + srcURL: '', + misspelledWord: '', + selectionText: '', + })).toBe(false); + }); + + it('should not show menu on buttons', () => { + expect(contextMenu.menuOptions.shouldShowMenu(null, { + mediaType: 'none', + linkURL: '', + pageURL: 'http://server-1.com/subpath', + srcURL: '', + misspelledWord: '', + selectionText: '', + })).toBe(false); + }); + + it('should show menu on editables', () => { + expect(contextMenu.menuOptions.shouldShowMenu(null, { + mediaType: 'none', + linkURL: '', + pageURL: 'http://server-1.com/subpath', + srcURL: '', + misspelledWord: '', + selectionText: '', + isEditable: true, + })).toBe(true); + }); + + it('should show menu on images', () => { + expect(contextMenu.menuOptions.shouldShowMenu(null, { + mediaType: 'image', + linkURL: '', + pageURL: 'http://server-1.com/subpath', + srcURL: 'http://server-1.com/subpath/image.png', + misspelledWord: '', + selectionText: '', + isEditable: true, + })).toBe(true); + }); + + it('should show menu on external links', () => { + expect(contextMenu.menuOptions.shouldShowMenu(null, { + mediaType: 'none', + linkURL: 'http://server-2.com/link', + pageURL: 'http://server-1.com/subpath', + srcURL: '', + misspelledWord: '', + selectionText: '', + isEditable: true, + })).toBe(true); + }); + }); + + describe('reload', () => { + it('should call dispose on reload', () => { + const contextMenu = new ContextMenu(); + const fn = contextMenu.menuDispose; + contextMenu.reload(); + expect(fn).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/contextMenu.ts b/src/main/contextMenu.ts index 5d189eed..98fa6168 100644 --- a/src/main/contextMenu.ts +++ b/src/main/contextMenu.ts @@ -2,7 +2,7 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {BrowserView, BrowserWindow, ContextMenuParams, Event, WebContents} from 'electron'; +import {BrowserView, BrowserWindow, ContextMenuParams, Event} from 'electron'; import electronContextMenu, {Options} from 'electron-context-menu'; import urlUtils from 'common/utils/url'; @@ -51,11 +51,7 @@ export default class ContextMenu { reload = () => { this.dispose(); - /** - * Work-around issue with passing `WebContents` to `electron-context-menu` in Electron 11 - * @see https://github.com/sindresorhus/electron-context-menu/issues/123 - */ - const options = {window: {webContents: this.view.webContents, inspectElement: this.view.webContents.inspectElement.bind(this.view.webContents), isDestroyed: this.view.webContents.isDestroyed.bind(this.view.webContents), off: this.view.webContents.off.bind(this.view.webContents)} as unknown as WebContents, ...this.menuOptions}; + const options = {window: this.view, ...this.menuOptions}; this.menuDispose = electronContextMenu(options); } } diff --git a/src/main/main.ts b/src/main/main.ts index 0f55cd87..27417909 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -55,7 +55,6 @@ import {protocols} from '../../electron-builder.json'; import AutoLauncher from './AutoLauncher'; import CriticalErrorHandler from './CriticalErrorHandler'; -import upgradeAutoLaunch from './autoLaunch'; import CertificateStore from './certificateStore'; import TrustedOriginsStore from './trustedOrigins'; import {createMenu as createAppMenu} from './menus/app'; @@ -91,6 +90,7 @@ const { } = 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 @@ -281,8 +281,7 @@ function handleConfigUpdate(newConfig: CombinedConfig) { return; } if (process.platform === 'win32' || process.platform === 'linux') { - const appLauncher = new AutoLauncher(); - const autoStartTask = config.autostart ? appLauncher.enable() : appLauncher.disable(); + const autoStartTask = config.autostart ? autoLauncher.enable() : autoLauncher.disable(); autoStartTask.then(() => { log.info('config.autostart has been configured:', newConfig.autostart); }).catch((err) => { @@ -684,7 +683,7 @@ function initializeAfterAppReady() { appVersion.lastAppVersion = app.getVersion(); if (!global.isDev) { - upgradeAutoLaunch(); + autoLauncher.upgradeAutoLaunch(); } if (global.isDev) { diff --git a/src/main/utils.test.js b/src/main/utils.test.js new file mode 100644 index 00000000..7626ff54 --- /dev/null +++ b/src/main/utils.test.js @@ -0,0 +1,100 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +'use strict'; + +import {BACK_BAR_HEIGHT, TAB_BAR_HEIGHT} from 'common/utils/constants'; +import {runMode} from 'common/utils/util'; + +import * as Utils from './utils'; + +jest.mock('electron', () => ({ + app: { + getLoginItemSettings: () => ({ + wasOpenedAsHidden: true, + }), + getAppPath: () => '/path/to/app', + }, +})); + +jest.mock('common/utils/util', () => ({ + runMode: jest.fn(), +})); + +jest.mock('path', () => { + const original = jest.requireActual('path'); + return { + ...original, + resolve: (basePath, ...restOfPath) => original.join('/path/to/app/src/main', ...restOfPath), + }; +}); + +describe('main/utils', () => { + describe('shouldBeHiddenOnStartup', () => { + let originalPlatform; + + beforeAll(() => { + originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + }); + + it('should be hidden on mac when opened as hidden', () => { + expect(Utils.shouldBeHiddenOnStartup({})).toBe(true); + }); + + afterAll(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + }); + }); + + describe('getWindowBoundaries', () => { + it('should include tab bar height', () => { + expect(Utils.getWindowBoundaries({ + getContentBounds: () => ({width: 500, height: 400}), + })).toStrictEqual({ + x: 0, + y: TAB_BAR_HEIGHT, + width: 500, + height: 400 - TAB_BAR_HEIGHT, + }); + }); + + it('should include back bar height when specified', () => { + expect(Utils.getWindowBoundaries({ + getContentBounds: () => ({width: 500, height: 400}), + }, true)).toStrictEqual({ + x: 0, + y: TAB_BAR_HEIGHT + BACK_BAR_HEIGHT, + width: 500, + height: 400 - TAB_BAR_HEIGHT - BACK_BAR_HEIGHT, + }); + }); + }); + + describe('getLocalURLString', () => { + it('should return URL relative to current run directory', () => { + runMode.mockImplementation(() => 'development'); + expect(Utils.getLocalURLString('index.html')).toStrictEqual('file:///path/to/app/dist/renderer/index.html'); + }); + + it('should return URL relative to current run directory in production', () => { + runMode.mockImplementation(() => 'production'); + expect(Utils.getLocalURLString('index.html')).toStrictEqual('file:///path/to/app/renderer/index.html'); + }); + + it('should include query string when specified', () => { + const queryMap = new Map([['key', 'value']]); + runMode.mockImplementation(() => 'development'); + expect(Utils.getLocalURLString('index.html', queryMap)).toStrictEqual('file:///path/to/app/dist/renderer/index.html?key=value'); + }); + + it('should return URL relative to current run directory when using main process', () => { + runMode.mockImplementation(() => 'development'); + expect(Utils.getLocalURLString('index.html', null, true)).toStrictEqual('file:///path/to/app/dist/index.html'); + }); + }); +}); diff --git a/src/main/utils.ts b/src/main/utils.ts index 6b7cdf83..487793b5 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -2,7 +2,7 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import electron, {app, BrowserWindow} from 'electron'; +import {app, BrowserWindow} from 'electron'; import path from 'path'; import {Args} from 'types/args'; @@ -49,7 +49,7 @@ export function getLocalURL(urlPath: string, query?: Map, isMain const hostname = ''; const port = ''; if (mode === PRODUCTION) { - pathname = path.join(electron.app.getAppPath(), `${processPath}/${urlPath}`); + pathname = path.join(app.getAppPath(), `${processPath}/${urlPath}`); } else { pathname = path.resolve(__dirname, `../../dist/${processPath}/${urlPath}`); // TODO: find a better way to work with webpack on this } @@ -66,7 +66,7 @@ export function getLocalURL(urlPath: string, query?: Map, isMain export function getLocalPreload(file: string) { if (Utils.runMode() === PRODUCTION) { - return path.join(electron.app.getAppPath(), `${file}`); + return path.join(app.getAppPath(), `${file}`); } return path.resolve(__dirname, `../../dist/${file}`); }