[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
This commit is contained in:
156
src/main/app/app.test.js
Normal file
156
src/main/app/app.test.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
145
src/main/app/app.ts
Normal file
145
src/main/app/app.ts
Normal file
@@ -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})`);
|
||||
}
|
108
src/main/app/config.test.js
Normal file
108
src/main/app/config.test.js
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
75
src/main/app/config.ts
Normal file
75
src/main/app/config.ts
Normal file
@@ -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);
|
||||
}
|
17
src/main/app/index.ts
Normal file
17
src/main/app/index.ts
Normal file
@@ -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()}`);
|
||||
}
|
287
src/main/app/initialize.test.js
Normal file
287
src/main/app/initialize.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
381
src/main/app/initialize.ts
Normal file
381
src/main/app/initialize.ts
Normal file
@@ -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<void>((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);
|
||||
}
|
||||
}
|
||||
}
|
238
src/main/app/intercom.test.js
Normal file
238
src/main/app/intercom.test.js
Normal file
@@ -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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
215
src/main/app/intercom.ts
Normal file
215
src/main/app/intercom.ts
Normal file
@@ -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<unknown, Team>('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<Team, Team>('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<string, boolean>('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);
|
||||
}
|
||||
|
192
src/main/app/utils.test.js
Normal file
192
src/main/app/utils.test.js
Normal file
@@ -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<lolbad'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return url if deeplinking URL is valid', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
178
src/main/app/utils.ts
Normal file
178
src/main/app/utils.ts
Normal file
@@ -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<Promise<RemoteInfo | string | undefined>> = [];
|
||||
teams.forEach((team) => {
|
||||
const serverInfo = new ServerInfo(new MattermostServer(team.name, team.url));
|
||||
serverInfos.push(serverInfo.promise);
|
||||
});
|
||||
Promise.all(serverInfos).then((data: Array<RemoteInfo | string | undefined>) => {
|
||||
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<RemoteInfo | string | undefined>, 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);
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user