[MM-51871] Migrate mainWindow and settingsWindow to singletons (#2650)

* Migrate mainWindow to singleton

* Migrate settingsWindow to singleton

* PR feedback

* Missed a couple unwrapping cases
This commit is contained in:
Devin Binnie
2023-04-04 10:01:40 -04:00
committed by GitHub
parent c682cf5dd2
commit 22ec280945
46 changed files with 1131 additions and 990 deletions

View File

@@ -42,30 +42,10 @@ jest.mock('main/i18nManager', () => ({
describe('main/CriticalErrorHandler', () => { describe('main/CriticalErrorHandler', () => {
const criticalErrorHandler = new 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', () => { describe('processUncaughtExceptionHandler', () => {
beforeEach(() => { beforeEach(() => {
app.isReady.mockImplementation(() => true); app.isReady.mockImplementation(() => true);
criticalErrorHandler.setMainWindow({isVisible: true});
}); });
it('should throw error if app is not ready', () => { it('should throw error if app is not ready', () => {
@@ -76,16 +56,6 @@ describe('main/CriticalErrorHandler', () => {
expect(dialog.showMessageBox).not.toBeCalled(); 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 () => { it('should open external file on Show Details', async () => {
path.join.mockImplementation(() => 'testfile.txt'); path.join.mockImplementation(() => 'testfile.txt');
const promise = Promise.resolve({response: process.platform === 'darwin' ? 2 : 0}); const promise = Promise.resolve({response: process.platform === 'darwin' ? 2 : 0});

View File

@@ -7,72 +7,36 @@ import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import {app, BrowserWindow, dialog} from 'electron'; import {app, dialog} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import {localizeMessage} from 'main/i18nManager'; import {localizeMessage} from 'main/i18nManager';
function createErrorReport(err: Error) {
// eslint-disable-next-line no-undef
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return `Application: ${app.name} ${app.getVersion()}${__HASH_VERSION__ ? ` [commit: ${__HASH_VERSION__}]` : ''}\n` +
`Platform: ${os.type()} ${os.release()} ${os.arch()}\n` +
`${err.stack}`;
}
function openDetachedExternal(url: string) {
const spawnOption = {detached: true, stdio: 'ignore' as const};
switch (process.platform) {
case 'win32':
return spawn('cmd', ['/C', 'start', url], spawnOption);
case 'darwin':
return spawn('open', [url], spawnOption);
case 'linux':
return spawn('xdg-open', [url], spawnOption);
default:
return undefined;
}
}
export class CriticalErrorHandler { export class CriticalErrorHandler {
mainWindow?: BrowserWindow; init = () => {
process.on('unhandledRejection', this.processUncaughtExceptionHandler);
setMainWindow(mainWindow: BrowserWindow) { process.on('uncaughtException', this.processUncaughtExceptionHandler);
this.mainWindow = mainWindow;
} }
windowUnresponsiveHandler() { private processUncaughtExceptionHandler = (err: Error) => {
if (!this.mainWindow) {
return;
}
dialog.showMessageBox(this.mainWindow, {
type: 'warning',
title: app.name,
message: localizeMessage('main.CriticalErrorHandler.unresponsive.dialog.message', 'The window is no longer responsive.\nDo you wait until the window becomes responsive again?'),
buttons: [
localizeMessage('label.no', 'No'),
localizeMessage('label.yes', 'Yes'),
],
defaultId: 0,
}).then(({response}) => {
if (response === 0) {
log.error('BrowserWindow \'unresponsive\' event has been emitted');
app.relaunch();
}
});
}
processUncaughtExceptionHandler(err: Error) {
const file = path.join(app.getPath('userData'), `uncaughtException-${Date.now()}.txt`);
const report = createErrorReport(err);
fs.writeFileSync(file, report.replace(new RegExp('\\n', 'g'), os.EOL));
if (process.env.NODE_ENV === 'test') { if (process.env.NODE_ENV === 'test') {
return; return;
} }
if (app.isReady()) { if (app.isReady()) {
this.showExceptionDialog(err);
} else {
app.once('ready', () => {
this.showExceptionDialog(err);
});
}
}
private showExceptionDialog = (err: Error) => {
const file = path.join(app.getPath('userData'), `uncaughtException-${Date.now()}.txt`);
const report = this.createErrorReport(err);
fs.writeFileSync(file, report.replace(new RegExp('\\n', 'g'), os.EOL));
const buttons = [ const buttons = [
localizeMessage('main.CriticalErrorHandler.uncaughtException.button.showDetails', 'Show Details'), localizeMessage('main.CriticalErrorHandler.uncaughtException.button.showDetails', 'Show Details'),
localizeMessage('label.ok', 'OK'), localizeMessage('label.ok', 'OK'),
@@ -85,11 +49,7 @@ export class CriticalErrorHandler {
indexOfReopen = 0; indexOfReopen = 0;
indexOfShowDetails = 2; indexOfShowDetails = 2;
} }
if (!this.mainWindow?.isVisible) {
return;
}
dialog.showMessageBox( dialog.showMessageBox(
this.mainWindow,
{ {
type: 'error', type: 'error',
title: app.name, title: app.name,
@@ -111,7 +71,7 @@ export class CriticalErrorHandler {
let child; let child;
switch (response) { switch (response) {
case indexOfShowDetails: case indexOfShowDetails:
child = openDetachedExternal(file); child = this.openDetachedExternal(file);
if (child) { if (child) {
child.on( child.on(
'error', 'error',
@@ -128,10 +88,29 @@ export class CriticalErrorHandler {
} }
app.exit(-1); app.exit(-1);
}); });
} else {
log.error(`Window wasn't ready to handle the error: ${err}\ntrace: ${err.stack}`);
throw err;
} }
private openDetachedExternal = (url: string) => {
const spawnOption = {detached: true, stdio: 'ignore' as const};
switch (process.platform) {
case 'win32':
return spawn('cmd', ['/C', 'start', url], spawnOption);
case 'darwin':
return spawn('open', [url], spawnOption);
case 'linux':
return spawn('xdg-open', [url], spawnOption);
default:
return undefined;
}
}
private createErrorReport = (err: Error) => {
// eslint-disable-next-line no-undef
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return `Application: ${app.name} ${app.getVersion()}${__HASH_VERSION__ ? ` [commit: ${__HASH_VERSION__}]` : ''}\n` +
`Platform: ${os.type()} ${os.release()} ${os.arch()}\n` +
`${err.stack}`;
} }
} }

View File

@@ -6,7 +6,7 @@ import fs from 'fs';
import {shell, dialog} from 'electron'; import {shell, dialog} from 'electron';
import WindowManager from './windows/windowManager'; import MainWindow from './windows/mainWindow';
import {AllowProtocolDialog} from './allowProtocolDialog'; import {AllowProtocolDialog} from './allowProtocolDialog';
@@ -42,8 +42,8 @@ jest.mock('common/Validator', () => ({
validateAllowedProtocols: (protocols) => protocols, validateAllowedProtocols: (protocols) => protocols,
})); }));
jest.mock('./windows/windowManager', () => ({ jest.mock('./windows/mainWindow', () => ({
getMainWindow: jest.fn(), get: jest.fn(),
})); }));
jest.mock('main/i18nManager', () => ({ jest.mock('main/i18nManager', () => ({
@@ -117,7 +117,7 @@ describe('main/allowProtocolDialog', () => {
}); });
it('should not open message box if main window is missing', () => { it('should not open message box if main window is missing', () => {
WindowManager.getMainWindow.mockImplementation(() => null); MainWindow.get.mockImplementation(() => null);
allowProtocolDialog.handleDialogEvent('mattermost:', 'mattermost://community.mattermost.com'); allowProtocolDialog.handleDialogEvent('mattermost:', 'mattermost://community.mattermost.com');
expect(shell.openExternal).not.toBeCalled(); expect(shell.openExternal).not.toBeCalled();
expect(dialog.showMessageBox).not.toBeCalled(); expect(dialog.showMessageBox).not.toBeCalled();
@@ -125,7 +125,7 @@ describe('main/allowProtocolDialog', () => {
describe('main window not null', () => { describe('main window not null', () => {
beforeEach(() => { beforeEach(() => {
WindowManager.getMainWindow.mockImplementation(() => ({})); MainWindow.get.mockImplementation(() => ({}));
}); });
it('should open the window but not save when clicking Yes', async () => { it('should open the window but not save when clicking Yes', async () => {

View File

@@ -13,7 +13,7 @@ import {localizeMessage} from 'main/i18nManager';
import buildConfig from 'common/config/buildConfig'; import buildConfig from 'common/config/buildConfig';
import * as Validator from 'common/Validator'; import * as Validator from 'common/Validator';
import WindowManager from './windows/windowManager'; import MainWindow from './windows/mainWindow';
import {allowedProtocolFile} from './constants'; import {allowedProtocolFile} from './constants';
export class AllowProtocolDialog { export class AllowProtocolDialog {
@@ -47,7 +47,7 @@ export class AllowProtocolDialog {
shell.openExternal(URL); shell.openExternal(URL);
return; return;
} }
const mainWindow = WindowManager.getMainWindow(); const mainWindow = MainWindow.get();
if (!mainWindow) { if (!mainWindow) {
return; return;
} }

View File

@@ -4,6 +4,7 @@
import {app, dialog} from 'electron'; import {app, dialog} from 'electron';
import CertificateStore from 'main/certificateStore'; import CertificateStore from 'main/certificateStore';
import MainWindow from 'main/windows/mainWindow';
import WindowManager from 'main/windows/windowManager'; import WindowManager from 'main/windows/windowManager';
import {handleAppWillFinishLaunching, handleAppCertificateError, certificateErrorCallbacks} from 'main/app/app'; import {handleAppWillFinishLaunching, handleAppCertificateError, certificateErrorCallbacks} from 'main/app/app';
@@ -45,13 +46,15 @@ jest.mock('main/i18nManager', () => ({
})); }));
jest.mock('main/tray/tray', () => ({})); jest.mock('main/tray/tray', () => ({}));
jest.mock('main/windows/windowManager', () => ({ jest.mock('main/windows/windowManager', () => ({
getMainWindow: jest.fn(),
getViewNameByWebContentsId: jest.fn(), getViewNameByWebContentsId: jest.fn(),
getServerNameByWebContentsId: jest.fn(), getServerNameByWebContentsId: jest.fn(),
viewManager: { viewManager: {
views: new Map(), views: new Map(),
}, },
})); }));
jest.mock('main/windows/mainWindow', () => ({
get: jest.fn(),
}));
describe('main/app/app', () => { describe('main/app/app', () => {
describe('handleAppWillFinishLaunching', () => { describe('handleAppWillFinishLaunching', () => {
@@ -103,7 +106,7 @@ describe('main/app/app', () => {
const certificate = {}; const certificate = {};
beforeEach(() => { beforeEach(() => {
WindowManager.getMainWindow.mockReturnValue(mainWindow); MainWindow.get.mockReturnValue(mainWindow);
WindowManager.getServerNameByWebContentsId.mockReturnValue('test-team'); WindowManager.getServerNameByWebContentsId.mockReturnValue('test-team');
}); });

View File

@@ -12,6 +12,7 @@ import CertificateStore from 'main/certificateStore';
import {localizeMessage} from 'main/i18nManager'; import {localizeMessage} from 'main/i18nManager';
import {destroyTray} from 'main/tray/tray'; import {destroyTray} from 'main/tray/tray';
import WindowManager from 'main/windows/windowManager'; import WindowManager from 'main/windows/windowManager';
import MainWindow from 'main/windows/mainWindow';
import {getDeeplinkingURL, openDeepLink, resizeScreen} from './utils'; import {getDeeplinkingURL, openDeepLink, resizeScreen} from './utils';
@@ -115,7 +116,7 @@ export async function handleAppCertificateError(event: Event, webContents: WebCo
certificateErrorCallbacks.set(errorID, callback); certificateErrorCallbacks.set(errorID, callback);
// TODO: should we move this to window manager or provide a handler for dialogs? // TODO: should we move this to window manager or provide a handler for dialogs?
const mainWindow = WindowManager.getMainWindow(); const mainWindow = MainWindow.get();
if (!mainWindow) { if (!mainWindow) {
return; return;
} }

View File

@@ -61,9 +61,11 @@ export function handleConfigUpdate(newConfig: CombinedConfig) {
}); });
} }
if (app.isReady()) {
updateServerInfos(newConfig.teams); updateServerInfos(newConfig.teams);
WindowManager.initializeCurrentServerName(); WindowManager.initializeCurrentServerName();
handleMainWindowIsShown(); handleMainWindowIsShown();
}
handleUpdateMenuEvent(); handleUpdateMenuEvent();
if (newConfig.trayIconTheme) { if (newConfig.trayIconTheme) {

View File

@@ -9,6 +9,7 @@ import Config from 'common/config';
import urlUtils from 'common/utils/url'; import urlUtils from 'common/utils/url';
import parseArgs from 'main/ParseArgs'; import parseArgs from 'main/ParseArgs';
import MainWindow from 'main/windows/mainWindow';
import WindowManager from 'main/windows/windowManager'; import WindowManager from 'main/windows/windowManager';
import {initialize} from './initialize'; import {initialize} from './initialize';
@@ -138,8 +139,7 @@ jest.mock('main/badge', () => ({
})); }));
jest.mock('main/certificateManager', () => ({})); jest.mock('main/certificateManager', () => ({}));
jest.mock('main/CriticalErrorHandler', () => ({ jest.mock('main/CriticalErrorHandler', () => ({
processUncaughtExceptionHandler: jest.fn(), init: jest.fn(),
setMainWindow: jest.fn(),
})); }));
jest.mock('main/notifications', () => ({ jest.mock('main/notifications', () => ({
displayDownloadCompleted: jest.fn(), displayDownloadCompleted: jest.fn(),
@@ -157,13 +157,17 @@ jest.mock('main/UserActivityMonitor', () => ({
startMonitoring: jest.fn(), startMonitoring: jest.fn(),
})); }));
jest.mock('main/windows/windowManager', () => ({ jest.mock('main/windows/windowManager', () => ({
getMainWindow: jest.fn(),
showMainWindow: jest.fn(), showMainWindow: jest.fn(),
sendToMattermostViews: jest.fn(),
sendToRenderer: jest.fn(), sendToRenderer: jest.fn(),
getServerNameByWebContentsId: jest.fn(), getServerNameByWebContentsId: jest.fn(),
getServerURLFromWebContentsId: jest.fn(), getServerURLFromWebContentsId: jest.fn(),
})); }));
jest.mock('main/windows/settingsWindow', () => ({
show: jest.fn(),
}));
jest.mock('main/windows/mainWindow', () => ({
get: jest.fn(),
}));
const originalProcess = process; const originalProcess = process;
describe('main/app/initialize', () => { describe('main/app/initialize', () => {
beforeAll(() => { beforeAll(() => {
@@ -269,7 +273,7 @@ describe('main/app/initialize', () => {
expect(callback).toHaveBeenCalledWith(false); expect(callback).toHaveBeenCalledWith(false);
callback = jest.fn(); callback = jest.fn();
WindowManager.getMainWindow.mockReturnValue({webContents: {id: 1}}); MainWindow.get.mockReturnValue({webContents: {id: 1}});
session.defaultSession.setPermissionRequestHandler.mockImplementation((cb) => { session.defaultSession.setPermissionRequestHandler.mockImplementation((cb) => {
cb({id: 1, getURL: () => 'http://server-1.com'}, 'openExternal', callback); cb({id: 1, getURL: () => 'http://server-1.com'}, 'openExternal', callback);
}); });

View File

@@ -52,10 +52,12 @@ import CriticalErrorHandler from 'main/CriticalErrorHandler';
import downloadsManager from 'main/downloadsManager'; import downloadsManager from 'main/downloadsManager';
import i18nManager from 'main/i18nManager'; import i18nManager from 'main/i18nManager';
import parseArgs from 'main/ParseArgs'; import parseArgs from 'main/ParseArgs';
import SettingsWindow from 'main/windows/settingsWindow';
import TrustedOriginsStore from 'main/trustedOrigins'; import TrustedOriginsStore from 'main/trustedOrigins';
import {refreshTrayImages, setupTray} from 'main/tray/tray'; import {refreshTrayImages, setupTray} from 'main/tray/tray';
import UserActivityMonitor from 'main/UserActivityMonitor'; import UserActivityMonitor from 'main/UserActivityMonitor';
import WindowManager from 'main/windows/windowManager'; import WindowManager from 'main/windows/windowManager';
import MainWindow from 'main/windows/mainWindow';
import {protocols} from '../../../electron-builder.json'; import {protocols} from '../../../electron-builder.json';
@@ -105,7 +107,7 @@ export const mainProtocol = protocols?.[0]?.schemes?.[0];
* Main entry point for the application, ensures that everything initializes in the proper order * Main entry point for the application, ensures that everything initializes in the proper order
*/ */
export async function initialize() { export async function initialize() {
process.on('uncaughtException', CriticalErrorHandler.processUncaughtExceptionHandler.bind(CriticalErrorHandler)); CriticalErrorHandler.init();
global.willAppQuit = false; global.willAppQuit = false;
// initialization that can run before the app is ready // initialization that can run before the app is ready
@@ -258,7 +260,7 @@ function initializeInterCommunicationEventListeners() {
ipcMain.on(WINDOW_MAXIMIZE, WindowManager.maximize); ipcMain.on(WINDOW_MAXIMIZE, WindowManager.maximize);
ipcMain.on(WINDOW_MINIMIZE, WindowManager.minimize); ipcMain.on(WINDOW_MINIMIZE, WindowManager.minimize);
ipcMain.on(WINDOW_RESTORE, WindowManager.restore); ipcMain.on(WINDOW_RESTORE, WindowManager.restore);
ipcMain.on(SHOW_SETTINGS_WINDOW, WindowManager.showSettingsWindow); ipcMain.on(SHOW_SETTINGS_WINDOW, SettingsWindow.show);
ipcMain.handle(GET_AVAILABLE_SPELL_CHECKER_LANGUAGES, () => session.defaultSession.availableSpellCheckerLanguages); ipcMain.handle(GET_AVAILABLE_SPELL_CHECKER_LANGUAGES, () => session.defaultSession.availableSpellCheckerLanguages);
ipcMain.handle(GET_DOWNLOAD_LOCATION, handleSelectDownload); ipcMain.handle(GET_DOWNLOAD_LOCATION, handleSelectDownload);
ipcMain.on(START_UPDATE_DOWNLOAD, handleStartDownload); ipcMain.on(START_UPDATE_DOWNLOAD, handleStartDownload);
@@ -344,8 +346,6 @@ function initializeAfterAppReady() {
WindowManager.showMainWindow(deeplinkingURL); WindowManager.showMainWindow(deeplinkingURL);
CriticalErrorHandler.setMainWindow(WindowManager.getMainWindow()!);
// listen for status updates and pass on to renderer // listen for status updates and pass on to renderer
UserActivityMonitor.on('status', (status) => { UserActivityMonitor.on('status', (status) => {
log.debug('Initialize.UserActivityMonitor.on(status)', status); log.debug('Initialize.UserActivityMonitor.on(status)', status);
@@ -396,7 +396,7 @@ function initializeAfterAppReady() {
} }
// is the request coming from the renderer? // is the request coming from the renderer?
const mainWindow = WindowManager.getMainWindow(); const mainWindow = MainWindow.get();
if (mainWindow && webContents.id === mainWindow.webContents.id) { if (mainWindow && webContents.id === mainWindow.webContents.id) {
callback(true); callback(true);
return; return;

View File

@@ -5,6 +5,7 @@ import Config from 'common/config';
import {getDefaultTeamWithTabsFromTeam} from 'common/tabs/TabView'; import {getDefaultTeamWithTabsFromTeam} from 'common/tabs/TabView';
import {getLocalURLString, getLocalPreload} from 'main/utils'; import {getLocalURLString, getLocalPreload} from 'main/utils';
import MainWindow from 'main/windows/mainWindow';
import ModalManager from 'main/views/modalManager'; import ModalManager from 'main/views/modalManager';
import WindowManager from 'main/windows/windowManager'; import WindowManager from 'main/windows/windowManager';
@@ -33,10 +34,12 @@ jest.mock('main/views/modalManager', () => ({
addModal: jest.fn(), addModal: jest.fn(),
})); }));
jest.mock('main/windows/windowManager', () => ({ jest.mock('main/windows/windowManager', () => ({
getMainWindow: jest.fn(),
switchServer: jest.fn(), switchServer: jest.fn(),
switchTab: jest.fn(), switchTab: jest.fn(),
})); }));
jest.mock('main/windows/mainWindow', () => ({
get: jest.fn(),
}));
jest.mock('./app', () => ({})); jest.mock('./app', () => ({}));
jest.mock('./utils', () => ({ jest.mock('./utils', () => ({
@@ -111,7 +114,7 @@ describe('main/app/intercom', () => {
beforeEach(() => { beforeEach(() => {
getLocalURLString.mockReturnValue('/some/index.html'); getLocalURLString.mockReturnValue('/some/index.html');
getLocalPreload.mockReturnValue('/some/preload.js'); getLocalPreload.mockReturnValue('/some/preload.js');
WindowManager.getMainWindow.mockReturnValue({}); MainWindow.get.mockReturnValue({});
Config.set.mockImplementation((name, value) => { Config.set.mockImplementation((name, value) => {
Config[name] = value; Config[name] = value;
@@ -150,7 +153,7 @@ describe('main/app/intercom', () => {
beforeEach(() => { beforeEach(() => {
getLocalURLString.mockReturnValue('/some/index.html'); getLocalURLString.mockReturnValue('/some/index.html');
getLocalPreload.mockReturnValue('/some/preload.js'); getLocalPreload.mockReturnValue('/some/preload.js');
WindowManager.getMainWindow.mockReturnValue({}); MainWindow.get.mockReturnValue({});
Config.set.mockImplementation((name, value) => { Config.set.mockImplementation((name, value) => {
Config[name] = value; Config[name] = value;
@@ -193,7 +196,7 @@ describe('main/app/intercom', () => {
beforeEach(() => { beforeEach(() => {
getLocalURLString.mockReturnValue('/some/index.html'); getLocalURLString.mockReturnValue('/some/index.html');
getLocalPreload.mockReturnValue('/some/preload.js'); getLocalPreload.mockReturnValue('/some/preload.js');
WindowManager.getMainWindow.mockReturnValue({}); MainWindow.get.mockReturnValue({});
Config.set.mockImplementation((name, value) => { Config.set.mockImplementation((name, value) => {
Config[name] = value; Config[name] = value;
@@ -242,7 +245,7 @@ describe('main/app/intercom', () => {
beforeEach(() => { beforeEach(() => {
getLocalURLString.mockReturnValue('/some/index.html'); getLocalURLString.mockReturnValue('/some/index.html');
getLocalPreload.mockReturnValue('/some/preload.js'); getLocalPreload.mockReturnValue('/some/preload.js');
WindowManager.getMainWindow.mockReturnValue({}); MainWindow.get.mockReturnValue({});
Config.set.mockImplementation((name, value) => { Config.set.mockImplementation((name, value) => {
Config[name] = value; Config[name] = value;
@@ -263,7 +266,7 @@ describe('main/app/intercom', () => {
it('MM-48079 should not show onboarding screen or server screen if GPO server is pre-configured', () => { it('MM-48079 should not show onboarding screen or server screen if GPO server is pre-configured', () => {
getLocalURLString.mockReturnValue('/some/index.html'); getLocalURLString.mockReturnValue('/some/index.html');
getLocalPreload.mockReturnValue('/some/preload.js'); getLocalPreload.mockReturnValue('/some/preload.js');
WindowManager.getMainWindow.mockReturnValue({ MainWindow.get.mockReturnValue({
isVisible: () => true, isVisible: () => true,
}); });

View File

@@ -15,6 +15,7 @@ import {displayMention} from 'main/notifications';
import {getLocalPreload, getLocalURLString} from 'main/utils'; import {getLocalPreload, getLocalURLString} from 'main/utils';
import ModalManager from 'main/views/modalManager'; import ModalManager from 'main/views/modalManager';
import WindowManager from 'main/windows/windowManager'; import WindowManager from 'main/windows/windowManager';
import MainWindow from 'main/windows/mainWindow';
import {handleAppBeforeQuit} from './app'; import {handleAppBeforeQuit} from './app';
import {updateServerInfos} from './utils'; import {updateServerInfos} from './utils';
@@ -121,7 +122,7 @@ export function handleMainWindowIsShown() {
* calls of this function will notification re-evaluate the booleans passed to "handleShowOnboardingScreens". * calls of this function will notification re-evaluate the booleans passed to "handleShowOnboardingScreens".
*/ */
const mainWindow = WindowManager.getMainWindow(); const mainWindow = MainWindow.get();
log.debug('intercom.handleMainWindowIsShown', {configTeams: Config.teams, showWelcomeScreen, showNewServerModal, mainWindow: Boolean(mainWindow)}); log.debug('intercom.handleMainWindowIsShown', {configTeams: Config.teams, showWelcomeScreen, showNewServerModal, mainWindow: Boolean(mainWindow)});
if (mainWindow?.isVisible()) { if (mainWindow?.isVisible()) {
@@ -140,7 +141,7 @@ export function handleNewServerModal() {
const preload = getLocalPreload('desktopAPI.js'); const preload = getLocalPreload('desktopAPI.js');
const mainWindow = WindowManager.getMainWindow(); const mainWindow = MainWindow.get();
if (!mainWindow) { if (!mainWindow) {
return; return;
} }
@@ -172,7 +173,7 @@ export function handleEditServerModal(e: IpcMainEvent, name: string) {
const preload = getLocalPreload('desktopAPI.js'); const preload = getLocalPreload('desktopAPI.js');
const mainWindow = WindowManager.getMainWindow(); const mainWindow = MainWindow.get();
if (!mainWindow) { if (!mainWindow) {
return; return;
} }
@@ -214,7 +215,7 @@ export function handleRemoveServerModal(e: IpcMainEvent, name: string) {
const preload = getLocalPreload('desktopAPI.js'); const preload = getLocalPreload('desktopAPI.js');
const mainWindow = WindowManager.getMainWindow(); const mainWindow = MainWindow.get();
if (!mainWindow) { if (!mainWindow) {
return; return;
} }
@@ -254,7 +255,7 @@ export function handleWelcomeScreenModal() {
const preload = getLocalPreload('desktopAPI.js'); const preload = getLocalPreload('desktopAPI.js');
const mainWindow = WindowManager.getMainWindow(); const mainWindow = MainWindow.get();
if (!mainWindow) { if (!mainWindow) {
return; return;
} }
@@ -293,7 +294,7 @@ export function handleOpenAppMenu() {
return; return;
} }
windowMenu.popup({ windowMenu.popup({
window: WindowManager.getMainWindow(), window: MainWindow.get(),
x: 18, x: 18,
y: 18, y: 18,
}); });

View File

@@ -60,6 +60,7 @@ jest.mock('main/server/serverInfo', () => ({
ServerInfo: jest.fn(), ServerInfo: jest.fn(),
})); }));
jest.mock('main/tray/tray', () => ({})); jest.mock('main/tray/tray', () => ({}));
jest.mock('main/windows/mainWindow', () => ({}));
jest.mock('main/windows/windowManager', () => ({})); jest.mock('main/windows/windowManager', () => ({}));
jest.mock('./initialize', () => ({ jest.mock('./initialize', () => ({

View File

@@ -28,6 +28,7 @@ import {createMenu as createTrayMenu} from 'main/menus/tray';
import {ServerInfo} from 'main/server/serverInfo'; import {ServerInfo} from 'main/server/serverInfo';
import {setTrayMenu} from 'main/tray/tray'; import {setTrayMenu} from 'main/tray/tray';
import WindowManager from 'main/windows/windowManager'; import WindowManager from 'main/windows/windowManager';
import MainWindow from 'main/windows/mainWindow';
import {mainProtocol} from './initialize'; import {mainProtocol} from './initialize';
@@ -140,7 +141,7 @@ export function wasUpdated(lastAppVersion?: string) {
export function clearAppCache() { export function clearAppCache() {
// TODO: clear cache on browserviews, not in the renderer. // TODO: clear cache on browserviews, not in the renderer.
const mainWindow = WindowManager.getMainWindow(); const mainWindow = MainWindow.get();
if (mainWindow) { if (mainWindow) {
mainWindow.webContents.session.clearCache().then(mainWindow.reload); mainWindow.webContents.session.clearCache().then(mainWindow.reload);
} else { } else {

View File

@@ -3,6 +3,7 @@
'use strict'; 'use strict';
import {AuthManager} from 'main/authManager'; import {AuthManager} from 'main/authManager';
import MainWindow from 'main/windows/mainWindow';
import WindowManager from 'main/windows/windowManager'; import WindowManager from 'main/windows/windowManager';
import ModalManager from 'main/views/modalManager'; import ModalManager from 'main/views/modalManager';
@@ -84,8 +85,11 @@ jest.mock('main/trustedOrigins', () => ({
save: jest.fn(), save: jest.fn(),
})); }));
jest.mock('main/windows/mainWindow', () => ({
get: jest.fn().mockImplementation(() => ({})),
}));
jest.mock('main/windows/windowManager', () => ({ jest.mock('main/windows/windowManager', () => ({
getMainWindow: jest.fn().mockImplementation(() => ({})),
getServerURLFromWebContentsId: jest.fn(), getServerURLFromWebContentsId: jest.fn(),
})); }));
@@ -151,7 +155,7 @@ describe('main/authManager', () => {
const authManager = new AuthManager(); const authManager = new AuthManager();
it('should not pop modal when no main window exists', () => { it('should not pop modal when no main window exists', () => {
WindowManager.getMainWindow.mockImplementationOnce(() => null); MainWindow.get.mockImplementationOnce(() => null);
authManager.popLoginModal({url: 'http://anormalurl.com'}, { authManager.popLoginModal({url: 'http://anormalurl.com'}, {
isProxy: false, isProxy: false,
host: 'anormalurl', host: 'anormalurl',
@@ -219,7 +223,7 @@ describe('main/authManager', () => {
const authManager = new AuthManager(); const authManager = new AuthManager();
it('should not pop modal when no main window exists', () => { it('should not pop modal when no main window exists', () => {
WindowManager.getMainWindow.mockImplementationOnce(() => null); MainWindow.get.mockImplementationOnce(() => null);
authManager.popPermissionModal({url: 'http://anormalurl.com'}, { authManager.popPermissionModal({url: 'http://anormalurl.com'}, {
isProxy: false, isProxy: false,
host: 'anormalurl', host: 'anormalurl',

View File

@@ -13,6 +13,7 @@ import modalManager from 'main/views/modalManager';
import TrustedOriginsStore from 'main/trustedOrigins'; import TrustedOriginsStore from 'main/trustedOrigins';
import {getLocalURLString, getLocalPreload} from 'main/utils'; import {getLocalURLString, getLocalPreload} from 'main/utils';
import WindowManager from 'main/windows/windowManager'; import WindowManager from 'main/windows/windowManager';
import MainWindow from 'main/windows/mainWindow';
const preload = getLocalPreload('desktopAPI.js'); const preload = getLocalPreload('desktopAPI.js');
const loginModalHtml = getLocalURLString('loginModal.html'); const loginModalHtml = getLocalURLString('loginModal.html');
@@ -52,7 +53,7 @@ export class AuthManager {
} }
popLoginModal = (request: AuthenticationResponseDetails, authInfo: AuthInfo) => { popLoginModal = (request: AuthenticationResponseDetails, authInfo: AuthInfo) => {
const mainWindow = WindowManager.getMainWindow(); const mainWindow = MainWindow.get();
if (!mainWindow) { if (!mainWindow) {
return; return;
} }
@@ -71,7 +72,7 @@ export class AuthManager {
} }
popPermissionModal = (request: AuthenticationResponseDetails, authInfo: AuthInfo, permission: PermissionType) => { popPermissionModal = (request: AuthenticationResponseDetails, authInfo: AuthInfo, permission: PermissionType) => {
const mainWindow = WindowManager.getMainWindow(); const mainWindow = MainWindow.get();
if (!mainWindow) { if (!mainWindow) {
return; return;
} }

View File

@@ -2,24 +2,29 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
'use strict'; 'use strict';
import {app} from 'electron'; import {app, nativeImage} from 'electron';
import MainWindow from './windows/mainWindow';
import * as Badge from './badge'; import * as Badge from './badge';
import WindowManager from './windows/windowManager';
jest.mock('electron', () => ({ jest.mock('electron', () => ({
app: { app: {
dock: { dock: {
setBadge: jest.fn(), setBadge: jest.fn(),
}, },
}, },
nativeImage: {
createFromDataURL: jest.fn(),
},
})); }));
jest.mock('./appState', () => ({ jest.mock('./appState', () => ({
updateBadge: jest.fn(), updateBadge: jest.fn(),
})); }));
jest.mock('./windows/mainWindow', () => ({
get: jest.fn(),
}));
jest.mock('./windows/windowManager', () => ({ jest.mock('./windows/windowManager', () => ({
setOverlayIcon: jest.fn(), setOverlayIcon: jest.fn(),
})); }));
@@ -30,35 +35,69 @@ jest.mock('main/i18nManager', () => ({
describe('main/badge', () => { describe('main/badge', () => {
describe('showBadgeWindows', () => { describe('showBadgeWindows', () => {
it('should show dot when session expired', () => { const window = {
setOverlayIcon: jest.fn(),
webContents: {
executeJavaScript: jest.fn(),
},
};
let promise;
beforeEach(() => {
window.webContents.executeJavaScript.mockImplementation((code) => {
promise = new Promise((resolve) => resolve(code));
return promise;
});
nativeImage.createFromDataURL.mockImplementation((url) => url);
Badge.setUnreadBadgeSetting(false);
MainWindow.get.mockReturnValue(window);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should show dot when session expired', async () => {
Badge.showBadgeWindows(true, 0, false); Badge.showBadgeWindows(true, 0, false);
expect(WindowManager.setOverlayIcon).toBeCalledWith('•', expect.any(String), expect.any(Boolean)); await promise;
expect(window.setOverlayIcon).toBeCalledWith(expect.stringContaining('window.drawBadge(\'•\', false)'), expect.any(String));
}); });
it('should show mention count when has mention count', () => { it('should show mention count when has mention count', async () => {
Badge.showBadgeWindows(true, 50, false); Badge.showBadgeWindows(true, 50, false);
expect(WindowManager.setOverlayIcon).toBeCalledWith('50', expect.any(String), false); await promise;
expect(window.setOverlayIcon).toBeCalledWith(expect.stringContaining('window.drawBadge(\'50\', false)'), expect.any(String));
}); });
it('should show 99+ when has mention count over 99', () => { it('should show 99+ when has mention count over 99', async () => {
Badge.showBadgeWindows(true, 200, false); Badge.showBadgeWindows(true, 200, false);
expect(WindowManager.setOverlayIcon).toBeCalledWith('99+', expect.any(String), true); await promise;
expect(window.setOverlayIcon).toBeCalledWith(expect.stringContaining('window.drawBadge(\'99+\', true)'), expect.any(String));
}); });
it('should not show dot when has unreads but setting is off', () => { it('should not show dot when has unreads but setting is off', async () => {
Badge.showBadgeWindows(false, 0, true); Badge.showBadgeWindows(false, 0, true);
expect(WindowManager.setOverlayIcon).not.toBeCalledWith('•', expect.any(String), expect.any(Boolean)); await promise;
expect(window.setOverlayIcon).toBeCalledWith(null, expect.any(String));
}); });
it('should show dot when has unreads', () => { it('should show dot when has unreads', async () => {
Badge.setUnreadBadgeSetting(true); Badge.setUnreadBadgeSetting(true);
Badge.showBadgeWindows(false, 0, true); Badge.showBadgeWindows(false, 0, true);
expect(WindowManager.setOverlayIcon).toBeCalledWith('•', expect.any(String), expect.any(Boolean)); await promise;
Badge.setUnreadBadgeSetting(false); expect(window.setOverlayIcon).toBeCalledWith(expect.stringContaining('window.drawBadge(\'•\', false)'), expect.any(String));
}); });
}); });
describe('showBadgeOSX', () => { describe('showBadgeOSX', () => {
beforeEach(() => {
Badge.setUnreadBadgeSetting(false);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should show dot when session expired', () => { it('should show dot when session expired', () => {
Badge.showBadgeOSX(true, 0, false); Badge.showBadgeOSX(true, 0, false);
expect(app.dock.setBadge).toBeCalledWith('•'); expect(app.dock.setBadge).toBeCalledWith('•');

View File

@@ -2,20 +2,78 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {app} from 'electron'; import {BrowserWindow, app, nativeImage} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import {UPDATE_BADGE} from 'common/communication'; import {UPDATE_BADGE} from 'common/communication';
import {localizeMessage} from 'main/i18nManager'; import {localizeMessage} from 'main/i18nManager';
import WindowManager from './windows/windowManager';
import * as AppState from './appState'; import * as AppState from './appState';
import MainWindow from './windows/mainWindow';
const MAX_WIN_COUNT = 99; const MAX_WIN_COUNT = 99;
let showUnreadBadgeSetting: boolean; let showUnreadBadgeSetting: boolean;
/**
* Badge generation for Windows
*/
function drawBadge(text: string, small: boolean) {
const scale = 2; // should rely display dpi
const size = (small ? 20 : 16) * scale;
const canvas = document.createElement('canvas');
canvas.setAttribute('width', `${size}`);
canvas.setAttribute('height', `${size}`);
const ctx = canvas.getContext('2d');
if (!ctx) {
log.error('Could not create canvas context');
return null;
}
// circle
ctx.fillStyle = '#FF1744'; // Material Red A400
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.fill();
// text
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = (11 * scale) + 'px sans-serif';
ctx.fillText(text, size / 2, size / 2, size);
return canvas.toDataURL();
}
function createDataURL(win: BrowserWindow, text: string, small: boolean) {
// since we don't have a document/canvas object in the main process, we use the webcontents from the window to draw.
const code = `
window.drawBadge = ${drawBadge};
window.drawBadge('${text || ''}', ${small});
`;
return win.webContents.executeJavaScript(code);
}
async function setOverlayIcon(badgeText: string | undefined, description: string, small: boolean) {
let overlay = null;
const mainWindow = MainWindow.get();
if (mainWindow) {
if (badgeText) {
try {
const dataUrl = await createDataURL(mainWindow, badgeText, small);
overlay = nativeImage.createFromDataURL(dataUrl);
} catch (err) {
log.error('Could not generate a badge:', err);
}
}
mainWindow.setOverlayIcon(overlay, description);
}
}
export function showBadgeWindows(sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean) { export function showBadgeWindows(sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean) {
let description = localizeMessage('main.badge.noUnreads', 'You have no unread messages'); let description = localizeMessage('main.badge.noUnreads', 'You have no unread messages');
let text; let text;
@@ -29,7 +87,7 @@ export function showBadgeWindows(sessionExpired: boolean, mentionCount: number,
text = '•'; text = '•';
description = localizeMessage('main.badge.sessionExpired', 'Session Expired: Please sign in to continue receiving notifications.'); description = localizeMessage('main.badge.sessionExpired', 'Session Expired: Please sign in to continue receiving notifications.');
} }
WindowManager.setOverlayIcon(text, description, mentionCount > 99); setOverlayIcon(text, description, mentionCount > 99);
} }
export function showBadgeOSX(sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean) { export function showBadgeOSX(sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean) {

View File

@@ -2,12 +2,12 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
'use strict'; 'use strict';
import WindowManager from 'main/windows/windowManager'; import MainWindow from 'main/windows/mainWindow';
import ModalManager from 'main/views/modalManager'; import ModalManager from 'main/views/modalManager';
import {CertificateManager} from 'main/certificateManager'; import {CertificateManager} from 'main/certificateManager';
jest.mock('main/windows/windowManager', () => ({ jest.mock('main/windows/mainWindow', () => ({
getMainWindow: jest.fn().mockImplementation(() => ({})), get: jest.fn().mockImplementation(() => ({})),
})); }));
jest.mock('main/views/modalManager', () => ({ jest.mock('main/views/modalManager', () => ({
@@ -50,7 +50,7 @@ describe('main/certificateManager', () => {
const certificateManager = new CertificateManager(); const certificateManager = new CertificateManager();
it('should not pop modal when no main window exists', () => { it('should not pop modal when no main window exists', () => {
WindowManager.getMainWindow.mockImplementationOnce(() => null); MainWindow.get.mockImplementationOnce(() => null);
certificateManager.popCertificateModal('http://anormalurl.com', [{data: 'test 1'}, {data: 'test 2'}, {data: 'test 3'}]); certificateManager.popCertificateModal('http://anormalurl.com', [{data: 'test 1'}, {data: 'test 2'}, {data: 'test 3'}]);
expect(ModalManager.addModal).not.toBeCalled(); expect(ModalManager.addModal).not.toBeCalled();
}); });

View File

@@ -5,10 +5,9 @@ import {Certificate, WebContents} from 'electron';
import {CertificateModalData} from 'types/certificate'; import {CertificateModalData} from 'types/certificate';
import WindowManager from './windows/windowManager';
import modalManager from './views/modalManager'; import modalManager from './views/modalManager';
import {getLocalURLString, getLocalPreload} from './utils'; import {getLocalURLString, getLocalPreload} from './utils';
import MainWindow from './windows/mainWindow';
const preload = getLocalPreload('desktopAPI.js'); const preload = getLocalPreload('desktopAPI.js');
const html = getLocalURLString('certificateModal.html'); const html = getLocalURLString('certificateModal.html');
@@ -39,7 +38,7 @@ export class CertificateManager {
} }
popCertificateModal = (url: string, list: Certificate[]) => { popCertificateModal = (url: string, list: Certificate[]) => {
const mainWindow = WindowManager.getMainWindow(); const mainWindow = MainWindow.get();
if (!mainWindow) { if (!mainWindow) {
return; return;
} }

View File

@@ -3,9 +3,7 @@
import Diagnostics from '.'; import Diagnostics from '.';
jest.mock('main/windows/windowManager', () => ({ jest.mock('main/windows/mainWindow', () => ({}));
mainWindow: {},
}));
jest.mock('common/config', () => ({ jest.mock('common/config', () => ({
configFilePath: 'mock/config/filepath/', configFilePath: 'mock/config/filepath/',
})); }));

View File

@@ -5,7 +5,7 @@ import {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics'; import {DiagnosticStepResponse} from 'types/diagnostics';
import windowManager from 'main/windows/windowManager'; import MainWindow from 'main/windows/mainWindow';
import DiagnosticsStep from '../DiagnosticStep'; import DiagnosticsStep from '../DiagnosticStep';
@@ -17,11 +17,11 @@ const stepDescriptiveName = 'BrowserWindowsChecks';
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => { const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try { try {
/** Main window check */ /** Main window check */
if (!windowManager.mainWindowReady) { if (!MainWindow.isReady) {
throw new Error('Main window not ready'); throw new Error('Main window not ready');
} }
const mainWindowVisibilityStatus = browserWindowVisibilityStatus('mainWindow', windowManager.mainWindow); const mainWindowVisibilityStatus = browserWindowVisibilityStatus('mainWindow', MainWindow.get());
const webContentsOk = webContentsCheck(windowManager.mainWindow?.webContents); const webContentsOk = webContentsCheck(MainWindow.get()?.webContents);
if (mainWindowVisibilityStatus.some((status) => !status.ok) || !webContentsOk) { if (mainWindowVisibilityStatus.some((status) => !status.ok) || !webContentsOk) {
return { return {

View File

@@ -76,6 +76,7 @@ jest.mock('fs', () => ({
jest.mock('macos-notification-state', () => ({ jest.mock('macos-notification-state', () => ({
getDoNotDisturb: jest.fn(), getDoNotDisturb: jest.fn(),
})); }));
jest.mock('main/notifications', () => ({}));
jest.mock('main/windows/windowManager', () => ({ jest.mock('main/windows/windowManager', () => ({
sendToRenderer: jest.fn(), sendToRenderer: jest.fn(),
})); }));

View File

@@ -49,10 +49,15 @@ jest.mock('macos-notification-state', () => ({
jest.mock('main/i18nManager', () => ({ jest.mock('main/i18nManager', () => ({
localizeMessage: jest.fn(), localizeMessage: jest.fn(),
})); }));
jest.mock('main/downloadsManager', () => ({
hasDownloads: jest.fn(),
}));
jest.mock('main/diagnostics', () => ({}));
jest.mock('main/windows/windowManager', () => ({ jest.mock('main/windows/windowManager', () => ({
getCurrentTeamName: jest.fn(), getCurrentTeamName: jest.fn(),
sendToRenderer: jest.fn(), sendToRenderer: jest.fn(),
})); }));
jest.mock('main/windows/settingsWindow', () => ({}));
jest.mock('common/tabs/TabView', () => ({ jest.mock('common/tabs/TabView', () => ({
getTabDisplayName: (name) => name, getTabDisplayName: (name) => name,
})); }));

View File

@@ -16,6 +16,7 @@ import WindowManager from 'main/windows/windowManager';
import {UpdateManager} from 'main/autoUpdater'; import {UpdateManager} from 'main/autoUpdater';
import downloadsManager from 'main/downloadsManager'; import downloadsManager from 'main/downloadsManager';
import Diagnostics from 'main/diagnostics'; import Diagnostics from 'main/diagnostics';
import SettingsWindow from 'main/windows/settingsWindow';
export function createTemplate(config: Config, updateManager: UpdateManager) { export function createTemplate(config: Config, updateManager: UpdateManager) {
const separatorItem: MenuItemConstructorOptions = { const separatorItem: MenuItemConstructorOptions = {
@@ -43,7 +44,7 @@ export function createTemplate(config: Config, updateManager: UpdateManager) {
label: settingsLabel, label: settingsLabel,
accelerator: 'CmdOrCtrl+,', accelerator: 'CmdOrCtrl+,',
click() { click() {
WindowManager.showSettingsWindow(); SettingsWindow.show();
}, },
}); });

View File

@@ -9,6 +9,7 @@ jest.mock('main/i18nManager', () => ({
localizeMessage: jest.fn(), localizeMessage: jest.fn(),
})); }));
jest.mock('main/windows/settingsWindow', () => ({}));
jest.mock('main/windows/windowManager', () => ({})); jest.mock('main/windows/windowManager', () => ({}));
describe('main/menus/tray', () => { describe('main/menus/tray', () => {

View File

@@ -8,6 +8,7 @@ import {CombinedConfig} from 'types/config';
import WindowManager from 'main/windows/windowManager'; import WindowManager from 'main/windows/windowManager';
import {localizeMessage} from 'main/i18nManager'; import {localizeMessage} from 'main/i18nManager';
import SettingsWindow from 'main/windows/settingsWindow';
export function createTemplate(config: CombinedConfig) { export function createTemplate(config: CombinedConfig) {
const teams = config.teams; const teams = config.teams;
@@ -24,7 +25,7 @@ export function createTemplate(config: CombinedConfig) {
}, { }, {
label: process.platform === 'darwin' ? localizeMessage('main.menus.tray.preferences', 'Preferences...') : localizeMessage('main.menus.tray.settings', 'Settings'), label: process.platform === 'darwin' ? localizeMessage('main.menus.tray.preferences', 'Preferences...') : localizeMessage('main.menus.tray.settings', 'Settings'),
click: () => { click: () => {
WindowManager.showSettingsWindow(); SettingsWindow.show();
}, },
}, { }, {
type: 'separator', type: 'separator',

View File

@@ -4,16 +4,18 @@
'use strict'; 'use strict';
import cp from 'child_process'; import cp from 'child_process';
import {Notification, shell} from 'electron'; import {Notification, shell, app} from 'electron';
import {getFocusAssist} from 'windows-focus-assist'; import {getFocusAssist} from 'windows-focus-assist';
import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state';
import {PLAY_SOUND} from 'common/communication'; import {PLAY_SOUND} from 'common/communication';
import Config from 'common/config';
import {TAB_MESSAGING} from 'common/tabs/TabView'; import {TAB_MESSAGING} from 'common/tabs/TabView';
import {localizeMessage} from 'main/i18nManager'; import {localizeMessage} from 'main/i18nManager';
import MainWindow from '../windows/mainWindow';
import WindowManager from '../windows/windowManager'; import WindowManager from '../windows/windowManager';
import getLinuxDoNotDisturb from './dnd-linux'; import getLinuxDoNotDisturb from './dnd-linux';
@@ -55,6 +57,9 @@ jest.mock('electron', () => {
return { return {
app: { app: {
getAppPath: () => '/path/to/app', getAppPath: () => '/path/to/app',
dock: {
bounce: jest.fn(),
},
}, },
Notification: NotificationMock, Notification: NotificationMock,
shell: { shell: {
@@ -70,7 +75,9 @@ jest.mock('windows-focus-assist', () => ({
jest.mock('macos-notification-state', () => ({ jest.mock('macos-notification-state', () => ({
getDoNotDisturb: jest.fn(), getDoNotDisturb: jest.fn(),
})); }));
jest.mock('../windows/mainWindow', () => ({
get: jest.fn(),
}));
jest.mock('../windows/windowManager', () => ({ jest.mock('../windows/windowManager', () => ({
getServerNameByWebContentsId: () => 'server_name', getServerNameByWebContentsId: () => 'server_name',
sendToRenderer: jest.fn(), sendToRenderer: jest.fn(),
@@ -82,12 +89,25 @@ jest.mock('main/i18nManager', () => ({
localizeMessage: jest.fn(), localizeMessage: jest.fn(),
})); }));
jest.mock('common/config', () => ({}));
describe('main/notifications', () => { describe('main/notifications', () => {
describe('displayMention', () => { describe('displayMention', () => {
const mainWindow = {
flashFrame: jest.fn(),
};
beforeEach(() => { beforeEach(() => {
Notification.isSupported.mockImplementation(() => true); Notification.isSupported.mockImplementation(() => true);
getFocusAssist.mockReturnValue({value: false}); getFocusAssist.mockReturnValue({value: false});
getDarwinDoNotDisturb.mockReturnValue(false); getDarwinDoNotDisturb.mockReturnValue(false);
Config.notifications = {};
MainWindow.get.mockReturnValue(mainWindow);
});
afterEach(() => {
jest.resetAllMocks();
Config.notifications = {};
}); });
it('should do nothing when Notification is not supported', () => { it('should do nothing when Notification is not supported', () => {
@@ -220,11 +240,108 @@ describe('main/notifications', () => {
); );
const mention = mentions.find((m) => m.body === 'mention_click_body'); const mention = mentions.find((m) => m.body === 'mention_click_body');
mention.value.click(); mention.value.click();
expect(WindowManager.switchTab).toHaveBeenCalledWith('server_name', TAB_MESSAGING); expect(WindowManager.switchTab).toBeCalledWith('server_name', TAB_MESSAGING);
});
it('linux/windows - should not flash frame when config item is not set', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'linux',
});
displayMention(
'click_test',
'mention_click_body',
{id: 'channel_id'},
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1, send: jest.fn()},
{},
);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
expect(mainWindow.flashFrame).not.toBeCalled();
});
it('linux/windows - should flash frame when config item is set', () => {
Config.notifications = {
flashWindow: true,
};
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'linux',
});
displayMention(
'click_test',
'mention_click_body',
{id: 'channel_id'},
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1, send: jest.fn()},
{},
);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
expect(mainWindow.flashFrame).toBeCalledWith(true);
});
it('mac - should not bounce icon when config item is not set', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'darwin',
});
displayMention(
'click_test',
'mention_click_body',
{id: 'channel_id'},
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1, send: jest.fn()},
{},
);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
expect(app.dock.bounce).not.toBeCalled();
});
it('mac - should bounce icon when config item is set', () => {
Config.notifications = {
bounceIcon: true,
bounceIconType: 'critical',
};
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'darwin',
});
displayMention(
'click_test',
'mention_click_body',
{id: 'channel_id'},
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1, send: jest.fn()},
{},
);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
expect(app.dock.bounce).toHaveBeenCalledWith('critical');
}); });
}); });
describe('displayDownloadCompleted', () => { describe('displayDownloadCompleted', () => {
beforeEach(() => {
Notification.isSupported.mockImplementation(() => true);
getFocusAssist.mockReturnValue({value: false});
getDarwinDoNotDisturb.mockReturnValue(false);
});
it('should open file when clicked', () => { it('should open file when clicked', () => {
getDarwinDoNotDisturb.mockReturnValue(false); getDarwinDoNotDisturb.mockReturnValue(false);
localizeMessage.mockReturnValue('test_filename'); localizeMessage.mockReturnValue('test_filename');

View File

@@ -1,16 +1,18 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {shell, Notification} from 'electron'; import {app, shell, Notification} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state';
import {MentionData} from 'types/notification'; import {MentionData} from 'types/notification';
import Config from 'common/config';
import {PLAY_SOUND} from 'common/communication'; import {PLAY_SOUND} from 'common/communication';
import {TAB_MESSAGING} from 'common/tabs/TabView'; import {TAB_MESSAGING} from 'common/tabs/TabView';
import MainWindow from '../windows/mainWindow';
import WindowManager from '../windows/windowManager'; import WindowManager from '../windows/windowManager';
import {Mention} from './Mention'; import {Mention} from './Mention';
@@ -61,7 +63,7 @@ export function displayMention(title: string, body: string, channel: {id: string
if (notificationSound) { if (notificationSound) {
WindowManager.sendToRenderer(PLAY_SOUND, notificationSound); WindowManager.sendToRenderer(PLAY_SOUND, notificationSound);
} }
WindowManager.flashFrame(true); flashFrame(true);
}); });
mention.on('click', () => { mention.on('click', () => {
@@ -89,7 +91,7 @@ export function displayDownloadCompleted(fileName: string, path: string, serverN
const download = new DownloadNotification(fileName, serverName); const download = new DownloadNotification(fileName, serverName);
download.on('show', () => { download.on('show', () => {
WindowManager.flashFrame(true); flashFrame(true);
}); });
download.on('click', () => { download.on('click', () => {
@@ -153,3 +155,14 @@ function getDoNotDisturb() {
return false; return false;
} }
function flashFrame(flash: boolean) {
if (process.platform === 'linux' || process.platform === 'win32') {
if (Config.notifications.flashWindow) {
MainWindow.get()?.flashFrame(flash);
}
}
if (process.platform === 'darwin' && Config.notifications.bounceIcon) {
app.dock.bounce(Config.notifications.bounceIconType);
}
}

View File

@@ -46,8 +46,8 @@ jest.mock('electron', () => {
jest.mock('main/app/utils', () => ({ jest.mock('main/app/utils', () => ({
handleUpdateMenuEvent: jest.fn(), handleUpdateMenuEvent: jest.fn(),
updateSpellCheckerLocales: jest.fn(), updateSpellCheckerLocales: jest.fn(),
updateServerInfos: jest.fn(),
setLoggingLevel: jest.fn(), setLoggingLevel: jest.fn(),
updateServerInfos: jest.fn(),
})); }));
jest.mock('main/app/intercom', () => ({ jest.mock('main/app/intercom', () => ({
handleMainWindowIsShown: jest.fn(), handleMainWindowIsShown: jest.fn(),

View File

@@ -8,8 +8,9 @@ import {app, nativeImage, Tray, systemPreferences, nativeTheme} from 'electron';
import {UPDATE_TRAY} from 'common/communication'; import {UPDATE_TRAY} from 'common/communication';
import {localizeMessage} from 'main/i18nManager'; import {localizeMessage} from 'main/i18nManager';
import MainWindow from 'main/windows/mainWindow';
import WindowManager from 'main/windows/windowManager';
import WindowManager from '../windows/windowManager';
import * as AppState from '../appState'; import * as AppState from '../appState';
const assetsDir = path.resolve(app.getAppPath(), 'assets'); const assetsDir = path.resolve(app.getAppPath(), 'assets');
@@ -85,9 +86,10 @@ export function setupTray(iconTheme: string) {
trayIcon.setToolTip(app.name); trayIcon.setToolTip(app.name);
trayIcon.on('click', () => { trayIcon.on('click', () => {
if (WindowManager.mainWindow!.isVisible()) { const mainWindow = MainWindow.get(true)!;
WindowManager.mainWindow!.blur(); // To move focus to the next top-level window in Windows if (mainWindow.isVisible()) {
WindowManager.mainWindow!.hide(); mainWindow.blur(); // To move focus to the next top-level window in Windows
mainWindow.hide();
} else { } else {
WindowManager.restoreMain(); WindowManager.restoreMain();
} }

View File

@@ -18,6 +18,10 @@ import {BACK_BAR_HEIGHT, customLoginRegexPaths, PRODUCTION, TAB_BAR_HEIGHT} from
import UrlUtils from 'common/utils/url'; import UrlUtils from 'common/utils/url';
import Utils from 'common/utils/util'; import Utils from 'common/utils/util';
export function isInsideRectangle(container: Electron.Rectangle, rect: Electron.Rectangle) {
return container.x <= rect.x && container.y <= rect.y && container.width >= rect.width && container.height >= rect.height;
}
export function shouldBeHiddenOnStartup(parsedArgv: Args) { export function shouldBeHiddenOnStartup(parsedArgv: Args) {
if (parsedArgv.hidden) { if (parsedArgv.hidden) {
return true; return true;

View File

@@ -7,6 +7,7 @@ import {LOAD_FAILED, TOGGLE_BACK_BUTTON, UPDATE_TARGET_URL} from 'common/communi
import {MattermostServer} from 'common/servers/MattermostServer'; import {MattermostServer} from 'common/servers/MattermostServer';
import MessagingTabView from 'common/tabs/MessagingTabView'; import MessagingTabView from 'common/tabs/MessagingTabView';
import MainWindow from '../windows/mainWindow';
import * as WindowManager from '../windows/windowManager'; import * as WindowManager from '../windows/windowManager';
import * as appState from '../appState'; import * as appState from '../appState';
import Utils from '../utils'; import Utils from '../utils';
@@ -30,9 +31,12 @@ jest.mock('electron', () => ({
}, },
})); }));
jest.mock('../windows/mainWindow', () => ({
focusThreeDotMenu: jest.fn(),
get: jest.fn(),
}));
jest.mock('../windows/windowManager', () => ({ jest.mock('../windows/windowManager', () => ({
sendToRenderer: jest.fn(), sendToRenderer: jest.fn(),
focusThreeDotMenu: jest.fn(),
})); }));
jest.mock('../appState', () => ({ jest.mock('../appState', () => ({
updateMentions: jest.fn(), updateMentions: jest.fn(),
@@ -54,9 +58,10 @@ const tabView = new MessagingTabView(server);
describe('main/views/MattermostView', () => { describe('main/views/MattermostView', () => {
describe('load', () => { describe('load', () => {
const window = {on: jest.fn()}; const window = {on: jest.fn()};
const mattermostView = new MattermostView(tabView, {}, window, {}); const mattermostView = new MattermostView(tabView, {}, {});
beforeEach(() => { beforeEach(() => {
MainWindow.get.mockReturnValue(window);
mattermostView.loadSuccess = jest.fn(); mattermostView.loadSuccess = jest.fn();
mattermostView.loadRetry = jest.fn(); mattermostView.loadRetry = jest.fn();
}); });
@@ -112,11 +117,12 @@ describe('main/views/MattermostView', () => {
describe('retry', () => { describe('retry', () => {
const window = {on: jest.fn()}; const window = {on: jest.fn()};
const mattermostView = new MattermostView(tabView, {}, window, {}); const mattermostView = new MattermostView(tabView, {}, {});
const retryInBackgroundFn = jest.fn(); const retryInBackgroundFn = jest.fn();
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
MainWindow.get.mockReturnValue(window);
mattermostView.view.webContents.loadURL.mockImplementation(() => Promise.resolve()); mattermostView.view.webContents.loadURL.mockImplementation(() => Promise.resolve());
mattermostView.loadSuccess = jest.fn(); mattermostView.loadSuccess = jest.fn();
mattermostView.loadRetry = jest.fn(); mattermostView.loadRetry = jest.fn();
@@ -175,10 +181,11 @@ describe('main/views/MattermostView', () => {
describe('loadSuccess', () => { describe('loadSuccess', () => {
const window = {on: jest.fn()}; const window = {on: jest.fn()};
const mattermostView = new MattermostView(tabView, {}, window, {}); const mattermostView = new MattermostView(tabView, {}, {});
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
MainWindow.get.mockReturnValue(window);
mattermostView.emit = jest.fn(); mattermostView.emit = jest.fn();
mattermostView.setBounds = jest.fn(); mattermostView.setBounds = jest.fn();
mattermostView.setInitialized = jest.fn(); mattermostView.setInitialized = jest.fn();
@@ -202,10 +209,11 @@ describe('main/views/MattermostView', () => {
describe('show', () => { describe('show', () => {
const window = {addBrowserView: jest.fn(), removeBrowserView: jest.fn(), on: jest.fn()}; const window = {addBrowserView: jest.fn(), removeBrowserView: jest.fn(), on: jest.fn()};
const mattermostView = new MattermostView(tabView, {}, window, {}); const mattermostView = new MattermostView(tabView, {}, {});
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
MainWindow.get.mockReturnValue(window);
mattermostView.setBounds = jest.fn(); mattermostView.setBounds = jest.fn();
mattermostView.focus = jest.fn(); mattermostView.focus = jest.fn();
}); });
@@ -253,9 +261,10 @@ describe('main/views/MattermostView', () => {
describe('destroy', () => { describe('destroy', () => {
const window = {removeBrowserView: jest.fn(), on: jest.fn()}; const window = {removeBrowserView: jest.fn(), on: jest.fn()};
const mattermostView = new MattermostView(tabView, {}, window, {}); const mattermostView = new MattermostView(tabView, {}, {});
beforeEach(() => { beforeEach(() => {
MainWindow.get.mockReturnValue(window);
mattermostView.view.webContents.destroy = jest.fn(); mattermostView.view.webContents.destroy = jest.fn();
}); });
@@ -280,17 +289,18 @@ describe('main/views/MattermostView', () => {
describe('handleInputEvents', () => { describe('handleInputEvents', () => {
const window = {on: jest.fn()}; const window = {on: jest.fn()};
const mattermostView = new MattermostView(tabView, {}, window, {}); const mattermostView = new MattermostView(tabView, {}, {});
it('should open three dot menu on pressing Alt', () => { it('should open three dot menu on pressing Alt', () => {
MainWindow.get.mockReturnValue(window);
mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyDown', alt: true, shift: false, control: false, meta: false}); mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyDown', alt: true, shift: false, control: false, meta: false});
mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyUp'}); mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyUp'});
expect(WindowManager.focusThreeDotMenu).toHaveBeenCalled(); expect(MainWindow.focusThreeDotMenu).toHaveBeenCalled();
}); });
it('should not open three dot menu on holding Alt', () => { it('should not open three dot menu on holding Alt', () => {
mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyDown'}); mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyDown'});
expect(WindowManager.focusThreeDotMenu).not.toHaveBeenCalled(); expect(MainWindow.focusThreeDotMenu).not.toHaveBeenCalled();
}); });
it('should not open three dot menu on Alt as key combp', () => { it('should not open three dot menu on Alt as key combp', () => {
@@ -298,15 +308,16 @@ describe('main/views/MattermostView', () => {
mattermostView.handleInputEvents(null, {key: 'F', type: 'keyDown'}); mattermostView.handleInputEvents(null, {key: 'F', type: 'keyDown'});
mattermostView.handleInputEvents(null, {key: 'F', type: 'keyUp'}); mattermostView.handleInputEvents(null, {key: 'F', type: 'keyUp'});
mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyUp'}); mattermostView.handleInputEvents(null, {key: 'Alt', type: 'keyUp'});
expect(WindowManager.focusThreeDotMenu).not.toHaveBeenCalled(); expect(MainWindow.focusThreeDotMenu).not.toHaveBeenCalled();
}); });
}); });
describe('handleDidNavigate', () => { describe('handleDidNavigate', () => {
const window = {on: jest.fn()}; const window = {on: jest.fn()};
const mattermostView = new MattermostView(tabView, {}, window, {}); const mattermostView = new MattermostView(tabView, {}, {});
beforeEach(() => { beforeEach(() => {
MainWindow.get.mockReturnValue(window);
mattermostView.setBounds = jest.fn(); mattermostView.setBounds = jest.fn();
}); });
@@ -325,9 +336,10 @@ describe('main/views/MattermostView', () => {
describe('handleUpdateTarget', () => { describe('handleUpdateTarget', () => {
const window = {on: jest.fn()}; const window = {on: jest.fn()};
const mattermostView = new MattermostView(tabView, {}, window, {}); const mattermostView = new MattermostView(tabView, {}, {});
beforeEach(() => { beforeEach(() => {
MainWindow.get.mockReturnValue(window);
mattermostView.emit = jest.fn(); mattermostView.emit = jest.fn();
}); });
@@ -355,8 +367,7 @@ describe('main/views/MattermostView', () => {
}); });
describe('updateMentionsFromTitle', () => { describe('updateMentionsFromTitle', () => {
const window = {on: jest.fn()}; const mattermostView = new MattermostView(tabView, {}, {});
const mattermostView = new MattermostView(tabView, {}, window, {});
it('should parse mentions from title', () => { it('should parse mentions from title', () => {
mattermostView.updateMentionsFromTitle('(7) Mattermost'); mattermostView.updateMentionsFromTitle('(7) Mattermost');

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {BrowserView, app, ipcMain, BrowserWindow} from 'electron'; import {BrowserView, app, ipcMain} from 'electron';
import {BrowserViewConstructorOptions, Event, Input} from 'electron/main'; import {BrowserViewConstructorOptions, Event, Input} from 'electron/main';
import log from 'electron-log'; import log from 'electron-log';
@@ -25,6 +25,8 @@ import {MattermostServer} from 'common/servers/MattermostServer';
import {TabView, TabTuple} from 'common/tabs/TabView'; import {TabView, TabTuple} from 'common/tabs/TabView';
import {ServerInfo} from 'main/server/serverInfo'; import {ServerInfo} from 'main/server/serverInfo';
import MainWindow from 'main/windows/mainWindow';
import ContextMenu from '../contextMenu'; import ContextMenu from '../contextMenu';
import {getWindowBoundaries, getLocalPreload, composeUserAgent, shouldHaveBackBar} from '../utils'; import {getWindowBoundaries, getLocalPreload, composeUserAgent, shouldHaveBackBar} from '../utils';
import WindowManager from '../windows/windowManager'; import WindowManager from '../windows/windowManager';
@@ -43,7 +45,6 @@ const MENTIONS_GROUP = 2;
export class MattermostView extends EventEmitter { export class MattermostView extends EventEmitter {
tab: TabView; tab: TabView;
window: BrowserWindow;
view: BrowserView; view: BrowserView;
isVisible: boolean; isVisible: boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
@@ -63,10 +64,9 @@ export class MattermostView extends EventEmitter {
private altPressStatus: boolean; private altPressStatus: boolean;
constructor(tab: TabView, serverInfo: ServerInfo, win: BrowserWindow, options: BrowserViewConstructorOptions) { constructor(tab: TabView, serverInfo: ServerInfo, options: BrowserViewConstructorOptions) {
super(); super();
this.tab = tab; this.tab = tab;
this.window = win;
this.serverInfo = serverInfo; this.serverInfo = serverInfo;
const preload = getLocalPreload('preload.js'); const preload = getLocalPreload('preload.js');
@@ -118,7 +118,7 @@ export class MattermostView extends EventEmitter {
this.altPressStatus = false; this.altPressStatus = false;
this.window.on('blur', () => { MainWindow.get()?.on('blur', () => {
this.altPressStatus = false; this.altPressStatus = false;
}); });
} }
@@ -224,6 +224,11 @@ export class MattermostView extends EventEmitter {
loadSuccess = (loadURL: string) => { loadSuccess = (loadURL: string) => {
return () => { return () => {
const mainWindow = MainWindow.get();
if (!mainWindow) {
return;
}
log.verbose(`[${Util.shorten(this.tab.name)}] finished loading ${loadURL}`); log.verbose(`[${Util.shorten(this.tab.name)}] finished loading ${loadURL}`);
WindowManager.sendToRenderer(LOAD_SUCCESS, this.tab.name); WindowManager.sendToRenderer(LOAD_SUCCESS, this.tab.name);
this.maxRetries = MAX_SERVER_RETRIES; this.maxRetries = MAX_SERVER_RETRIES;
@@ -235,21 +240,26 @@ export class MattermostView extends EventEmitter {
this.status = Status.WAITING_MM; this.status = Status.WAITING_MM;
this.removeLoading = setTimeout(this.setInitialized, MAX_LOADING_SCREEN_SECONDS, true); this.removeLoading = setTimeout(this.setInitialized, MAX_LOADING_SCREEN_SECONDS, true);
this.emit(LOAD_SUCCESS, this.tab.name, loadURL); this.emit(LOAD_SUCCESS, this.tab.name, loadURL);
this.setBounds(getWindowBoundaries(this.window, shouldHaveBackBar(this.tab.url || '', this.view.webContents.getURL()))); this.setBounds(getWindowBoundaries(mainWindow, shouldHaveBackBar(this.tab.url || '', this.view.webContents.getURL())));
}; };
} }
show = (requestedVisibility?: boolean) => { show = (requestedVisibility?: boolean) => {
const mainWindow = MainWindow.get();
if (!mainWindow) {
return;
}
this.hasBeenShown = true; this.hasBeenShown = true;
const request = typeof requestedVisibility === 'undefined' ? true : requestedVisibility; const request = typeof requestedVisibility === 'undefined' ? true : requestedVisibility;
if (request && !this.isVisible) { if (request && !this.isVisible) {
this.window.addBrowserView(this.view); mainWindow.addBrowserView(this.view);
this.setBounds(getWindowBoundaries(this.window, shouldHaveBackBar(this.tab.url || '', this.view.webContents.getURL()))); this.setBounds(getWindowBoundaries(mainWindow, shouldHaveBackBar(this.tab.url || '', this.view.webContents.getURL())));
if (this.status === Status.READY) { if (this.status === Status.READY) {
this.focus(); this.focus();
} }
} else if (!request && this.isVisible) { } else if (!request && this.isVisible) {
this.window.removeBrowserView(this.view); mainWindow.removeBrowserView(this.view);
} }
this.isVisible = request; this.isVisible = request;
} }
@@ -268,9 +278,7 @@ export class MattermostView extends EventEmitter {
destroy = () => { destroy = () => {
WebContentsEventManager.removeWebContentsListeners(this.view.webContents.id); WebContentsEventManager.removeWebContentsListeners(this.view.webContents.id);
appState.updateMentions(this.tab.name, 0, false); appState.updateMentions(this.tab.name, 0, false);
if (this.window) { MainWindow.get()?.removeBrowserView(this.view);
this.window.removeBrowserView(this.view);
}
// workaround to eliminate zombie processes // workaround to eliminate zombie processes
// https://github.com/mattermost/desktop/pull/1519 // https://github.com/mattermost/desktop/pull/1519
@@ -352,7 +360,7 @@ export class MattermostView extends EventEmitter {
this.registerAltKeyPressed(input); this.registerAltKeyPressed(input);
if (this.isAltKeyReleased(input)) { if (this.isAltKeyReleased(input)) {
WindowManager.focusThreeDotMenu(); MainWindow.focusThreeDotMenu();
} }
} }
@@ -360,11 +368,11 @@ export class MattermostView extends EventEmitter {
log.debug('MattermostView.handleDidNavigate', {tabName: this.tab.name, url}); log.debug('MattermostView.handleDidNavigate', {tabName: this.tab.name, url});
if (shouldHaveBackBar(this.tab.url || '', url)) { if (shouldHaveBackBar(this.tab.url || '', url)) {
this.setBounds(getWindowBoundaries(this.window, true)); this.setBounds(getWindowBoundaries(MainWindow.get()!, true));
WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, true); WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, true);
log.info('show back button'); log.info('show back button');
} else { } else {
this.setBounds(getWindowBoundaries(this.window)); this.setBounds(getWindowBoundaries(MainWindow.get()!));
WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, false); WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, false);
log.info('hide back button'); log.info('hide back button');
} }

View File

@@ -7,6 +7,8 @@ import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state
import {DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT, DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, TAB_BAR_HEIGHT} from 'common/utils/constants'; import {DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT, DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, TAB_BAR_HEIGHT} from 'common/utils/constants';
import MainWindow from 'main/windows/mainWindow';
import DownloadsDropdownMenuView from './downloadsDropdownMenuView'; import DownloadsDropdownMenuView from './downloadsDropdownMenuView';
jest.mock('main/utils', () => ({ jest.mock('main/utils', () => ({
@@ -50,6 +52,11 @@ jest.mock('electron', () => {
jest.mock('macos-notification-state', () => ({ jest.mock('macos-notification-state', () => ({
getDoNotDisturb: jest.fn(), getDoNotDisturb: jest.fn(),
})); }));
jest.mock('main/downloadsManager', () => ({}));
jest.mock('main/windows/mainWindow', () => ({
get: jest.fn(),
getBounds: jest.fn(),
}));
jest.mock('main/windows/windowManager', () => ({ jest.mock('main/windows/windowManager', () => ({
sendToRenderer: jest.fn(), sendToRenderer: jest.fn(),
})); }));
@@ -60,24 +67,21 @@ jest.mock('fs', () => ({
})); }));
describe('main/views/DownloadsDropdownMenuView', () => { describe('main/views/DownloadsDropdownMenuView', () => {
const window = {
getContentBounds: () => ({width: 800, height: 600, x: 0, y: 0}),
addBrowserView: jest.fn(),
setTopBrowserView: jest.fn(),
};
const downloadsDropdownMenuView = new DownloadsDropdownMenuView(window, {}, false);
beforeEach(() => { beforeEach(() => {
MainWindow.get.mockReturnValue({addBrowserView: jest.fn(), setTopBrowserView: jest.fn()});
MainWindow.getBounds.mockReturnValue({width: 800, height: 600, x: 0, y: 0});
getDarwinDoNotDisturb.mockReturnValue(false); getDarwinDoNotDisturb.mockReturnValue(false);
}); });
describe('getBounds', () => { describe('getBounds', () => {
it('should be placed top-left inside the downloads dropdown if coordinates not used', () => { it('should be placed top-left inside the downloads dropdown if coordinates not used', () => {
const downloadsDropdownMenuView = new DownloadsDropdownMenuView();
expect(downloadsDropdownMenuView.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT)).toStrictEqual({x: 800 - DOWNLOADS_DROPDOWN_FULL_WIDTH - DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, y: TAB_BAR_HEIGHT, width: DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, height: DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT}); expect(downloadsDropdownMenuView.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT)).toStrictEqual({x: 800 - DOWNLOADS_DROPDOWN_FULL_WIDTH - DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, y: TAB_BAR_HEIGHT, width: DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, height: DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT});
}); });
}); });
it('should change the view bounds based on open/closed state', () => { it('should change the view bounds based on open/closed state', () => {
const downloadsDropdownMenuView = new DownloadsDropdownMenuView();
downloadsDropdownMenuView.bounds = {width: 400, height: 300}; downloadsDropdownMenuView.bounds = {width: 400, height: 300};
downloadsDropdownMenuView.handleOpen(); downloadsDropdownMenuView.handleOpen();
expect(downloadsDropdownMenuView.view.setBounds).toBeCalledWith(downloadsDropdownMenuView.bounds); expect(downloadsDropdownMenuView.view.setBounds).toBeCalledWith(downloadsDropdownMenuView.bounds);

View File

@@ -1,6 +1,6 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {BrowserView, BrowserWindow, ipcMain, IpcMainEvent} from 'electron'; import {BrowserView, ipcMain, IpcMainEvent} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
@@ -30,40 +30,23 @@ import {getLocalPreload, getLocalURLString} from 'main/utils';
import WindowManager from '../windows/windowManager'; import WindowManager from '../windows/windowManager';
import downloadsManager from 'main/downloadsManager'; import downloadsManager from 'main/downloadsManager';
import MainWindow from 'main/windows/mainWindow';
export default class DownloadsDropdownMenuView { export default class DownloadsDropdownMenuView {
open: boolean; open: boolean;
view: BrowserView; view: BrowserView;
bounds?: Electron.Rectangle; bounds: Electron.Rectangle;
item?: DownloadedItem; item?: DownloadedItem;
coordinates?: CoordinatesToJsonType; coordinates?: CoordinatesToJsonType;
darkMode: boolean; darkMode: boolean;
window: BrowserWindow;
windowBounds: Electron.Rectangle; windowBounds: Electron.Rectangle;
constructor(window: BrowserWindow, darkMode: boolean) { constructor(darkMode: boolean) {
this.open = false; this.open = false;
this.item = undefined; this.item = undefined;
this.coordinates = undefined; this.coordinates = undefined;
this.window = window;
this.darkMode = darkMode; this.darkMode = darkMode;
this.windowBounds = this.window.getContentBounds();
this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT);
const preload = getLocalPreload('desktopAPI.js');
this.view = new BrowserView({webPreferences: {
preload,
// Workaround for this issue: https://github.com/electron/electron/issues/30993
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
transparent: true,
}});
this.view.webContents.loadURL(getLocalURLString('downloadsDropdownMenu.html'));
this.window.addBrowserView(this.view);
ipcMain.on(OPEN_DOWNLOADS_DROPDOWN_MENU, this.handleOpen); ipcMain.on(OPEN_DOWNLOADS_DROPDOWN_MENU, this.handleOpen);
ipcMain.on(CLOSE_DOWNLOADS_DROPDOWN_MENU, this.handleClose); ipcMain.on(CLOSE_DOWNLOADS_DROPDOWN_MENU, this.handleClose);
ipcMain.on(TOGGLE_DOWNLOADS_DROPDOWN_MENU, this.handleToggle); ipcMain.on(TOGGLE_DOWNLOADS_DROPDOWN_MENU, this.handleToggle);
@@ -74,6 +57,27 @@ export default class DownloadsDropdownMenuView {
ipcMain.on(DOWNLOADS_DROPDOWN_MENU_CANCEL_DOWNLOAD, this.cancelDownload); ipcMain.on(DOWNLOADS_DROPDOWN_MENU_CANCEL_DOWNLOAD, this.cancelDownload);
ipcMain.on(DOWNLOADS_DROPDOWN_MENU_CLEAR_FILE, this.clearFile); ipcMain.on(DOWNLOADS_DROPDOWN_MENU_CLEAR_FILE, this.clearFile);
ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN_MENU, this.updateItem); ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN_MENU, this.updateItem);
const mainWindow = MainWindow.get();
const windowBounds = MainWindow.getBounds();
if (!(mainWindow && windowBounds)) {
throw new Error('Cannot initialize downloadsDropdownMenuView, missing MainWindow');
}
this.windowBounds = windowBounds;
this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT);
const preload = getLocalPreload('desktopAPI.js');
this.view = new BrowserView({webPreferences: {
preload,
// Workaround for this issue: https://github.com/electron/electron/issues/30993
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
transparent: true,
}});
this.view.webContents.loadURL(getLocalURLString('downloadsDropdownMenu.html'));
mainWindow.addBrowserView(this.view);
} }
updateItem = (event: IpcMainEvent, item: DownloadedItem) => { updateItem = (event: IpcMainEvent, item: DownloadedItem) => {
@@ -98,10 +102,13 @@ export default class DownloadsDropdownMenuView {
updateWindowBounds = () => { updateWindowBounds = () => {
log.debug('DownloadsDropdownMenuView.updateWindowBounds'); log.debug('DownloadsDropdownMenuView.updateWindowBounds');
this.windowBounds = this.window.getContentBounds(); const mainWindow = MainWindow.get();
if (mainWindow) {
this.windowBounds = mainWindow.getContentBounds();
this.updateDownloadsDropdownMenu(); this.updateDownloadsDropdownMenu();
this.repositionDownloadsDropdownMenu(); this.repositionDownloadsDropdownMenu();
} }
}
updateDownloadsDropdownMenu = () => { updateDownloadsDropdownMenu = () => {
log.debug('DownloadsDropdownMenuView.updateDownloadsDropdownMenu'); log.debug('DownloadsDropdownMenuView.updateDownloadsDropdownMenu');
@@ -131,7 +138,7 @@ export default class DownloadsDropdownMenuView {
this.item = item; this.item = item;
this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT); this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT);
this.view.setBounds(this.bounds); this.view.setBounds(this.bounds);
this.window.setTopBrowserView(this.view); MainWindow.get()?.setTopBrowserView(this.view);
this.view.webContents.focus(); this.view.webContents.focus();
this.updateDownloadsDropdownMenu(); this.updateDownloadsDropdownMenu();
} }

View File

@@ -7,6 +7,8 @@ import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state
import {DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT, TAB_BAR_HEIGHT} from 'common/utils/constants'; import {DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT, TAB_BAR_HEIGHT} from 'common/utils/constants';
import MainWindow from 'main/windows/mainWindow';
import DownloadsDropdownView from './downloadsDropdownView'; import DownloadsDropdownView from './downloadsDropdownView';
jest.mock('main/utils', () => ({ jest.mock('main/utils', () => ({
@@ -59,42 +61,35 @@ jest.mock('electron', () => {
Notification: NotificationMock, Notification: NotificationMock,
}; };
}); });
jest.mock('main/windows/mainWindow', () => ({
get: jest.fn(),
getBounds: jest.fn(),
}));
jest.mock('main/windows/windowManager', () => ({ jest.mock('main/windows/windowManager', () => ({
sendToRenderer: jest.fn(), sendToRenderer: jest.fn(),
})); }));
describe('main/views/DownloadsDropdownView', () => { describe('main/views/DownloadsDropdownView', () => {
beforeEach(() => { beforeEach(() => {
MainWindow.get.mockReturnValue({addBrowserView: jest.fn(), setTopBrowserView: jest.fn()});
getDarwinDoNotDisturb.mockReturnValue(false); getDarwinDoNotDisturb.mockReturnValue(false);
}); });
describe('getBounds', () => { describe('getBounds', () => {
it('should be placed far right when window is large enough', () => { it('should be placed far right when window is large enough', () => {
const window = { MainWindow.getBounds.mockReturnValue({width: 800, height: 600, x: 0, y: 0});
getContentBounds: () => ({width: 800, height: 600, x: 0, y: 0}), const downloadsDropdownView = new DownloadsDropdownView();
addBrowserView: jest.fn(),
setTopBrowserView: jest.fn(),
};
const downloadsDropdownView = new DownloadsDropdownView(window, {}, false);
expect(downloadsDropdownView.getBounds(DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT)).toStrictEqual({x: 800 - DOWNLOADS_DROPDOWN_FULL_WIDTH, y: TAB_BAR_HEIGHT, width: DOWNLOADS_DROPDOWN_FULL_WIDTH, height: DOWNLOADS_DROPDOWN_HEIGHT}); expect(downloadsDropdownView.getBounds(DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT)).toStrictEqual({x: 800 - DOWNLOADS_DROPDOWN_FULL_WIDTH, y: TAB_BAR_HEIGHT, width: DOWNLOADS_DROPDOWN_FULL_WIDTH, height: DOWNLOADS_DROPDOWN_HEIGHT});
}); });
it('should be placed left if window is very small', () => { it('should be placed left if window is very small', () => {
const window = { MainWindow.getBounds.mockReturnValue({width: 500, height: 400, x: 0, y: 0});
getContentBounds: () => ({width: 500, height: 400, x: 0, y: 0}), const downloadsDropdownView = new DownloadsDropdownView();
addBrowserView: jest.fn(),
setTopBrowserView: jest.fn(),
};
const downloadsDropdownView = new DownloadsDropdownView(window, {}, false);
expect(downloadsDropdownView.getBounds(DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT)).toStrictEqual({x: 0, y: TAB_BAR_HEIGHT, width: DOWNLOADS_DROPDOWN_FULL_WIDTH, height: DOWNLOADS_DROPDOWN_HEIGHT}); expect(downloadsDropdownView.getBounds(DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT)).toStrictEqual({x: 0, y: TAB_BAR_HEIGHT, width: DOWNLOADS_DROPDOWN_FULL_WIDTH, height: DOWNLOADS_DROPDOWN_HEIGHT});
}); });
}); });
it('should change the view bounds based on open/closed state', () => { it('should change the view bounds based on open/closed state', () => {
const window = { MainWindow.getBounds.mockReturnValue({width: 800, height: 600, x: 0, y: 0});
getContentBounds: () => ({width: 800, height: 600, x: 0, y: 0}), const downloadsDropdownView = new DownloadsDropdownView();
addBrowserView: jest.fn(),
setTopBrowserView: jest.fn(),
};
const downloadsDropdownView = new DownloadsDropdownView(window, {}, false);
downloadsDropdownView.bounds = {width: 400, height: 300}; downloadsDropdownView.bounds = {width: 400, height: 300};
downloadsDropdownView.handleOpen(); downloadsDropdownView.handleOpen();
expect(downloadsDropdownView.view.setBounds).toBeCalledWith(downloadsDropdownView.bounds); expect(downloadsDropdownView.view.setBounds).toBeCalledWith(downloadsDropdownView.bounds);

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {BrowserView, BrowserWindow, ipcMain, IpcMainEvent, IpcMainInvokeEvent} from 'electron'; import {BrowserView, ipcMain, IpcMainEvent, IpcMainInvokeEvent} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
@@ -25,23 +25,38 @@ import {getLocalPreload, getLocalURLString} from 'main/utils';
import WindowManager from '../windows/windowManager'; import WindowManager from '../windows/windowManager';
import downloadsManager from 'main/downloadsManager'; import downloadsManager from 'main/downloadsManager';
import MainWindow from 'main/windows/mainWindow';
export default class DownloadsDropdownView { export default class DownloadsDropdownView {
bounds?: Electron.Rectangle; bounds?: Electron.Rectangle;
darkMode: boolean; darkMode: boolean;
downloads: DownloadedItems; downloads: DownloadedItems;
item: DownloadedItem | undefined; item?: DownloadedItem;
view: BrowserView; view: BrowserView;
window: BrowserWindow;
windowBounds: Electron.Rectangle; windowBounds: Electron.Rectangle;
constructor(window: BrowserWindow, downloads: DownloadedItems, darkMode: boolean) { constructor(downloads: DownloadedItems, darkMode: boolean) {
this.downloads = downloads; this.downloads = downloads;
this.window = window;
this.darkMode = darkMode; this.darkMode = darkMode;
this.item = undefined;
this.windowBounds = this.window.getContentBounds(); ipcMain.on(OPEN_DOWNLOADS_DROPDOWN, this.handleOpen);
ipcMain.on(CLOSE_DOWNLOADS_DROPDOWN, this.handleClose);
ipcMain.on(EMIT_CONFIGURATION, this.updateConfig);
ipcMain.on(REQUEST_DOWNLOADS_DROPDOWN_INFO, this.updateDownloadsDropdown);
ipcMain.on(REQUEST_CLEAR_DOWNLOADS_DROPDOWN, this.clearDownloads);
ipcMain.on(RECEIVE_DOWNLOADS_DROPDOWN_SIZE, this.handleReceivedDownloadsDropdownSize);
ipcMain.on(DOWNLOADS_DROPDOWN_OPEN_FILE, this.openFile);
ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN, this.updateDownloads);
ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM, this.updateDownloadsDropdownMenuItem);
ipcMain.handle(GET_DOWNLOADED_IMAGE_THUMBNAIL_LOCATION, this.getDownloadImageThumbnailLocation);
const mainWindow = MainWindow.get();
const windowBounds = MainWindow.getBounds();
if (!(mainWindow && windowBounds)) {
throw new Error('Cannot initialize downloadsDropdownView, missing MainWindow');
}
this.windowBounds = windowBounds;
this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT); this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT);
const preload = getLocalPreload('desktopAPI.js'); const preload = getLocalPreload('desktopAPI.js');
@@ -55,20 +70,8 @@ export default class DownloadsDropdownView {
}}); }});
this.view.webContents.loadURL(getLocalURLString('downloadsDropdown.html')); this.view.webContents.loadURL(getLocalURLString('downloadsDropdown.html'));
this.window.addBrowserView(this.view);
this.view.webContents.session.webRequest.onHeadersReceived(downloadsManager.webRequestOnHeadersReceivedHandler); this.view.webContents.session.webRequest.onHeadersReceived(downloadsManager.webRequestOnHeadersReceivedHandler);
mainWindow.addBrowserView(this.view);
ipcMain.on(OPEN_DOWNLOADS_DROPDOWN, this.handleOpen);
ipcMain.on(CLOSE_DOWNLOADS_DROPDOWN, this.handleClose);
ipcMain.on(EMIT_CONFIGURATION, this.updateConfig);
ipcMain.on(REQUEST_DOWNLOADS_DROPDOWN_INFO, this.updateDownloadsDropdown);
ipcMain.on(REQUEST_CLEAR_DOWNLOADS_DROPDOWN, this.clearDownloads);
ipcMain.on(RECEIVE_DOWNLOADS_DROPDOWN_SIZE, this.handleReceivedDownloadsDropdownSize);
ipcMain.on(DOWNLOADS_DROPDOWN_OPEN_FILE, this.openFile);
ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN, this.updateDownloads);
ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM, this.updateDownloadsDropdownMenuItem);
ipcMain.handle(GET_DOWNLOADED_IMAGE_THUMBNAIL_LOCATION, this.getDownloadImageThumbnailLocation);
} }
updateDownloads = (event: IpcMainEvent, downloads: DownloadedItems) => { updateDownloads = (event: IpcMainEvent, downloads: DownloadedItems) => {
@@ -99,10 +102,13 @@ export default class DownloadsDropdownView {
updateWindowBounds = () => { updateWindowBounds = () => {
log.debug('DownloadsDropdownView.updateWindowBounds'); log.debug('DownloadsDropdownView.updateWindowBounds');
this.windowBounds = this.window.getContentBounds(); const mainWindow = MainWindow.get();
if (mainWindow) {
this.windowBounds = mainWindow.getContentBounds();
this.updateDownloadsDropdown(); this.updateDownloadsDropdown();
this.repositionDownloadsDropdown(); this.repositionDownloadsDropdown();
} }
}
updateDownloadsDropdown = () => { updateDownloadsDropdown = () => {
log.debug('DownloadsDropdownView.updateDownloadsDropdown'); log.debug('DownloadsDropdownView.updateDownloadsDropdown');
@@ -124,7 +130,7 @@ export default class DownloadsDropdownView {
} }
this.view.setBounds(this.bounds); this.view.setBounds(this.bounds);
this.window.setTopBrowserView(this.view); MainWindow.get()?.setTopBrowserView(this.view);
this.view.webContents.focus(); this.view.webContents.focus();
downloadsManager.onOpen(); downloadsManager.onOpen();
WindowManager.sendToRenderer(OPEN_DOWNLOADS_DROPDOWN); WindowManager.sendToRenderer(OPEN_DOWNLOADS_DROPDOWN);
@@ -172,7 +178,7 @@ export default class DownloadsDropdownView {
} }
repositionDownloadsDropdown = () => { repositionDownloadsDropdown = () => {
if (!this.bounds) { if (!(this.bounds && this.windowBounds)) {
return; return;
} }
this.bounds = { this.bounds = {

View File

@@ -5,6 +5,8 @@
import {TAB_BAR_HEIGHT, THREE_DOT_MENU_WIDTH, THREE_DOT_MENU_WIDTH_MAC, MENU_SHADOW_WIDTH} from 'common/utils/constants'; import {TAB_BAR_HEIGHT, THREE_DOT_MENU_WIDTH, THREE_DOT_MENU_WIDTH_MAC, MENU_SHADOW_WIDTH} from 'common/utils/constants';
import MainWindow from 'main/windows/mainWindow';
import TeamDropdownView from './teamDropdownView'; import TeamDropdownView from './teamDropdownView';
jest.mock('main/utils', () => ({ jest.mock('main/utils', () => ({
@@ -24,20 +26,23 @@ jest.mock('electron', () => ({
on: jest.fn(), on: jest.fn(),
}, },
})); }));
jest.mock('main/windows/mainWindow', () => ({
get: jest.fn(),
getBounds: jest.fn(),
addBrowserView: jest.fn(),
setTopBrowserView: jest.fn(),
}));
jest.mock('../windows/windowManager', () => ({ jest.mock('../windows/windowManager', () => ({
sendToRenderer: jest.fn(), sendToRenderer: jest.fn(),
})); }));
describe('main/views/teamDropdownView', () => { describe('main/views/teamDropdownView', () => {
const window = {
getContentBounds: () => ({width: 500, height: 400, x: 0, y: 0}),
addBrowserView: jest.fn(),
setTopBrowserView: jest.fn(),
};
describe('getBounds', () => { describe('getBounds', () => {
const teamDropdownView = new TeamDropdownView(window, [], false, true); beforeEach(() => {
MainWindow.getBounds.mockReturnValue({width: 500, height: 400, x: 0, y: 0});
});
const teamDropdownView = new TeamDropdownView([], false, true);
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
it('should account for three dot menu, tab bar and shadow', () => { it('should account for three dot menu, tab bar and shadow', () => {
expect(teamDropdownView.getBounds(400, 300)).toStrictEqual({x: THREE_DOT_MENU_WIDTH_MAC - MENU_SHADOW_WIDTH, y: TAB_BAR_HEIGHT - MENU_SHADOW_WIDTH, width: 400, height: 300}); expect(teamDropdownView.getBounds(400, 300)).toStrictEqual({x: THREE_DOT_MENU_WIDTH_MAC - MENU_SHADOW_WIDTH, y: TAB_BAR_HEIGHT - MENU_SHADOW_WIDTH, width: 400, height: 300});
@@ -50,7 +55,7 @@ describe('main/views/teamDropdownView', () => {
}); });
it('should change the view bounds based on open/closed state', () => { it('should change the view bounds based on open/closed state', () => {
const teamDropdownView = new TeamDropdownView(window, [], false, true); const teamDropdownView = new TeamDropdownView([], false, true);
teamDropdownView.bounds = {width: 400, height: 300}; teamDropdownView.bounds = {width: 400, height: 300};
teamDropdownView.handleOpen(); teamDropdownView.handleOpen();
expect(teamDropdownView.view.setBounds).toBeCalledWith(teamDropdownView.bounds); expect(teamDropdownView.view.setBounds).toBeCalledWith(teamDropdownView.bounds);
@@ -60,7 +65,7 @@ describe('main/views/teamDropdownView', () => {
describe('addGpoToTeams', () => { describe('addGpoToTeams', () => {
it('should return teams with "isGPO": false when no config.registryTeams exist', () => { it('should return teams with "isGPO": false when no config.registryTeams exist', () => {
const teamDropdownView = new TeamDropdownView(window, [], false, true); const teamDropdownView = new TeamDropdownView([], false, true);
const teams = [{ const teams = [{
name: 'team-1', name: 'team-1',
url: 'https://mattermost.team-1.com', url: 'https://mattermost.team-1.com',
@@ -81,7 +86,7 @@ describe('main/views/teamDropdownView', () => {
}]); }]);
}); });
it('should return teams with "isGPO": true if they exist in config.registryTeams', () => { it('should return teams with "isGPO": true if they exist in config.registryTeams', () => {
const teamDropdownView = new TeamDropdownView(window, [], false, true); const teamDropdownView = new TeamDropdownView([], false, true);
const teams = [{ const teams = [{
name: 'team-1', name: 'team-1',
url: 'https://mattermost.team-1.com', url: 'https://mattermost.team-1.com',

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {BrowserView, BrowserWindow, ipcMain, IpcMainEvent} from 'electron'; import {BrowserView, ipcMain, IpcMainEvent} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
@@ -21,6 +21,7 @@ import * as AppState from '../appState';
import {TAB_BAR_HEIGHT, THREE_DOT_MENU_WIDTH, THREE_DOT_MENU_WIDTH_MAC, MENU_SHADOW_WIDTH} from 'common/utils/constants'; import {TAB_BAR_HEIGHT, THREE_DOT_MENU_WIDTH, THREE_DOT_MENU_WIDTH_MAC, MENU_SHADOW_WIDTH} from 'common/utils/constants';
import {getLocalPreload, getLocalURLString} from 'main/utils'; import {getLocalPreload, getLocalURLString} from 'main/utils';
import WindowManager from '../windows/windowManager'; import WindowManager from '../windows/windowManager';
import MainWindow from '../windows/mainWindow';
export default class TeamDropdownView { export default class TeamDropdownView {
view: BrowserView; view: BrowserView;
@@ -33,18 +34,16 @@ export default class TeamDropdownView {
unreads?: Map<string, boolean>; unreads?: Map<string, boolean>;
mentions?: Map<string, number>; mentions?: Map<string, number>;
expired?: Map<string, boolean>; expired?: Map<string, boolean>;
window: BrowserWindow; windowBounds?: Electron.Rectangle;
windowBounds: Electron.Rectangle;
isOpen: boolean; isOpen: boolean;
constructor(window: BrowserWindow, teams: TeamWithTabs[], darkMode: boolean, enableServerManagement: boolean) { constructor(teams: TeamWithTabs[], darkMode: boolean, enableServerManagement: boolean) {
this.teams = this.addGpoToTeams(teams, []); this.teams = this.addGpoToTeams(teams, []);
this.window = window;
this.darkMode = darkMode; this.darkMode = darkMode;
this.enableServerManagement = enableServerManagement; this.enableServerManagement = enableServerManagement;
this.isOpen = false; this.isOpen = false;
this.windowBounds = this.window.getContentBounds(); this.windowBounds = MainWindow.getBounds();
const preload = getLocalPreload('desktopAPI.js'); const preload = getLocalPreload('desktopAPI.js');
this.view = new BrowserView({webPreferences: { this.view = new BrowserView({webPreferences: {
@@ -57,7 +56,7 @@ export default class TeamDropdownView {
}}); }});
this.view.webContents.loadURL(getLocalURLString('dropdown.html')); this.view.webContents.loadURL(getLocalURLString('dropdown.html'));
this.window.addBrowserView(this.view); MainWindow.get()?.addBrowserView(this.view);
ipcMain.on(OPEN_TEAMS_DROPDOWN, this.handleOpen); ipcMain.on(OPEN_TEAMS_DROPDOWN, this.handleOpen);
ipcMain.on(CLOSE_TEAMS_DROPDOWN, this.handleClose); ipcMain.on(CLOSE_TEAMS_DROPDOWN, this.handleClose);
@@ -95,7 +94,7 @@ export default class TeamDropdownView {
} }
updateWindowBounds = () => { updateWindowBounds = () => {
this.windowBounds = this.window.getContentBounds(); this.windowBounds = MainWindow.getBounds();
this.updateDropdown(); this.updateDropdown();
} }
@@ -123,7 +122,7 @@ export default class TeamDropdownView {
return; return;
} }
this.view.setBounds(this.bounds); this.view.setBounds(this.bounds);
this.window.setTopBrowserView(this.view); MainWindow.get()?.setTopBrowserView(this.view);
this.view.webContents.focus(); this.view.webContents.focus();
WindowManager.sendToRenderer(OPEN_TEAMS_DROPDOWN); WindowManager.sendToRenderer(OPEN_TEAMS_DROPDOWN);
this.isOpen = true; this.isOpen = true;

View File

@@ -7,11 +7,13 @@
import {dialog, ipcMain} from 'electron'; import {dialog, ipcMain} from 'electron';
import {Tuple as tuple} from '@bloomberg/record-tuple-polyfill'; import {Tuple as tuple} from '@bloomberg/record-tuple-polyfill';
import {BROWSER_HISTORY_PUSH, LOAD_SUCCESS, MAIN_WINDOW_SHOWN} from 'common/communication'; import {LOAD_SUCCESS, MAIN_WINDOW_SHOWN, BROWSER_HISTORY_PUSH} from 'common/communication';
import {MattermostServer} from 'common/servers/MattermostServer'; import {MattermostServer} from 'common/servers/MattermostServer';
import {getTabViewName} from 'common/tabs/TabView'; import {getTabViewName} from 'common/tabs/TabView';
import {equalUrlsIgnoringSubpath} from 'common/utils/url'; import {equalUrlsIgnoringSubpath} from 'common/utils/url';
import MainWindow from 'main/windows/mainWindow';
import {MattermostView} from './MattermostView'; import {MattermostView} from './MattermostView';
import {ViewManager} from './viewManager'; import {ViewManager} from './viewManager';
@@ -56,6 +58,9 @@ jest.mock('main/server/serverInfo', () => ({
ServerInfo: jest.fn(), ServerInfo: jest.fn(),
})); }));
jest.mock('main/windows/mainWindow', () => ({
get: jest.fn(),
}));
jest.mock('./MattermostView', () => ({ jest.mock('./MattermostView', () => ({
MattermostView: jest.fn(), MattermostView: jest.fn(),
})); }));
@@ -174,15 +179,18 @@ describe('main/views/viewManager', () => {
}); });
describe('reloadConfiguration', () => { describe('reloadConfiguration', () => {
const viewManager = new ViewManager({}); const viewManager = new ViewManager();
beforeEach(() => { beforeEach(() => {
viewManager.loadView = jest.fn(); viewManager.loadView = jest.fn();
viewManager.showByName = jest.fn(); viewManager.showByName = jest.fn();
viewManager.showInitial = jest.fn(); viewManager.showInitial = jest.fn();
viewManager.mainWindow.webContents = { const mainWindow = {
webContents: {
send: jest.fn(), send: jest.fn(),
},
}; };
MainWindow.get.mockReturnValue(mainWindow);
viewManager.getServerView = jest.fn().mockImplementation((srv, tabName) => ({ viewManager.getServerView = jest.fn().mockImplementation((srv, tabName) => ({
name: `${srv.name}-${tabName}`, name: `${srv.name}-${tabName}`,
@@ -653,10 +661,11 @@ describe('main/views/viewManager', () => {
setTopBrowserView: jest.fn(), setTopBrowserView: jest.fn(),
addBrowserView: jest.fn(), addBrowserView: jest.fn(),
}; };
const viewManager = new ViewManager(window); const viewManager = new ViewManager();
const loadingScreen = {webContents: {send: jest.fn(), isLoading: () => false}}; const loadingScreen = {webContents: {send: jest.fn(), isLoading: () => false}};
beforeEach(() => { beforeEach(() => {
MainWindow.get.mockReturnValue(window);
viewManager.createLoadingScreen = jest.fn(); viewManager.createLoadingScreen = jest.fn();
viewManager.setLoadingScreenBounds = jest.fn(); viewManager.setLoadingScreenBounds = jest.fn();
window.getBrowserViews.mockImplementation(() => []); window.getBrowserViews.mockImplementation(() => []);

View File

@@ -1,8 +1,10 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {BrowserView, dialog, ipcMain, IpcMainEvent} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import {BrowserView, BrowserWindow, dialog, ipcMain, IpcMainEvent} from 'electron';
import {BrowserViewConstructorOptions} from 'electron/main'; import {BrowserViewConstructorOptions} from 'electron/main';
import {Tuple as tuple} from '@bloomberg/record-tuple-polyfill'; import {Tuple as tuple} from '@bloomberg/record-tuple-polyfill';
import {Tab, TeamWithTabs} from 'types/config'; import {Tab, TeamWithTabs} from 'types/config';
@@ -33,6 +35,7 @@ import PlaybooksTabView from 'common/tabs/PlaybooksTabView';
import {localizeMessage} from 'main/i18nManager'; import {localizeMessage} from 'main/i18nManager';
import {ServerInfo} from 'main/server/serverInfo'; import {ServerInfo} from 'main/server/serverInfo';
import MainWindow from 'main/windows/mainWindow';
import {getLocalURLString, getLocalPreload, getWindowBoundaries} from '../utils'; import {getLocalURLString, getLocalPreload, getWindowBoundaries} from '../utils';
@@ -57,23 +60,17 @@ export class ViewManager {
currentView?: string; currentView?: string;
urlView?: BrowserView; urlView?: BrowserView;
urlViewCancel?: () => void; urlViewCancel?: () => void;
mainWindow: BrowserWindow;
loadingScreen?: BrowserView; loadingScreen?: BrowserView;
loadingScreenState: LoadingScreenState; loadingScreenState: LoadingScreenState;
constructor(mainWindow: BrowserWindow) { constructor() {
this.lastActiveServer = Config.lastActiveTeam; this.lastActiveServer = Config.lastActiveTeam;
this.viewOptions = {webPreferences: {spellcheck: Config.useSpellChecker}}; this.viewOptions = {webPreferences: {spellcheck: Config.useSpellChecker}};
this.views = new Map(); // keep in mind that this doesn't need to hold server order, only tabs on the renderer need that. this.views = new Map(); // keep in mind that this doesn't need to hold server order, only tabs on the renderer need that.
this.mainWindow = mainWindow;
this.closedViews = new Map(); this.closedViews = new Map();
this.loadingScreenState = LoadingScreenState.HIDDEN; this.loadingScreenState = LoadingScreenState.HIDDEN;
} }
updateMainWindow = (mainWindow: BrowserWindow) => {
this.mainWindow = mainWindow;
}
getServers = () => { getServers = () => {
return Config.teams.concat(); return Config.teams.concat();
} }
@@ -86,7 +83,7 @@ export class ViewManager {
makeView = (srv: MattermostServer, serverInfo: ServerInfo, tab: Tab, url?: string): MattermostView => { makeView = (srv: MattermostServer, serverInfo: ServerInfo, tab: Tab, url?: string): MattermostView => {
const tabView = this.getServerView(srv, tab.name); const tabView = this.getServerView(srv, tab.name);
const view = new MattermostView(tabView, serverInfo, this.mainWindow, this.viewOptions); const view = new MattermostView(tabView, serverInfo, this.viewOptions);
view.once(LOAD_SUCCESS, this.activateView); view.once(LOAD_SUCCESS, this.activateView);
view.load(url); view.load(url);
view.on(UPDATE_TARGET_URL, this.showURLView); view.on(UPDATE_TARGET_URL, this.showURLView);
@@ -186,7 +183,7 @@ export class ViewManager {
this.currentView = undefined; this.currentView = undefined;
this.showInitial(); this.showInitial();
} else { } else {
this.mainWindow.webContents.send(SET_ACTIVE_VIEW); MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW);
} }
} }
@@ -196,7 +193,7 @@ export class ViewManager {
if (view) { if (view) {
this.currentView = view.name; this.currentView = view.name;
this.showByName(view.name); this.showByName(view.name);
this.mainWindow.webContents.send(SET_ACTIVE_VIEW, view.tab.server.name, view.tab.type); MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW, view.tab.server.name, view.tab.type);
} }
} else { } else {
this.showInitial(); this.showInitial();
@@ -221,7 +218,7 @@ export class ViewManager {
} }
} }
} else { } else {
this.mainWindow.webContents.send(SET_ACTIVE_VIEW, null, null); MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW, null, null);
ipcMain.emit(MAIN_WINDOW_SHOWN); ipcMain.emit(MAIN_WINDOW_SHOWN);
} }
} }
@@ -248,7 +245,7 @@ export class ViewManager {
this.showLoadingScreen(); this.showLoadingScreen();
} }
} }
newView.window.webContents.send(SET_ACTIVE_VIEW, newView.tab.server.name, newView.tab.type); MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW, newView.tab.server.name, newView.tab.type);
ipcMain.emit(SET_ACTIVE_VIEW, true, newView.tab.server.name, newView.tab.type); ipcMain.emit(SET_ACTIVE_VIEW, true, newView.tab.server.name, newView.tab.type);
if (newView.isReady()) { if (newView.isReady()) {
ipcMain.emit(UPDATE_LAST_ACTIVE, true, newView.tab.server.name, newView.tab.type); ipcMain.emit(UPDATE_LAST_ACTIVE, true, newView.tab.server.name, newView.tab.type);
@@ -375,13 +372,13 @@ export class ViewManager {
const query = new Map([['url', urlString]]); const query = new Map([['url', urlString]]);
const localURL = getLocalURLString('urlView.html', query); const localURL = getLocalURLString('urlView.html', query);
urlView.webContents.loadURL(localURL); urlView.webContents.loadURL(localURL);
this.mainWindow.addBrowserView(urlView); MainWindow.get()?.addBrowserView(urlView);
const boundaries = this.views.get(this.currentView || '')?.view.getBounds() ?? this.mainWindow.getBounds(); const boundaries = this.views.get(this.currentView || '')?.view.getBounds() ?? MainWindow.get()!.getBounds();
const hideView = () => { const hideView = () => {
delete this.urlViewCancel; delete this.urlViewCancel;
try { try {
this.mainWindow.removeBrowserView(urlView); MainWindow.get()?.removeBrowserView(urlView);
} catch (e) { } catch (e) {
log.error('Failed to remove URL view', e); log.error('Failed to remove URL view', e);
} }
@@ -421,9 +418,11 @@ export class ViewManager {
} }
setLoadingScreenBounds = () => { setLoadingScreenBounds = () => {
if (this.loadingScreen) { const mainWindow = MainWindow.get();
this.loadingScreen.setBounds(getWindowBoundaries(this.mainWindow)); if (!mainWindow) {
return;
} }
this.loadingScreen?.setBounds(getWindowBoundaries(mainWindow));
} }
createLoadingScreen = () => { createLoadingScreen = () => {
@@ -441,6 +440,11 @@ export class ViewManager {
} }
showLoadingScreen = () => { showLoadingScreen = () => {
const mainWindow = MainWindow.get();
if (!mainWindow) {
return;
}
if (!this.loadingScreen) { if (!this.loadingScreen) {
this.createLoadingScreen(); this.createLoadingScreen();
} }
@@ -455,10 +459,10 @@ export class ViewManager {
this.loadingScreen!.webContents.send(TOGGLE_LOADING_SCREEN_VISIBILITY, true); this.loadingScreen!.webContents.send(TOGGLE_LOADING_SCREEN_VISIBILITY, true);
} }
if (this.mainWindow.getBrowserViews().includes(this.loadingScreen!)) { if (mainWindow.getBrowserViews().includes(this.loadingScreen!)) {
this.mainWindow.setTopBrowserView(this.loadingScreen!); mainWindow.setTopBrowserView(this.loadingScreen!);
} else { } else {
this.mainWindow.addBrowserView(this.loadingScreen!); mainWindow.addBrowserView(this.loadingScreen!);
} }
this.setLoadingScreenBounds(); this.setLoadingScreenBounds();
@@ -474,7 +478,7 @@ export class ViewManager {
hideLoadingScreen = () => { hideLoadingScreen = () => {
if (this.loadingScreen && this.loadingScreenState !== LoadingScreenState.HIDDEN) { if (this.loadingScreen && this.loadingScreenState !== LoadingScreenState.HIDDEN) {
this.loadingScreenState = LoadingScreenState.HIDDEN; this.loadingScreenState = LoadingScreenState.HIDDEN;
this.mainWindow.removeBrowserView(this.loadingScreen); MainWindow.get()?.removeBrowserView(this.loadingScreen);
} }
} }

View File

@@ -10,21 +10,24 @@ import {BrowserWindow, screen, app, globalShortcut, dialog} from 'electron';
import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB} from 'common/communication'; import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB} from 'common/communication';
import Config from 'common/config'; import Config from 'common/config';
import {DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH} from 'common/utils/constants'; import {DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH} from 'common/utils/constants';
import ContextMenu from '../contextMenu';
import * as Validator from 'common/Validator'; import * as Validator from 'common/Validator';
import createMainWindow from './mainWindow'; import ContextMenu from '../contextMenu';
import {isInsideRectangle} from '../utils';
import {MainWindow} from './mainWindow';
jest.mock('path', () => ({ jest.mock('path', () => ({
join: jest.fn(), join: jest.fn(),
resolve: jest.fn(),
})); }));
jest.mock('electron', () => ({ jest.mock('electron', () => ({
app: { app: {
getAppPath: jest.fn(),
getPath: jest.fn(), getPath: jest.fn(),
hide: jest.fn(), hide: jest.fn(),
quit: jest.fn(), quit: jest.fn(),
relaunch: jest.fn(),
}, },
dialog: { dialog: {
showMessageBox: jest.fn(), showMessageBox: jest.fn(),
@@ -65,6 +68,7 @@ jest.mock('common/Validator', () => ({
jest.mock('../contextMenu', () => jest.fn()); jest.mock('../contextMenu', () => jest.fn());
jest.mock('../utils', () => ({ jest.mock('../utils', () => ({
isInsideRectangle: jest.fn(),
getLocalPreload: jest.fn(), getLocalPreload: jest.fn(),
getLocalURLString: jest.fn(), getLocalURLString: jest.fn(),
})); }));
@@ -76,7 +80,7 @@ jest.mock('main/i18nManager', () => ({
'use strict'; 'use strict';
describe('main/windows/mainWindow', () => { describe('main/windows/mainWindow', () => {
describe('createMainWindow', () => { describe('init', () => {
const baseWindow = { const baseWindow = {
setMenuBarVisibility: jest.fn(), setMenuBarVisibility: jest.fn(),
loadURL: jest.fn(), loadURL: jest.fn(),
@@ -90,6 +94,7 @@ describe('main/windows/mainWindow', () => {
webContents: { webContents: {
on: jest.fn(), on: jest.fn(),
send: jest.fn(), send: jest.fn(),
setWindowOpenHandler: jest.fn(),
}, },
isMaximized: jest.fn(), isMaximized: jest.fn(),
isFullScreen: jest.fn(), isFullScreen: jest.fn(),
@@ -104,6 +109,7 @@ describe('main/windows/mainWindow', () => {
fs.readFileSync.mockImplementation(() => '{"x":400,"y":300,"width":1280,"height":700,"maximized":false,"fullscreen":false}'); fs.readFileSync.mockImplementation(() => '{"x":400,"y":300,"width":1280,"height":700,"maximized":false,"fullscreen":false}');
path.join.mockImplementation(() => 'anyfile.txt'); path.join.mockImplementation(() => 'anyfile.txt');
screen.getDisplayMatching.mockImplementation(() => ({bounds: {x: 0, y: 0, width: 1920, height: 1080}})); screen.getDisplayMatching.mockImplementation(() => ({bounds: {x: 0, y: 0, width: 1920, height: 1080}}));
isInsideRectangle.mockReturnValue(true);
Validator.validateBoundsInfo.mockImplementation((data) => data); Validator.validateBoundsInfo.mockImplementation((data) => data);
ContextMenu.mockImplementation(() => ({ ContextMenu.mockImplementation(() => ({
reload: jest.fn(), reload: jest.fn(),
@@ -115,7 +121,8 @@ describe('main/windows/mainWindow', () => {
}); });
it('should set window size using bounds read from file', () => { it('should set window size using bounds read from file', () => {
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({
x: 400, x: 400,
y: 300, y: 300,
@@ -126,16 +133,10 @@ describe('main/windows/mainWindow', () => {
})); }));
}); });
it('should open in fullscreen if fullscreen set to true', () => {
createMainWindow({fullscreen: true});
expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({
fullscreen: true,
}));
});
it('should set default window size when failing to read bounds from file', () => { it('should set default window size when failing to read bounds from file', () => {
fs.readFileSync.mockImplementation(() => 'just a bunch of garbage'); fs.readFileSync.mockImplementation(() => 'just a bunch of garbage');
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({
width: DEFAULT_WINDOW_WIDTH, width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT, height: DEFAULT_WINDOW_HEIGHT,
@@ -145,27 +146,15 @@ describe('main/windows/mainWindow', () => {
it('should set default window size when bounds are outside the normal screen', () => { it('should set default window size when bounds are outside the normal screen', () => {
fs.readFileSync.mockImplementation(() => '{"x":-400,"y":-300,"width":1280,"height":700,"maximized":false,"fullscreen":false}'); fs.readFileSync.mockImplementation(() => '{"x":-400,"y":-300,"width":1280,"height":700,"maximized":false,"fullscreen":false}');
screen.getDisplayMatching.mockImplementation(() => ({bounds: {x: 0, y: 0, width: 1920, height: 1080}})); screen.getDisplayMatching.mockImplementation(() => ({bounds: {x: 0, y: 0, width: 1920, height: 1080}}));
createMainWindow({}); isInsideRectangle.mockReturnValue(false);
const mainWindow = new MainWindow();
mainWindow.init();
expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({
width: DEFAULT_WINDOW_WIDTH, width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT, height: DEFAULT_WINDOW_HEIGHT,
})); }));
}); });
it('should set linux app icon', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'linux',
});
createMainWindow({linuxAppIcon: 'linux-icon.png'});
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({
icon: 'linux-icon.png',
}));
});
it('should reset zoom level and maximize if applicable on ready-to-show', () => { it('should reset zoom level and maximize if applicable on ready-to-show', () => {
const window = { const window = {
...baseWindow, ...baseWindow,
@@ -178,7 +167,8 @@ describe('main/windows/mainWindow', () => {
BrowserWindow.mockImplementation(() => window); BrowserWindow.mockImplementation(() => window);
fs.readFileSync.mockImplementation(() => '{"x":400,"y":300,"width":1280,"height":700,"maximized":true,"fullscreen":false}'); fs.readFileSync.mockImplementation(() => '{"x":400,"y":300,"width":1280,"height":700,"maximized":true,"fullscreen":false}');
Config.hideOnStart = false; Config.hideOnStart = false;
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
expect(window.webContents.zoomLevel).toStrictEqual(0); expect(window.webContents.zoomLevel).toStrictEqual(0);
expect(window.maximize).toBeCalled(); expect(window.maximize).toBeCalled();
}); });
@@ -195,7 +185,8 @@ describe('main/windows/mainWindow', () => {
BrowserWindow.mockImplementation(() => window); BrowserWindow.mockImplementation(() => window);
fs.readFileSync.mockImplementation(() => '{"x":400,"y":300,"width":1280,"height":700,"maximized":true,"fullscreen":false}'); fs.readFileSync.mockImplementation(() => '{"x":400,"y":300,"width":1280,"height":700,"maximized":true,"fullscreen":false}');
Config.hideOnStart = true; Config.hideOnStart = true;
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
expect(window.show).not.toHaveBeenCalled(); expect(window.show).not.toHaveBeenCalled();
}); });
@@ -210,7 +201,8 @@ describe('main/windows/mainWindow', () => {
}), }),
}; };
BrowserWindow.mockImplementation(() => window); BrowserWindow.mockImplementation(() => window);
createMainWindow({}, {}); const mainWindow = new MainWindow();
mainWindow.init();
global.willAppQuit = false; global.willAppQuit = false;
expect(fs.writeFileSync).toHaveBeenCalled(); expect(fs.writeFileSync).toHaveBeenCalled();
}); });
@@ -231,7 +223,8 @@ describe('main/windows/mainWindow', () => {
BrowserWindow.mockImplementation(() => window); BrowserWindow.mockImplementation(() => window);
Config.minimizeToTray = true; Config.minimizeToTray = true;
Config.alwaysMinimize = true; Config.alwaysMinimize = true;
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
Config.minimizeToTray = false; Config.minimizeToTray = false;
Config.alwaysMinimize = false; Config.alwaysMinimize = false;
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
@@ -255,7 +248,8 @@ describe('main/windows/mainWindow', () => {
}; };
BrowserWindow.mockImplementation(() => window); BrowserWindow.mockImplementation(() => window);
Config.alwaysClose = true; Config.alwaysClose = true;
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
Config.alwaysClose = false; Config.alwaysClose = false;
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
value: originalPlatform, value: originalPlatform,
@@ -278,11 +272,13 @@ describe('main/windows/mainWindow', () => {
}; };
BrowserWindow.mockImplementation(() => window); BrowserWindow.mockImplementation(() => window);
dialog.showMessageBox.mockResolvedValue({response: 1}); dialog.showMessageBox.mockResolvedValue({response: 1});
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
expect(app.quit).not.toHaveBeenCalled(); expect(app.quit).not.toHaveBeenCalled();
const promise = Promise.resolve({response: 0}); const promise = Promise.resolve({response: 0});
dialog.showMessageBox.mockImplementation(() => promise); dialog.showMessageBox.mockImplementation(() => promise);
createMainWindow({}); const mainWindow2 = new MainWindow();
mainWindow2.init();
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
value: originalPlatform, value: originalPlatform,
}); });
@@ -306,7 +302,8 @@ describe('main/windows/mainWindow', () => {
BrowserWindow.mockImplementation(() => window); BrowserWindow.mockImplementation(() => window);
Config.minimizeToTray = true; Config.minimizeToTray = true;
Config.alwaysMinimize = true; Config.alwaysMinimize = true;
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
Config.minimizeToTray = false; Config.minimizeToTray = false;
Config.alwaysMinimize = false; Config.alwaysMinimize = false;
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
@@ -330,7 +327,8 @@ describe('main/windows/mainWindow', () => {
}; };
BrowserWindow.mockImplementation(() => window); BrowserWindow.mockImplementation(() => window);
Config.alwaysClose = true; Config.alwaysClose = true;
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
Config.alwaysClose = false; Config.alwaysClose = false;
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
value: originalPlatform, value: originalPlatform,
@@ -353,11 +351,13 @@ describe('main/windows/mainWindow', () => {
}; };
BrowserWindow.mockImplementation(() => window); BrowserWindow.mockImplementation(() => window);
dialog.showMessageBox.mockResolvedValue({response: 1}); dialog.showMessageBox.mockResolvedValue({response: 1});
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
expect(app.quit).not.toHaveBeenCalled(); expect(app.quit).not.toHaveBeenCalled();
const promise = Promise.resolve({response: 0}); const promise = Promise.resolve({response: 0});
dialog.showMessageBox.mockImplementation(() => promise); dialog.showMessageBox.mockImplementation(() => promise);
createMainWindow({}); const mainWindow2 = new MainWindow();
mainWindow2.init();
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
value: originalPlatform, value: originalPlatform,
}); });
@@ -379,7 +379,8 @@ describe('main/windows/mainWindow', () => {
}), }),
}; };
BrowserWindow.mockImplementation(() => window); BrowserWindow.mockImplementation(() => window);
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
value: originalPlatform, value: originalPlatform,
}); });
@@ -407,7 +408,8 @@ describe('main/windows/mainWindow', () => {
}), }),
}; };
BrowserWindow.mockImplementation(() => window); BrowserWindow.mockImplementation(() => window);
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
value: originalPlatform, value: originalPlatform,
}); });
@@ -434,7 +436,8 @@ describe('main/windows/mainWindow', () => {
}, },
}; };
BrowserWindow.mockImplementation(() => window); BrowserWindow.mockImplementation(() => window);
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
value: originalPlatform, value: originalPlatform,
}); });
@@ -456,11 +459,28 @@ describe('main/windows/mainWindow', () => {
}), }),
}; };
BrowserWindow.mockImplementation(() => window); BrowserWindow.mockImplementation(() => window);
createMainWindow({}); const mainWindow = new MainWindow();
mainWindow.init();
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
value: originalPlatform, value: originalPlatform,
}); });
expect(globalShortcut.registerAll).toHaveBeenCalledWith(['Alt+F', 'Alt+E', 'Alt+V', 'Alt+H', 'Alt+W', 'Alt+P'], expect.any(Function)); expect(globalShortcut.registerAll).toHaveBeenCalledWith(['Alt+F', 'Alt+E', 'Alt+V', 'Alt+H', 'Alt+W', 'Alt+P'], expect.any(Function));
}); });
}); });
describe('onUnresponsive', () => {
const mainWindow = new MainWindow();
beforeEach(() => {
mainWindow.win = {};
});
it('should call app.relaunch when user elects not to wait', async () => {
const promise = Promise.resolve({response: 0});
dialog.showMessageBox.mockImplementation(() => promise);
mainWindow.onUnresponsive();
await promise;
expect(app.relaunch).toBeCalled();
});
});
}); });

View File

@@ -3,14 +3,16 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path';
import os from 'os'; import os from 'os';
import {app, BrowserWindow, BrowserWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen} from 'electron'; import {app, BrowserWindow, BrowserWindowConstructorOptions, dialog, Event, globalShortcut, Input, ipcMain, screen} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import {SavedWindowState} from 'types/mainWindow'; import {SavedWindowState} from 'types/mainWindow';
import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB, GET_FULL_SCREEN_STATUS} from 'common/communication'; import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB, GET_FULL_SCREEN_STATUS, FOCUS_THREE_DOT_MENU} from 'common/communication';
import Config from 'common/config'; import Config from 'common/config';
import {DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, MINIMUM_WINDOW_HEIGHT, MINIMUM_WINDOW_WIDTH} from 'common/utils/constants'; import {DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, MINIMUM_WINDOW_HEIGHT, MINIMUM_WINDOW_WIDTH} from 'common/utils/constants';
import Utils from 'common/utils/util'; import Utils from 'common/utils/util';
@@ -20,33 +22,136 @@ import {boundsInfoPath} from 'main/constants';
import {localizeMessage} from 'main/i18nManager'; import {localizeMessage} from 'main/i18nManager';
import ContextMenu from '../contextMenu'; import ContextMenu from '../contextMenu';
import {getLocalPreload, getLocalURLString} from '../utils'; import {getLocalPreload, getLocalURLString, isInsideRectangle} from '../utils';
function saveWindowState(file: string, window: BrowserWindow) { const ALT_MENU_KEYS = ['Alt+F', 'Alt+E', 'Alt+V', 'Alt+H', 'Alt+W', 'Alt+P'];
const windowState: SavedWindowState = {
...window.getBounds(),
maximized: window.isMaximized(),
fullscreen: window.isFullScreen(),
};
try {
fs.writeFileSync(file, JSON.stringify(windowState));
} catch (e) {
// [Linux] error happens only when the window state is changed before the config dir is created.
log.error(e);
}
}
function isInsideRectangle(container: Electron.Rectangle, rect: Electron.Rectangle) { export class MainWindow {
return container.x <= rect.x && container.y <= rect.y && container.width >= rect.width && container.height >= rect.height; private win?: BrowserWindow;
}
function isFramelessWindow() { private savedWindowState: SavedWindowState;
return os.platform() === 'darwin' || (os.platform() === 'win32' && Utils.isVersionGreaterThanOrEqualTo(os.release(), '6.2')); private ready: boolean;
}
function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean}) { constructor() {
// Create the browser window. // Create the browser window.
const preload = getLocalPreload('desktopAPI.js'); this.ready = false;
this.savedWindowState = this.getSavedWindowState();
ipcMain.handle(GET_FULL_SCREEN_STATUS, () => this.win?.isFullScreen());
}
init = () => {
const windowOptions: BrowserWindowConstructorOptions = Object.assign({}, this.savedWindowState, {
title: app.name,
fullscreenable: true,
show: false, // don't start the window until it is ready and only if it isn't hidden
paintWhenInitiallyHidden: true, // we want it to start painting to get info from the webapp
minWidth: MINIMUM_WINDOW_WIDTH,
minHeight: MINIMUM_WINDOW_HEIGHT,
frame: !this.isFramelessWindow(),
fullscreen: this.shouldStartFullScreen(),
titleBarStyle: 'hidden' as const,
trafficLightPosition: {x: 12, y: 12},
backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
webPreferences: {
disableBlinkFeatures: 'Auxclick',
preload: getLocalPreload('desktopAPI.js'),
spellcheck: typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker,
},
});
if (process.platform === 'linux') {
windowOptions.icon = path.join(path.resolve(app.getAppPath(), 'assets'), 'linux', 'app_icon.png');
}
this.win = new BrowserWindow(windowOptions);
this.win.setMenuBarVisibility(false);
if (!this.win) {
throw new Error('unable to create main window');
}
const localURL = getLocalURLString('index.html');
this.win.loadURL(localURL).catch(
(reason) => {
log.error('failed to load', reason);
});
this.win.once('ready-to-show', () => {
if (!this.win) {
return;
}
this.win.webContents.zoomLevel = 0;
if (Config.hideOnStart === false) {
this.win.show();
if (this.savedWindowState.maximized) {
this.win.maximize();
}
}
this.ready = true;
});
this.win.once('restore', () => {
this.win?.restore();
});
this.win.on('close', this.onClose);
this.win.on('closed', this.onClosed);
this.win.on('focus', this.onFocus);
this.win.on('blur', this.onBlur);
this.win.on('unresponsive', this.onUnresponsive);
this.win.webContents.on('before-input-event', this.onBeforeInputEvent);
// Should not allow the main window to generate a window of its own
this.win.webContents.setWindowOpenHandler(() => ({action: 'deny'}));
if (process.env.MM_DEBUG_SETTINGS) {
this.win.webContents.openDevTools({mode: 'detach'});
}
const contextMenu = new ContextMenu({}, this.win);
contextMenu.reload();
}
get isReady() {
return this.ready;
}
get = (ensureCreated?: boolean) => {
if (ensureCreated && !this.win) {
this.init();
}
return this.win;
}
getBounds = () => {
return this.win?.getContentBounds();
}
focusThreeDotMenu = () => {
if (this.win) {
this.win.webContents.focus();
this.win.webContents.send(FOCUS_THREE_DOT_MENU);
}
}
private shouldStartFullScreen = () => {
if (global?.args?.fullscreen !== undefined) {
return global.args.fullscreen;
}
if (Config.startInFullscreen) {
return Config.startInFullscreen;
}
return this.savedWindowState.fullscreen || false;
}
private isFramelessWindow = () => {
return os.platform() === 'darwin' || (os.platform() === 'win32' && Utils.isVersionGreaterThanOrEqualTo(os.release(), '6.2'));
}
private getSavedWindowState = () => {
let savedWindowState: any; let savedWindowState: any;
try { try {
savedWindowState = JSON.parse(fs.readFileSync(boundsInfoPath, 'utf-8')); savedWindowState = JSON.parse(fs.readFileSync(boundsInfoPath, 'utf-8'));
@@ -62,87 +167,71 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean})
// Follow Electron's defaults, except for window dimensions which targets 1024x768 screen resolution. // Follow Electron's defaults, except for window dimensions which targets 1024x768 screen resolution.
savedWindowState = {width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT}; savedWindowState = {width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT};
} }
return savedWindowState;
const {maximized: windowIsMaximized} = savedWindowState;
const spellcheck = (typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker);
const isFullScreen = () => {
if (global?.args?.fullscreen !== undefined) {
return global.args.fullscreen;
} }
if (Config.startInFullscreen) { private saveWindowState = (file: string, window: BrowserWindow) => {
return Config.startInFullscreen; const windowState: SavedWindowState = {
} ...window.getBounds(),
return options.fullscreen || savedWindowState.fullscreen || false; maximized: window.isMaximized(),
fullscreen: window.isFullScreen(),
}; };
const windowOptions: BrowserWindowConstructorOptions = Object.assign({}, savedWindowState, {
title: app.name,
fullscreenable: true,
show: false, // don't start the window until it is ready and only if it isn't hidden
paintWhenInitiallyHidden: true, // we want it to start painting to get info from the webapp
minWidth: MINIMUM_WINDOW_WIDTH,
minHeight: MINIMUM_WINDOW_HEIGHT,
frame: !isFramelessWindow(),
fullscreen: isFullScreen(),
titleBarStyle: 'hidden' as const,
trafficLightPosition: {x: 12, y: 12},
backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
webPreferences: {
disableBlinkFeatures: 'Auxclick',
preload,
spellcheck,
},
});
if (process.platform === 'linux') {
windowOptions.icon = options.linuxAppIcon;
}
const mainWindow = new BrowserWindow(windowOptions);
mainWindow.setMenuBarVisibility(false);
try { try {
ipcMain.handle(GET_FULL_SCREEN_STATUS, () => mainWindow.isFullScreen()); fs.writeFileSync(file, JSON.stringify(windowState));
} catch (e) { } catch (e) {
log.error('Tried to register second handler, skipping'); // [Linux] error happens only when the window state is changed before the config dir is created.
} log.error('failed to save window state', e);
const localURL = getLocalURLString('index.html');
mainWindow.loadURL(localURL).catch(
(reason) => {
log.error(`Main window failed to load: ${reason}`);
});
mainWindow.once('ready-to-show', () => {
mainWindow.webContents.zoomLevel = 0;
if (Config.hideOnStart === false) {
mainWindow.show();
if (windowIsMaximized) {
mainWindow.maximize();
} }
} }
});
mainWindow.once('restore', () => { private onBeforeInputEvent = (event: Event, input: Input) => {
mainWindow.restore(); // Register keyboard shortcuts
// Add Alt+Cmd+(Right|Left) as alternative to switch between servers
if (this.win && process.platform === 'darwin') {
if (input.alt && input.meta) {
if (input.key === 'ArrowRight') {
this.win.webContents.send(SELECT_NEXT_TAB);
}
if (input.key === 'ArrowLeft') {
this.win.webContents.send(SELECT_PREVIOUS_TAB);
}
}
}
}
private onFocus = () => {
// Only add shortcuts when window is in focus
if (process.platform === 'linux') {
globalShortcut.registerAll(ALT_MENU_KEYS, () => {
// do nothing because we want to supress the menu popping up
}); });
}
}
private onBlur = () => {
if (!this.win) {
return;
}
globalShortcut.unregisterAll();
// App should save bounds when a window is closed. // App should save bounds when a window is closed.
// However, 'close' is not fired in some situations(shutdown, ctrl+c) // However, 'close' is not fired in some situations(shutdown, ctrl+c)
// because main process is killed in such situations. // because main process is killed in such situations.
// 'blur' event was effective in order to avoid this. // 'blur' event was effective in order to avoid this.
// Ideally, app should detect that OS is shutting down. // Ideally, app should detect that OS is shutting down.
mainWindow.on('blur', () => { this.saveWindowState(boundsInfoPath, this.win);
saveWindowState(boundsInfoPath, mainWindow); }
});
mainWindow.on('close', (event) => { private onClose = (event: Event) => {
log.debug('MainWindow.on.close'); log.debug('MainWindow.on.close');
if (!this.win) {
return;
}
if (global.willAppQuit) { // when [Ctrl|Cmd]+Q if (global.willAppQuit) { // when [Ctrl|Cmd]+Q
saveWindowState(boundsInfoPath, mainWindow); this.saveWindowState(boundsInfoPath, this.win);
} else { // Minimize or hide the window for close button. } else { // Minimize or hide the window for close button.
event.preventDefault(); event.preventDefault();
function hideWindow(window: BrowserWindow) { function hideWindow(window: BrowserWindow) {
@@ -154,9 +243,9 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean})
case 'linux': case 'linux':
if (Config.minimizeToTray) { if (Config.minimizeToTray) {
if (Config.alwaysMinimize) { if (Config.alwaysMinimize) {
hideWindow(mainWindow); hideWindow(this.win);
} else { } else {
dialog.showMessageBox(mainWindow, { dialog.showMessageBox(this.win, {
title: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.title', 'Minimize to Tray'), title: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.title', 'Minimize to Tray'),
message: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.message', '{appName} will continue to run in the system tray. This can be disabled in Settings.', {appName: app.name}), message: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.message', '{appName} will continue to run in the system tray. This can be disabled in Settings.', {appName: app.name}),
type: 'info', type: 'info',
@@ -164,13 +253,13 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean})
checkboxLabel: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.checkboxLabel', 'Don\'t show again'), checkboxLabel: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.checkboxLabel', 'Don\'t show again'),
}).then((result: {response: number; checkboxChecked: boolean}) => { }).then((result: {response: number; checkboxChecked: boolean}) => {
Config.set('alwaysMinimize', result.checkboxChecked); Config.set('alwaysMinimize', result.checkboxChecked);
hideWindow(mainWindow); hideWindow(this.win!);
}); });
} }
} else if (Config.alwaysClose) { } else if (Config.alwaysClose) {
app.quit(); app.quit();
} else { } else {
dialog.showMessageBox(mainWindow, { dialog.showMessageBox(this.win, {
title: localizeMessage('main.windows.mainWindow.closeApp.dialog.title', 'Close Application'), title: localizeMessage('main.windows.mainWindow.closeApp.dialog.title', 'Close Application'),
message: localizeMessage('main.windows.mainWindow.closeApp.dialog.message', 'Are you sure you want to quit?'), message: localizeMessage('main.windows.mainWindow.closeApp.dialog.message', 'Are you sure you want to quit?'),
detail: localizeMessage('main.windows.mainWindow.closeApp.dialog.detail', 'You will no longer receive notifications for messages. If you want to leave {appName} running in the system tray, you can enable this in Settings.', {appName: app.name}), detail: localizeMessage('main.windows.mainWindow.closeApp.dialog.detail', 'You will no longer receive notifications for messages. If you want to leave {appName} running in the system tray, you can enable this in Settings.', {appName: app.name}),
@@ -191,11 +280,11 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean})
break; break;
case 'darwin': case 'darwin':
// need to leave fullscreen first, then hide the window // need to leave fullscreen first, then hide the window
if (mainWindow.isFullScreen()) { if (this.win.isFullScreen()) {
mainWindow.once('leave-full-screen', () => { this.win.once('leave-full-screen', () => {
app.hide(); app.hide();
}); });
mainWindow.setFullScreen(false); this.win.setFullScreen(false);
} else { } else {
app.hide(); app.hide();
} }
@@ -203,39 +292,35 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean})
default: default:
} }
} }
}); }
// Register keyboard shortcuts private onClosed = () => {
mainWindow.webContents.on('before-input-event', (event, input) => { log.verbose('main window closed');
// Add Alt+Cmd+(Right|Left) as alternative to switch between servers delete this.win;
if (process.platform === 'darwin') { this.ready = false;
if (input.alt && input.meta) {
if (input.key === 'ArrowRight') {
mainWindow.webContents.send(SELECT_NEXT_TAB);
} }
if (input.key === 'ArrowLeft') {
mainWindow.webContents.send(SELECT_PREVIOUS_TAB);
}
}
}
});
// Only add shortcuts when window is in focus private onUnresponsive = () => {
mainWindow.on('focus', () => { if (!this.win) {
if (process.platform === 'linux') { throw new Error('BrowserWindow \'unresponsive\' event has been emitted');
globalShortcut.registerAll(['Alt+F', 'Alt+E', 'Alt+V', 'Alt+H', 'Alt+W', 'Alt+P'], () => { }
// do nothing because we want to supress the menu popping up dialog.showMessageBox(this.win, {
}); type: 'warning',
title: app.name,
message: localizeMessage('main.CriticalErrorHandler.unresponsive.dialog.message', 'The window is no longer responsive.\nDo you wait until the window becomes responsive again?'),
buttons: [
localizeMessage('label.no', 'No'),
localizeMessage('label.yes', 'Yes'),
],
defaultId: 0,
}).then(({response}) => {
if (response === 0) {
log.error('BrowserWindow \'unresponsive\' event has been emitted');
app.relaunch();
} }
}); });
mainWindow.on('blur', () => { }
globalShortcut.unregisterAll();
});
const contextMenu = new ContextMenu({}, mainWindow);
contextMenu.reload();
return mainWindow;
} }
export default createMainWindow; const mainWindow = new MainWindow();
export default mainWindow;

View File

@@ -1,18 +1,45 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {BrowserWindow} from 'electron'; import {BrowserWindow, ipcMain} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import {SHOW_SETTINGS_WINDOW} from 'common/communication';
import Config from 'common/config'; import Config from 'common/config';
import ContextMenu from '../contextMenu'; import ContextMenu from '../contextMenu';
import {getLocalPreload, getLocalURLString} from '../utils'; import {getLocalPreload, getLocalURLString} from '../utils';
export function createSettingsWindow(mainWindow: BrowserWindow, withDevTools: boolean) { import MainWindow from './mainWindow';
export class SettingsWindow {
private win?: BrowserWindow;
constructor() {
ipcMain.on(SHOW_SETTINGS_WINDOW, this.show);
}
show = () => {
if (this.win) {
this.win.show();
} else {
this.create();
}
}
get = () => {
return this.win;
}
private create = () => {
const mainWindow = MainWindow.get(true);
if (!mainWindow) {
return;
}
const preload = getLocalPreload('desktopAPI.js'); const preload = getLocalPreload('desktopAPI.js');
const spellcheck = (typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker); const spellcheck = (typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker);
const settingsWindow = new BrowserWindow({ this.win = new BrowserWindow({
parent: mainWindow, parent: mainWindow,
title: 'Desktop App Settings', title: 'Desktop App Settings',
fullscreen: false, fullscreen: false,
@@ -21,20 +48,26 @@ export function createSettingsWindow(mainWindow: BrowserWindow, withDevTools: bo
spellcheck, spellcheck,
}}); }});
const contextMenu = new ContextMenu({}, settingsWindow); const contextMenu = new ContextMenu({}, this.win);
contextMenu.reload(); contextMenu.reload();
const localURL = getLocalURLString('settings.html'); const localURL = getLocalURLString('settings.html');
settingsWindow.setMenuBarVisibility(false); this.win.setMenuBarVisibility(false);
settingsWindow.loadURL(localURL).catch( this.win.loadURL(localURL).catch(
(reason) => { (reason) => {
log.error(`Settings window failed to load: ${reason}`); log.error('failed to load', reason);
log.info(process.env);
}); });
settingsWindow.show(); this.win.show();
if (withDevTools) { if (Boolean(process.env.MM_DEBUG_SETTINGS) || false) {
settingsWindow.webContents.openDevTools({mode: 'detach'}); this.win.webContents.openDevTools({mode: 'detach'});
}
this.win.on('closed', () => {
delete this.win;
});
} }
return settingsWindow;
} }
const settingsWindow = new SettingsWindow();
export default settingsWindow;

View File

@@ -4,7 +4,7 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
'use strict'; 'use strict';
import {app, systemPreferences, desktopCapturer} from 'electron'; import {systemPreferences, desktopCapturer} from 'electron';
import Config from 'common/config'; import Config from 'common/config';
import {getTabViewName, TAB_MESSAGING} from 'common/tabs/TabView'; import {getTabViewName, TAB_MESSAGING} from 'common/tabs/TabView';
@@ -17,9 +17,8 @@ import {
} from 'main/utils'; } from 'main/utils';
import {WindowManager} from './windowManager'; import {WindowManager} from './windowManager';
import createMainWindow from './mainWindow'; import MainWindow from './mainWindow';
import {createSettingsWindow} from './settingsWindow'; import SettingsWindow from './settingsWindow';
import CallsWidgetWindow from './callsWidgetWindow'; import CallsWidgetWindow from './callsWidgetWindow';
jest.mock('path', () => ({ jest.mock('path', () => ({
@@ -78,9 +77,13 @@ jest.mock('../views/teamDropdownView', () => jest.fn());
jest.mock('../views/downloadsDropdownView', () => jest.fn()); jest.mock('../views/downloadsDropdownView', () => jest.fn());
jest.mock('../views/downloadsDropdownMenuView', () => jest.fn()); jest.mock('../views/downloadsDropdownMenuView', () => jest.fn());
jest.mock('./settingsWindow', () => ({ jest.mock('./settingsWindow', () => ({
createSettingsWindow: jest.fn(), show: jest.fn(),
get: jest.fn(),
}));
jest.mock('./mainWindow', () => ({
get: jest.fn(),
focus: jest.fn(),
})); }));
jest.mock('./mainWindow', () => jest.fn());
jest.mock('../downloadsManager', () => ({ jest.mock('../downloadsManager', () => ({
getDownloads: () => {}, getDownloads: () => {},
})); }));
@@ -104,40 +107,6 @@ describe('main/windows/windowManager', () => {
}); });
}); });
describe('showSettingsWindow', () => {
const windowManager = new WindowManager();
windowManager.showMainWindow = jest.fn();
afterEach(() => {
jest.resetAllMocks();
delete windowManager.settingsWindow;
delete windowManager.mainWindow;
});
it('should show settings window if it exists', () => {
const settingsWindow = {show: jest.fn()};
windowManager.settingsWindow = settingsWindow;
windowManager.showSettingsWindow();
expect(settingsWindow.show).toHaveBeenCalled();
});
it('should create windows if they dont exist and delete the settings window when it is closed', () => {
let callback;
createSettingsWindow.mockReturnValue({on: (event, cb) => {
if (event === 'closed') {
callback = cb;
}
}});
windowManager.showSettingsWindow();
expect(windowManager.showMainWindow).toHaveBeenCalled();
expect(createSettingsWindow).toHaveBeenCalled();
expect(windowManager.settingsWindow).toBeDefined();
callback();
expect(windowManager.settingsWindow).toBeUndefined();
});
});
describe('showMainWindow', () => { describe('showMainWindow', () => {
const windowManager = new WindowManager(); const windowManager = new WindowManager();
windowManager.viewManager = { windowManager.viewManager = {
@@ -146,56 +115,38 @@ describe('main/windows/windowManager', () => {
}; };
windowManager.initializeViewManager = jest.fn(); windowManager.initializeViewManager = jest.fn();
const mainWindow = {
visible: false,
isVisible: () => mainWindow.visible,
show: jest.fn(),
focus: jest.fn(),
on: jest.fn(),
once: jest.fn(),
webContents: {
setWindowOpenHandler: jest.fn(),
},
};
beforeEach(() => {
mainWindow.show.mockImplementation(() => {
mainWindow.visible = true;
});
MainWindow.get.mockReturnValue(mainWindow);
});
afterEach(() => { afterEach(() => {
delete windowManager.mainWindow; jest.resetAllMocks();
}); });
it('should show main window if it exists and focus it if it is already visible', () => { it('should show main window if it exists and focus it if it is already visible', () => {
windowManager.mainWindow = { windowManager.showMainWindow();
visible: false, expect(mainWindow.show).toHaveBeenCalled();
isVisible: () => windowManager.mainWindow.visible,
show: jest.fn().mockImplementation(() => {
windowManager.mainWindow.visible = true;
}),
focus: jest.fn(),
};
windowManager.showMainWindow(); windowManager.showMainWindow();
expect(windowManager.mainWindow.show).toHaveBeenCalled(); expect(mainWindow.focus).toHaveBeenCalled();
windowManager.showMainWindow();
expect(windowManager.mainWindow.focus).toHaveBeenCalled();
});
it('should quit the app when the main window fails to create', () => {
windowManager.showMainWindow();
expect(app.quit).toHaveBeenCalled();
});
it('should create the main window and add listeners', () => {
const window = {
on: jest.fn(),
once: jest.fn(),
webContents: {
setWindowOpenHandler: jest.fn(),
},
};
createMainWindow.mockReturnValue(window);
windowManager.showMainWindow();
expect(windowManager.mainWindow).toBe(window);
expect(window.on).toHaveBeenCalled();
expect(window.webContents.setWindowOpenHandler).toHaveBeenCalled();
}); });
it('should open deep link when provided', () => { it('should open deep link when provided', () => {
const window = {
on: jest.fn(),
once: jest.fn(),
webContents: {
setWindowOpenHandler: jest.fn(),
},
};
createMainWindow.mockReturnValue(window);
windowManager.showMainWindow('mattermost://server-1.com/subpath'); windowManager.showMainWindow('mattermost://server-1.com/subpath');
expect(windowManager.viewManager.handleDeepLink).toHaveBeenCalledWith('mattermost://server-1.com/subpath'); expect(windowManager.viewManager.handleDeepLink).toHaveBeenCalledWith('mattermost://server-1.com/subpath');
}); });
@@ -218,7 +169,7 @@ describe('main/windows/windowManager', () => {
getCurrentView: () => view, getCurrentView: () => view,
setLoadingScreenBounds: jest.fn(), setLoadingScreenBounds: jest.fn(),
}; };
windowManager.mainWindow = { const mainWindow = {
getContentBounds: () => ({width: 800, height: 600}), getContentBounds: () => ({width: 800, height: 600}),
getSize: () => [1000, 900], getSize: () => [1000, 900],
}; };
@@ -227,6 +178,7 @@ describe('main/windows/windowManager', () => {
}; };
beforeEach(() => { beforeEach(() => {
MainWindow.get.mockReturnValue(mainWindow);
jest.useFakeTimers(); jest.useFakeTimers();
getAdjustedWindowBoundaries.mockImplementation((width, height) => ({width, height})); getAdjustedWindowBoundaries.mockImplementation((width, height) => ({width, height}));
}); });
@@ -281,15 +233,16 @@ describe('main/windows/windowManager', () => {
setLoadingScreenBounds: jest.fn(), setLoadingScreenBounds: jest.fn(),
loadingScreenState: 3, loadingScreenState: 3,
}; };
windowManager.mainWindow = {
getContentBounds: () => ({width: 1000, height: 900}),
getSize: () => [1000, 900],
};
windowManager.teamDropdown = { windowManager.teamDropdown = {
updateWindowBounds: jest.fn(), updateWindowBounds: jest.fn(),
}; };
const mainWindow = {
getContentBounds: () => ({width: 1000, height: 900}),
getSize: () => [1000, 900],
};
beforeEach(() => { beforeEach(() => {
MainWindow.get.mockReturnValue(mainWindow);
getAdjustedWindowBoundaries.mockImplementation((width, height) => ({width, height})); getAdjustedWindowBoundaries.mockImplementation((width, height) => ({width, height}));
}); });
@@ -333,12 +286,13 @@ describe('main/windows/windowManager', () => {
}, },
}, },
}; };
windowManager.mainWindow = { const mainWindow = {
getContentBounds: () => ({width: 800, height: 600}), getContentBounds: () => ({width: 800, height: 600}),
getSize: () => [1000, 900], getSize: () => [1000, 900],
}; };
beforeEach(() => { beforeEach(() => {
MainWindow.get.mockReturnValue(mainWindow);
getAdjustedWindowBoundaries.mockImplementation((width, height) => ({width, height})); getAdjustedWindowBoundaries.mockImplementation((width, height) => ({width, height}));
}); });
@@ -392,7 +346,7 @@ describe('main/windows/windowManager', () => {
describe('restoreMain', () => { describe('restoreMain', () => {
const windowManager = new WindowManager(); const windowManager = new WindowManager();
windowManager.mainWindow = { const mainWindow = {
isVisible: jest.fn(), isVisible: jest.fn(),
isMinimized: jest.fn(), isMinimized: jest.fn(),
restore: jest.fn(), restore: jest.fn(),
@@ -400,134 +354,62 @@ describe('main/windows/windowManager', () => {
focus: jest.fn(), focus: jest.fn(),
}; };
beforeEach(() => {
MainWindow.get.mockReturnValue(mainWindow);
});
afterEach(() => { afterEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();
delete windowManager.settingsWindow; delete windowManager.settingsWindow;
}); });
it('should restore main window if minimized', () => { it('should restore main window if minimized', () => {
windowManager.mainWindow.isMinimized.mockReturnValue(true); mainWindow.isMinimized.mockReturnValue(true);
windowManager.restoreMain(); windowManager.restoreMain();
expect(windowManager.mainWindow.restore).toHaveBeenCalled(); expect(mainWindow.restore).toHaveBeenCalled();
}); });
it('should show main window if not visible or minimized', () => { it('should show main window if not visible or minimized', () => {
windowManager.mainWindow.isVisible.mockReturnValue(false); mainWindow.isVisible.mockReturnValue(false);
windowManager.mainWindow.isMinimized.mockReturnValue(false); mainWindow.isMinimized.mockReturnValue(false);
windowManager.restoreMain(); windowManager.restoreMain();
expect(windowManager.mainWindow.show).toHaveBeenCalled(); expect(mainWindow.show).toHaveBeenCalled();
}); });
it('should focus main window if visible and not minimized', () => { it('should focus main window if visible and not minimized', () => {
windowManager.mainWindow.isVisible.mockReturnValue(true); mainWindow.isVisible.mockReturnValue(true);
windowManager.mainWindow.isMinimized.mockReturnValue(false); mainWindow.isMinimized.mockReturnValue(false);
windowManager.restoreMain(); windowManager.restoreMain();
expect(windowManager.mainWindow.focus).toHaveBeenCalled(); expect(mainWindow.focus).toHaveBeenCalled();
}); });
it('should focus settings window regardless of main window state if it exists', () => { it('should focus settings window regardless of main window state if it exists', () => {
windowManager.settingsWindow = { const settingsWindow = {focus: jest.fn()};
focus: jest.fn(), SettingsWindow.get.mockReturnValue(settingsWindow);
};
windowManager.mainWindow.isVisible.mockReturnValue(false); mainWindow.isVisible.mockReturnValue(false);
windowManager.mainWindow.isMinimized.mockReturnValue(false); mainWindow.isMinimized.mockReturnValue(false);
windowManager.restoreMain(); windowManager.restoreMain();
expect(windowManager.settingsWindow.focus).toHaveBeenCalled(); expect(settingsWindow.focus).toHaveBeenCalled();
windowManager.settingsWindow.focus.mockClear(); settingsWindow.focus.mockClear();
windowManager.mainWindow.isVisible.mockReturnValue(true); mainWindow.isVisible.mockReturnValue(true);
windowManager.mainWindow.isMinimized.mockReturnValue(false); mainWindow.isMinimized.mockReturnValue(false);
windowManager.restoreMain(); windowManager.restoreMain();
expect(windowManager.settingsWindow.focus).toHaveBeenCalled(); expect(settingsWindow.focus).toHaveBeenCalled();
windowManager.settingsWindow.focus.mockClear(); settingsWindow.focus.mockClear();
windowManager.mainWindow.isVisible.mockReturnValue(false); mainWindow.isVisible.mockReturnValue(false);
windowManager.mainWindow.isMinimized.mockReturnValue(true); mainWindow.isMinimized.mockReturnValue(true);
windowManager.restoreMain(); windowManager.restoreMain();
expect(windowManager.settingsWindow.focus).toHaveBeenCalled(); expect(settingsWindow.focus).toHaveBeenCalled();
windowManager.settingsWindow.focus.mockClear(); settingsWindow.focus.mockClear();
windowManager.mainWindow.isVisible.mockReturnValue(true); mainWindow.isVisible.mockReturnValue(true);
windowManager.mainWindow.isMinimized.mockReturnValue(true); mainWindow.isMinimized.mockReturnValue(true);
windowManager.restoreMain(); windowManager.restoreMain();
expect(windowManager.settingsWindow.focus).toHaveBeenCalled(); expect(settingsWindow.focus).toHaveBeenCalled();
windowManager.settingsWindow.focus.mockClear(); settingsWindow.focus.mockClear();
});
});
describe('flashFrame', () => {
const windowManager = new WindowManager();
windowManager.mainWindow = {
flashFrame: jest.fn(),
};
windowManager.settingsWindow = {
flashFrame: jest.fn(),
};
beforeEach(() => {
Config.notifications = {};
});
afterEach(() => {
jest.resetAllMocks();
Config.notifications = {};
});
it('linux/windows - should not flash frame when config item is not set', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'linux',
});
windowManager.flashFrame(true);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
expect(windowManager.mainWindow.flashFrame).not.toBeCalled();
expect(windowManager.settingsWindow.flashFrame).not.toBeCalled();
});
it('linux/windows - should flash frame when config item is set', () => {
Config.notifications = {
flashWindow: true,
};
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'linux',
});
windowManager.flashFrame(true);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
expect(windowManager.mainWindow.flashFrame).toBeCalledWith(true);
});
it('mac - should not bounce icon when config item is not set', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'darwin',
});
windowManager.flashFrame(true);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
expect(app.dock.bounce).not.toBeCalled();
});
it('mac - should bounce icon when config item is set', () => {
Config.notifications = {
bounceIcon: true,
bounceIconType: 'critical',
};
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'darwin',
});
windowManager.flashFrame(true);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
expect(app.dock.bounce).toHaveBeenCalledWith('critical');
}); });
}); });
@@ -569,13 +451,13 @@ describe('main/windows/windowManager', () => {
}); });
it('should maximize when not maximized and vice versa', () => { it('should maximize when not maximized and vice versa', () => {
windowManager.mainWindow = mainWindow; MainWindow.get.mockReturnValue(mainWindow);
windowManager.mainWindow.isMaximized.mockReturnValue(false); mainWindow.isMaximized.mockReturnValue(false);
windowManager.handleDoubleClick(); windowManager.handleDoubleClick();
expect(mainWindow.maximize).toHaveBeenCalled(); expect(mainWindow.maximize).toHaveBeenCalled();
windowManager.mainWindow.isMaximized.mockReturnValue(true); mainWindow.isMaximized.mockReturnValue(true);
windowManager.handleDoubleClick(); windowManager.handleDoubleClick();
expect(mainWindow.unmaximize).toHaveBeenCalled(); expect(mainWindow.unmaximize).toHaveBeenCalled();
}); });
@@ -585,16 +467,15 @@ describe('main/windows/windowManager', () => {
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
value: 'darwin', value: 'darwin',
}); });
windowManager.flashFrame(true);
systemPreferences.getUserDefault.mockReturnValue('Minimize'); systemPreferences.getUserDefault.mockReturnValue('Minimize');
windowManager.settingsWindow = settingsWindow; SettingsWindow.get.mockReturnValue(settingsWindow);
windowManager.settingsWindow.isMinimized.mockReturnValue(false); settingsWindow.isMinimized.mockReturnValue(false);
windowManager.handleDoubleClick(null, 'settings'); windowManager.handleDoubleClick(null, 'settings');
expect(settingsWindow.minimize).toHaveBeenCalled(); expect(settingsWindow.minimize).toHaveBeenCalled();
windowManager.settingsWindow.isMinimized.mockReturnValue(true); settingsWindow.isMinimized.mockReturnValue(true);
windowManager.handleDoubleClick(null, 'settings'); windowManager.handleDoubleClick(null, 'settings');
expect(settingsWindow.restore).toHaveBeenCalled(); expect(settingsWindow.restore).toHaveBeenCalled();
@@ -1418,10 +1299,10 @@ describe('main/windows/windowManager', () => {
describe('handleCallsError', () => { describe('handleCallsError', () => {
const windowManager = new WindowManager(); const windowManager = new WindowManager();
windowManager.switchServer = jest.fn(); const mainWindow = {
windowManager.mainWindow = {
focus: jest.fn(), focus: jest.fn(),
}; };
windowManager.switchServer = jest.fn();
beforeEach(() => { beforeEach(() => {
CallsWidgetWindow.mockImplementation(() => { CallsWidgetWindow.mockImplementation(() => {
@@ -1436,6 +1317,7 @@ describe('main/windows/windowManager', () => {
}), }),
}; };
}); });
MainWindow.get.mockReturnValue(mainWindow);
}); });
afterEach(() => { afterEach(() => {
@@ -1447,7 +1329,7 @@ describe('main/windows/windowManager', () => {
windowManager.callsWidgetWindow = new CallsWidgetWindow(); windowManager.callsWidgetWindow = new CallsWidgetWindow();
windowManager.handleCallsError('', {err: 'client-error'}); windowManager.handleCallsError('', {err: 'client-error'});
expect(windowManager.switchServer).toHaveBeenCalledWith('server-2'); expect(windowManager.switchServer).toHaveBeenCalledWith('server-2');
expect(windowManager.mainWindow.focus).toHaveBeenCalled(); expect(mainWindow.focus).toHaveBeenCalled();
expect(windowManager.callsWidgetWindow.getMainView().view.webContents.send).toHaveBeenCalledWith('calls-error', {err: 'client-error'}); expect(windowManager.callsWidgetWindow.getMainView().view.webContents.send).toHaveBeenCalledWith('calls-error', {err: 'client-error'});
}); });
}); });

View File

@@ -4,7 +4,7 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import path from 'path'; import path from 'path';
import {app, BrowserWindow, nativeImage, systemPreferences, ipcMain, IpcMainEvent, IpcMainInvokeEvent, desktopCapturer} from 'electron'; import {app, BrowserWindow, systemPreferences, ipcMain, IpcMainEvent, IpcMainInvokeEvent, desktopCapturer} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import { import {
@@ -55,7 +55,6 @@ import {
} from '../utils'; } from '../utils';
import {ViewManager, LoadingScreenState} from '../views/viewManager'; import {ViewManager, LoadingScreenState} from '../views/viewManager';
import CriticalErrorHandler from '../CriticalErrorHandler';
import TeamDropdownView from '../views/teamDropdownView'; import TeamDropdownView from '../views/teamDropdownView';
import DownloadsDropdownView from '../views/downloadsDropdownView'; import DownloadsDropdownView from '../views/downloadsDropdownView';
@@ -63,19 +62,16 @@ import DownloadsDropdownMenuView from '../views/downloadsDropdownMenuView';
import downloadsManager from 'main/downloadsManager'; import downloadsManager from 'main/downloadsManager';
import {createSettingsWindow} from './settingsWindow'; import MainWindow from './mainWindow';
import createMainWindow from './mainWindow';
import CallsWidgetWindow from './callsWidgetWindow'; import CallsWidgetWindow from './callsWidgetWindow';
import SettingsWindow from './settingsWindow';
// singleton module to manage application's windows // singleton module to manage application's windows
export class WindowManager { export class WindowManager {
assetsDir: string; assetsDir: string;
mainWindow?: BrowserWindow;
mainWindowReady: boolean;
settingsWindow?: BrowserWindow;
callsWidgetWindow?: CallsWidgetWindow; callsWidgetWindow?: CallsWidgetWindow;
viewManager?: ViewManager; viewManager?: ViewManager;
teamDropdown?: TeamDropdownView; teamDropdown?: TeamDropdownView;
@@ -85,7 +81,6 @@ export class WindowManager {
missingScreensharePermissions?: boolean; missingScreensharePermissions?: boolean;
constructor() { constructor() {
this.mainWindowReady = false;
this.assetsDir = path.resolve(app.getAppPath(), 'assets'); this.assetsDir = path.resolve(app.getAppPath(), 'assets');
ipcMain.on(HISTORY, this.handleHistory); ipcMain.on(HISTORY, this.handleHistory);
@@ -145,7 +140,7 @@ export class WindowManager {
return; return;
} }
this.callsWidgetWindow = new CallsWidgetWindow(this.mainWindow!, currentView, { this.callsWidgetWindow = new CallsWidgetWindow(MainWindow.get()!, currentView, {
callID: msg.callID, callID: msg.callID,
title: msg.title, title: msg.title,
rootID: msg.rootID, rootID: msg.rootID,
@@ -160,7 +155,7 @@ export class WindowManager {
if (this.callsWidgetWindow) { if (this.callsWidgetWindow) {
this.switchServer(this.callsWidgetWindow.getServerName()); this.switchServer(this.callsWidgetWindow.getServerName());
this.mainWindow?.focus(); MainWindow.get()?.focus();
this.callsWidgetWindow.getMainView().view.webContents.send(DESKTOP_SOURCES_MODAL_REQUEST); this.callsWidgetWindow.getMainView().view.webContents.send(DESKTOP_SOURCES_MODAL_REQUEST);
} }
} }
@@ -170,7 +165,7 @@ export class WindowManager {
if (this.callsWidgetWindow) { if (this.callsWidgetWindow) {
this.switchServer(this.callsWidgetWindow.getServerName()); this.switchServer(this.callsWidgetWindow.getServerName());
this.mainWindow?.focus(); MainWindow.get()?.focus();
this.callsWidgetWindow.getMainView().view.webContents.send(BROWSER_HISTORY_PUSH, this.callsWidgetWindow.getChannelURL()); this.callsWidgetWindow.getMainView().view.webContents.send(BROWSER_HISTORY_PUSH, this.callsWidgetWindow.getChannelURL());
} }
} }
@@ -180,7 +175,7 @@ export class WindowManager {
if (this.callsWidgetWindow) { if (this.callsWidgetWindow) {
this.switchServer(this.callsWidgetWindow.getServerName()); this.switchServer(this.callsWidgetWindow.getServerName());
this.mainWindow?.focus(); MainWindow.get()?.focus();
this.callsWidgetWindow.getMainView().view.webContents.send(CALLS_ERROR, msg); this.callsWidgetWindow.getMainView().view.webContents.send(CALLS_ERROR, msg);
} }
} }
@@ -190,7 +185,7 @@ export class WindowManager {
if (this.callsWidgetWindow) { if (this.callsWidgetWindow) {
this.switchServer(this.callsWidgetWindow.getServerName()); this.switchServer(this.callsWidgetWindow.getServerName());
this.mainWindow?.focus(); MainWindow.get()?.focus();
this.callsWidgetWindow.getMainView().view.webContents.send(BROWSER_HISTORY_PUSH, msg.link); this.callsWidgetWindow.getMainView().view.webContents.send(BROWSER_HISTORY_PUSH, msg.link);
} }
} }
@@ -201,100 +196,49 @@ export class WindowManager {
this.callsWidgetWindow?.close(); this.callsWidgetWindow?.close();
} }
showSettingsWindow = () => {
log.debug('WindowManager.showSettingsWindow');
if (this.settingsWindow) {
this.settingsWindow.show();
} else {
if (!this.mainWindow) {
this.showMainWindow();
}
const withDevTools = Boolean(process.env.MM_DEBUG_SETTINGS) || false;
this.settingsWindow = createSettingsWindow(this.mainWindow!, withDevTools);
this.settingsWindow.on('closed', () => {
delete this.settingsWindow;
});
}
}
showMainWindow = (deeplinkingURL?: string | URL) => { showMainWindow = (deeplinkingURL?: string | URL) => {
log.debug('WindowManager.showMainWindow', deeplinkingURL); log.debug('WindowManager.showMainWindow', deeplinkingURL);
if (this.mainWindow) { const mainWindow = MainWindow.get();
if (this.mainWindow.isVisible()) { if (mainWindow) {
this.mainWindow.focus(); if (mainWindow.isVisible()) {
mainWindow.focus();
} else { } else {
this.mainWindow.show(); mainWindow.show();
} }
} else { } else {
this.mainWindowReady = false; this.createMainWindow();
this.mainWindow = createMainWindow({ }
linuxAppIcon: path.join(this.assetsDir, 'linux', 'app_icon.png'),
});
if (!this.mainWindow) { if (deeplinkingURL) {
log.error('unable to create main window'); this.viewManager?.handleDeepLink(deeplinkingURL);
app.quit(); }
}
private createMainWindow = () => {
const mainWindow = MainWindow.get(true);
if (!mainWindow) {
return; return;
} }
this.mainWindow.once('ready-to-show', () => {
this.mainWindowReady = true;
});
// window handlers // window handlers
this.mainWindow.on('closed', () => { mainWindow.on('maximize', this.handleMaximizeMainWindow);
log.warn('main window closed'); mainWindow.on('unmaximize', this.handleUnmaximizeMainWindow);
delete this.mainWindow;
this.mainWindowReady = false;
});
this.mainWindow.on('unresponsive', () => {
CriticalErrorHandler.setMainWindow(this.mainWindow!);
CriticalErrorHandler.windowUnresponsiveHandler();
});
this.mainWindow.on('maximize', this.handleMaximizeMainWindow);
this.mainWindow.on('unmaximize', this.handleUnmaximizeMainWindow);
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
this.mainWindow.on('resize', this.handleResizeMainWindow); mainWindow.on('resize', this.handleResizeMainWindow);
} }
this.mainWindow.on('will-resize', this.handleWillResizeMainWindow); mainWindow.on('will-resize', this.handleWillResizeMainWindow);
this.mainWindow.on('resized', this.handleResizedMainWindow); mainWindow.on('resized', this.handleResizedMainWindow);
this.mainWindow.on('focus', this.focusBrowserView); mainWindow.on('focus', this.focusBrowserView);
this.mainWindow.on('enter-full-screen', () => this.sendToRenderer('enter-full-screen')); mainWindow.on('enter-full-screen', () => this.sendToRenderer('enter-full-screen'));
this.mainWindow.on('leave-full-screen', () => this.sendToRenderer('leave-full-screen')); mainWindow.on('leave-full-screen', () => this.sendToRenderer('leave-full-screen'));
// Should not allow the main window to generate a window of its own this.teamDropdown = new TeamDropdownView(Config.teams, Config.darkMode, Config.enableServerManagement);
this.mainWindow.webContents.setWindowOpenHandler(() => ({action: 'deny'})); this.downloadsDropdown = new DownloadsDropdownView(downloadsManager.getDownloads(), Config.darkMode);
this.downloadsDropdownMenu = new DownloadsDropdownMenuView(Config.darkMode);
if (process.env.MM_DEBUG_SETTINGS) {
this.mainWindow.webContents.openDevTools({mode: 'detach'});
}
if (this.viewManager) {
this.viewManager.updateMainWindow(this.mainWindow);
}
this.teamDropdown = new TeamDropdownView(this.mainWindow, Config.teams, Config.darkMode, Config.enableServerManagement);
this.downloadsDropdown = new DownloadsDropdownView(this.mainWindow, downloadsManager.getDownloads(), Config.darkMode);
this.downloadsDropdownMenu = new DownloadsDropdownMenuView(this.mainWindow, Config.darkMode);
}
this.initializeViewManager(); this.initializeViewManager();
if (deeplinkingURL) {
this.viewManager!.handleDeepLink(deeplinkingURL);
} }
}
getMainWindow = (ensureCreated?: boolean) => {
if (ensureCreated && !this.mainWindow) {
this.showMainWindow();
}
return this.mainWindow;
}
on = this.mainWindow?.on;
handleMaximizeMainWindow = () => { handleMaximizeMainWindow = () => {
this.downloadsDropdown?.updateWindowBounds(); this.downloadsDropdown?.updateWindowBounds();
@@ -313,7 +257,7 @@ export class WindowManager {
handleWillResizeMainWindow = (event: Event, newBounds: Electron.Rectangle) => { handleWillResizeMainWindow = (event: Event, newBounds: Electron.Rectangle) => {
log.silly('WindowManager.handleWillResizeMainWindow'); log.silly('WindowManager.handleWillResizeMainWindow');
if (!(this.viewManager && this.mainWindow)) { if (!(this.viewManager && MainWindow.get())) {
return; return;
} }
@@ -343,7 +287,7 @@ export class WindowManager {
handleResizedMainWindow = () => { handleResizedMainWindow = () => {
log.silly('WindowManager.handleResizedMainWindow'); log.silly('WindowManager.handleResizedMainWindow');
if (this.mainWindow) { if (MainWindow.get()) {
const bounds = this.getBounds(); const bounds = this.getBounds();
this.throttledWillResize(bounds); this.throttledWillResize(bounds);
ipcMain.emit(RESIZE_MODAL, null, bounds); ipcMain.emit(RESIZE_MODAL, null, bounds);
@@ -368,7 +312,7 @@ export class WindowManager {
handleResizeMainWindow = () => { handleResizeMainWindow = () => {
log.silly('WindowManager.handleResizeMainWindow'); log.silly('WindowManager.handleResizeMainWindow');
if (!(this.viewManager && this.mainWindow)) { if (!(this.viewManager && MainWindow.get())) {
return; return;
} }
if (this.isResizing) { if (this.isResizing) {
@@ -405,15 +349,16 @@ export class WindowManager {
private getBounds = () => { private getBounds = () => {
let bounds; let bounds;
if (this.mainWindow) { const mainWindow = MainWindow.get();
if (mainWindow) {
// Workaround for linux maximizing/minimizing, which doesn't work properly because of these bugs: // Workaround for linux maximizing/minimizing, which doesn't work properly because of these bugs:
// https://github.com/electron/electron/issues/28699 // https://github.com/electron/electron/issues/28699
// https://github.com/electron/electron/issues/28106 // https://github.com/electron/electron/issues/28106
if (process.platform === 'linux') { if (process.platform === 'linux') {
const size = this.mainWindow.getSize(); const size = mainWindow.getSize();
bounds = {width: size[0], height: size[1]}; bounds = {width: size[0], height: size[1]};
} else { } else {
bounds = this.mainWindow.getContentBounds(); bounds = mainWindow.getContentBounds();
} }
} }
@@ -422,7 +367,8 @@ export class WindowManager {
// max retries allows the message to get to the renderer even if it is sent while the app is starting up. // max retries allows the message to get to the renderer even if it is sent while the app is starting up.
sendToRendererWithRetry = (maxRetries: number, channel: string, ...args: unknown[]) => { sendToRendererWithRetry = (maxRetries: number, channel: string, ...args: unknown[]) => {
if (!this.mainWindow || !this.mainWindowReady) { const mainWindow = MainWindow.get();
if (!mainWindow || !MainWindow.isReady) {
if (maxRetries > 0) { if (maxRetries > 0) {
log.info(`Can't send ${channel}, will retry`); log.info(`Can't send ${channel}, will retry`);
setTimeout(() => { setTimeout(() => {
@@ -433,14 +379,8 @@ export class WindowManager {
} }
return; return;
} }
this.mainWindow!.webContents.send(channel, ...args); mainWindow.webContents.send(channel, ...args);
if (this.settingsWindow && this.settingsWindow.isVisible()) { SettingsWindow.get()?.webContents.send(channel, ...args);
try {
this.settingsWindow.webContents.send(channel, ...args);
} catch (e) {
log.error(`There was an error while trying to communicate with the renderer: ${e}`);
}
}
} }
sendToRenderer = (channel: string, ...args: unknown[]) => { sendToRenderer = (channel: string, ...args: unknown[]) => {
@@ -449,9 +389,7 @@ export class WindowManager {
sendToAll = (channel: string, ...args: unknown[]) => { sendToAll = (channel: string, ...args: unknown[]) => {
this.sendToRenderer(channel, ...args); this.sendToRenderer(channel, ...args);
if (this.settingsWindow) { SettingsWindow.get()?.webContents.send(channel, ...args);
this.settingsWindow.webContents.send(channel, ...args);
}
// TODO: should we include popups? // TODO: should we include popups?
} }
@@ -464,103 +402,31 @@ export class WindowManager {
restoreMain = () => { restoreMain = () => {
log.info('restoreMain'); log.info('restoreMain');
if (!this.mainWindow) {
this.showMainWindow(); const mainWindow = MainWindow.get(true);
if (!mainWindow) {
throw new Error('Main window does not exist');
} }
if (!this.mainWindow!.isVisible() || this.mainWindow!.isMinimized()) {
if (this.mainWindow!.isMinimized()) { if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
this.mainWindow!.restore(); if (mainWindow.isMinimized()) {
mainWindow.restore();
} else { } else {
this.mainWindow!.show(); mainWindow.show();
} }
if (this.settingsWindow) { const settingsWindow = SettingsWindow.get();
this.settingsWindow.focus(); if (settingsWindow) {
settingsWindow.focus();
} else { } else {
this.mainWindow!.focus(); mainWindow.focus();
} }
} else if (this.settingsWindow) { } else if (SettingsWindow.get()) {
this.settingsWindow.focus(); SettingsWindow.get()?.focus();
} else { } else {
this.mainWindow!.focus(); mainWindow.focus();
} }
} }
flashFrame = (flash: boolean) => {
if (process.platform === 'linux' || process.platform === 'win32') {
if (Config.notifications.flashWindow) {
this.mainWindow?.flashFrame(flash);
}
}
if (process.platform === 'darwin' && Config.notifications.bounceIcon) {
app.dock.bounce(Config.notifications.bounceIconType);
}
}
drawBadge = (text: string, small: boolean) => {
const scale = 2; // should rely display dpi
const size = (small ? 20 : 16) * scale;
const canvas = document.createElement('canvas');
canvas.setAttribute('width', `${size}`);
canvas.setAttribute('height', `${size}`);
const ctx = canvas.getContext('2d');
if (!ctx) {
log.error('Could not create canvas context');
return null;
}
// circle
ctx.fillStyle = '#FF1744'; // Material Red A400
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.fill();
// text
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = (11 * scale) + 'px sans-serif';
ctx.fillText(text, size / 2, size / 2, size);
return canvas.toDataURL();
}
createDataURL = (text: string, small: boolean) => {
const win = this.mainWindow;
if (!win) {
return null;
}
// since we don't have a document/canvas object in the main process, we use the webcontents from the window to draw.
const safeSmall = Boolean(small);
const code = `
window.drawBadge = ${this.drawBadge};
window.drawBadge('${text || ''}', ${safeSmall});
`;
return win.webContents.executeJavaScript(code);
}
setOverlayIcon = async (badgeText: string | undefined, description: string, small: boolean) => {
if (process.platform === 'win32') {
let overlay = null;
if (this.mainWindow) {
if (badgeText) {
try {
const dataUrl = await this.createDataURL(badgeText, small);
overlay = nativeImage.createFromDataURL(dataUrl);
} catch (err) {
log.error(`Couldn't generate a badge: ${err}`);
}
}
this.mainWindow.setOverlayIcon(overlay, description);
}
}
}
isMainWindow = (window: BrowserWindow) => {
return this.mainWindow && this.mainWindow === window;
}
handleDoubleClick = (e: IpcMainEvent, windowType?: string) => { handleDoubleClick = (e: IpcMainEvent, windowType?: string) => {
log.debug('WindowManager.handleDoubleClick', windowType); log.debug('WindowManager.handleDoubleClick', windowType);
@@ -568,7 +434,7 @@ export class WindowManager {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
action = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string'); action = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string');
} }
const win = (windowType === 'settings') ? this.settingsWindow : this.mainWindow; const win = (windowType === 'settings') ? SettingsWindow.get() : MainWindow.get();
if (!win) { if (!win) {
return; return;
} }
@@ -592,8 +458,8 @@ export class WindowManager {
} }
initializeViewManager = () => { initializeViewManager = () => {
if (!this.viewManager && Config && this.mainWindow) { if (!this.viewManager && Config) {
this.viewManager = new ViewManager(this.mainWindow); this.viewManager = new ViewManager();
this.viewManager.load(); this.viewManager.load();
this.viewManager.showInitial(); this.viewManager.showInitial();
this.initializeCurrentServerName(); this.initializeCurrentServerName();
@@ -658,10 +524,8 @@ export class WindowManager {
} }
focusThreeDotMenu = () => { focusThreeDotMenu = () => {
if (this.mainWindow) { MainWindow.get()?.webContents.focus();
this.mainWindow.webContents.focus(); MainWindow.get()?.webContents.send(FOCUS_THREE_DOT_MENU);
this.mainWindow.webContents.send(FOCUS_THREE_DOT_MENU);
}
} }
handleLoadingScreenDataRequest = () => { handleLoadingScreenDataRequest = () => {