[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', () => {
const criticalErrorHandler = new CriticalErrorHandler();
beforeEach(() => {
criticalErrorHandler.setMainWindow({});
});
describe('windowUnresponsiveHandler', () => {
it('should do nothing when mainWindow is null', () => {
criticalErrorHandler.setMainWindow(null);
criticalErrorHandler.windowUnresponsiveHandler();
expect(dialog.showMessageBox).not.toBeCalled();
});
it('should call app.relaunch when user elects not to wait', async () => {
const promise = Promise.resolve({response: 0});
dialog.showMessageBox.mockImplementation(() => promise);
criticalErrorHandler.windowUnresponsiveHandler();
await promise;
expect(app.relaunch).toBeCalled();
});
});
describe('processUncaughtExceptionHandler', () => {
beforeEach(() => {
app.isReady.mockImplementation(() => true);
criticalErrorHandler.setMainWindow({isVisible: true});
});
it('should throw error if app is not ready', () => {
@@ -76,16 +56,6 @@ describe('main/CriticalErrorHandler', () => {
expect(dialog.showMessageBox).not.toBeCalled();
});
it('should do nothing if main window is null or not visible', () => {
criticalErrorHandler.setMainWindow(null);
criticalErrorHandler.processUncaughtExceptionHandler(new Error('test'));
expect(dialog.showMessageBox).not.toBeCalled();
criticalErrorHandler.setMainWindow({isVisible: false});
criticalErrorHandler.processUncaughtExceptionHandler(new Error('test'));
expect(dialog.showMessageBox).not.toBeCalled();
});
it('should open external file on Show Details', async () => {
path.join.mockImplementation(() => 'testfile.txt');
const promise = Promise.resolve({response: process.platform === 'darwin' ? 2 : 0});

View File

@@ -7,72 +7,36 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import {app, BrowserWindow, dialog} from 'electron';
import {app, dialog} from 'electron';
import log from 'electron-log';
import {localizeMessage} from 'main/i18nManager';
function createErrorReport(err: Error) {
// eslint-disable-next-line no-undef
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return `Application: ${app.name} ${app.getVersion()}${__HASH_VERSION__ ? ` [commit: ${__HASH_VERSION__}]` : ''}\n` +
`Platform: ${os.type()} ${os.release()} ${os.arch()}\n` +
`${err.stack}`;
}
function openDetachedExternal(url: string) {
const spawnOption = {detached: true, stdio: 'ignore' as const};
switch (process.platform) {
case 'win32':
return spawn('cmd', ['/C', 'start', url], spawnOption);
case 'darwin':
return spawn('open', [url], spawnOption);
case 'linux':
return spawn('xdg-open', [url], spawnOption);
default:
return undefined;
}
}
export class CriticalErrorHandler {
mainWindow?: BrowserWindow;
setMainWindow(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
init = () => {
process.on('unhandledRejection', this.processUncaughtExceptionHandler);
process.on('uncaughtException', this.processUncaughtExceptionHandler);
}
windowUnresponsiveHandler() {
if (!this.mainWindow) {
return;
}
dialog.showMessageBox(this.mainWindow, {
type: 'warning',
title: app.name,
message: localizeMessage('main.CriticalErrorHandler.unresponsive.dialog.message', 'The window is no longer responsive.\nDo you wait until the window becomes responsive again?'),
buttons: [
localizeMessage('label.no', 'No'),
localizeMessage('label.yes', 'Yes'),
],
defaultId: 0,
}).then(({response}) => {
if (response === 0) {
log.error('BrowserWindow \'unresponsive\' event has been emitted');
app.relaunch();
}
});
}
processUncaughtExceptionHandler(err: Error) {
const file = path.join(app.getPath('userData'), `uncaughtException-${Date.now()}.txt`);
const report = createErrorReport(err);
fs.writeFileSync(file, report.replace(new RegExp('\\n', 'g'), os.EOL));
private processUncaughtExceptionHandler = (err: Error) => {
if (process.env.NODE_ENV === 'test') {
return;
}
if (app.isReady()) {
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 = [
localizeMessage('main.CriticalErrorHandler.uncaughtException.button.showDetails', 'Show Details'),
localizeMessage('label.ok', 'OK'),
@@ -85,11 +49,7 @@ export class CriticalErrorHandler {
indexOfReopen = 0;
indexOfShowDetails = 2;
}
if (!this.mainWindow?.isVisible) {
return;
}
dialog.showMessageBox(
this.mainWindow,
{
type: 'error',
title: app.name,
@@ -111,7 +71,7 @@ export class CriticalErrorHandler {
let child;
switch (response) {
case indexOfShowDetails:
child = openDetachedExternal(file);
child = this.openDetachedExternal(file);
if (child) {
child.on(
'error',
@@ -128,10 +88,29 @@ export class CriticalErrorHandler {
}
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 WindowManager from './windows/windowManager';
import MainWindow from './windows/mainWindow';
import {AllowProtocolDialog} from './allowProtocolDialog';
@@ -42,8 +42,8 @@ jest.mock('common/Validator', () => ({
validateAllowedProtocols: (protocols) => protocols,
}));
jest.mock('./windows/windowManager', () => ({
getMainWindow: jest.fn(),
jest.mock('./windows/mainWindow', () => ({
get: jest.fn(),
}));
jest.mock('main/i18nManager', () => ({
@@ -117,7 +117,7 @@ describe('main/allowProtocolDialog', () => {
});
it('should not open message box if main window is missing', () => {
WindowManager.getMainWindow.mockImplementation(() => null);
MainWindow.get.mockImplementation(() => null);
allowProtocolDialog.handleDialogEvent('mattermost:', 'mattermost://community.mattermost.com');
expect(shell.openExternal).not.toBeCalled();
expect(dialog.showMessageBox).not.toBeCalled();
@@ -125,7 +125,7 @@ describe('main/allowProtocolDialog', () => {
describe('main window not null', () => {
beforeEach(() => {
WindowManager.getMainWindow.mockImplementation(() => ({}));
MainWindow.get.mockImplementation(() => ({}));
});
it('should open the window but not save when clicking Yes', async () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

@@ -3,14 +3,16 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import {app, BrowserWindow, BrowserWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen} from 'electron';
import {app, BrowserWindow, BrowserWindowConstructorOptions, dialog, Event, globalShortcut, Input, ipcMain, screen} from 'electron';
import log from 'electron-log';
import {SavedWindowState} from 'types/mainWindow';
import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB, GET_FULL_SCREEN_STATUS} from 'common/communication';
import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB, GET_FULL_SCREEN_STATUS, FOCUS_THREE_DOT_MENU} from 'common/communication';
import Config from 'common/config';
import {DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, MINIMUM_WINDOW_HEIGHT, MINIMUM_WINDOW_WIDTH} from 'common/utils/constants';
import Utils from 'common/utils/util';
@@ -20,33 +22,136 @@ import {boundsInfoPath} from 'main/constants';
import {localizeMessage} from 'main/i18nManager';
import ContextMenu from '../contextMenu';
import {getLocalPreload, getLocalURLString} from '../utils';
import {getLocalPreload, getLocalURLString, isInsideRectangle} from '../utils';
function saveWindowState(file: string, window: BrowserWindow) {
const windowState: SavedWindowState = {
...window.getBounds(),
maximized: window.isMaximized(),
fullscreen: window.isFullScreen(),
};
try {
fs.writeFileSync(file, JSON.stringify(windowState));
} catch (e) {
// [Linux] error happens only when the window state is changed before the config dir is created.
log.error(e);
const ALT_MENU_KEYS = ['Alt+F', 'Alt+E', 'Alt+V', 'Alt+H', 'Alt+W', 'Alt+P'];
export class MainWindow {
private win?: BrowserWindow;
private savedWindowState: SavedWindowState;
private ready: boolean;
constructor() {
// Create the browser window.
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();
}
}
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;
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'});
}
function isFramelessWindow() {
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'));
}
function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean}) {
// Create the browser window.
const preload = getLocalPreload('desktopAPI.js');
private getSavedWindowState = () => {
let savedWindowState: any;
try {
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.
savedWindowState = {width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT};
}
const {maximized: windowIsMaximized} = savedWindowState;
const spellcheck = (typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker);
const isFullScreen = () => {
if (global?.args?.fullscreen !== undefined) {
return global.args.fullscreen;
return savedWindowState;
}
if (Config.startInFullscreen) {
return Config.startInFullscreen;
}
return options.fullscreen || savedWindowState.fullscreen || false;
private saveWindowState = (file: string, window: BrowserWindow) => {
const windowState: SavedWindowState = {
...window.getBounds(),
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 {
ipcMain.handle(GET_FULL_SCREEN_STATUS, () => mainWindow.isFullScreen());
fs.writeFileSync(file, JSON.stringify(windowState));
} catch (e) {
log.error('Tried to register second handler, skipping');
}
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();
// [Linux] error happens only when the window state is changed before the config dir is created.
log.error('failed to save window state', e);
}
}
});
mainWindow.once('restore', () => {
mainWindow.restore();
private onBeforeInputEvent = (event: Event, input: Input) => {
// Register keyboard shortcuts
// Add Alt+Cmd+(Right|Left) as alternative to switch between servers
if (this.win && process.platform === 'darwin') {
if (input.alt && input.meta) {
if (input.key === 'ArrowRight') {
this.win.webContents.send(SELECT_NEXT_TAB);
}
if (input.key === 'ArrowLeft') {
this.win.webContents.send(SELECT_PREVIOUS_TAB);
}
}
}
}
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.
// However, 'close' is not fired in some situations(shutdown, ctrl+c)
// because main process is killed in such situations.
// 'blur' event was effective in order to avoid this.
// Ideally, app should detect that OS is shutting down.
mainWindow.on('blur', () => {
saveWindowState(boundsInfoPath, mainWindow);
});
this.saveWindowState(boundsInfoPath, this.win);
}
mainWindow.on('close', (event) => {
private onClose = (event: Event) => {
log.debug('MainWindow.on.close');
if (!this.win) {
return;
}
if (global.willAppQuit) { // when [Ctrl|Cmd]+Q
saveWindowState(boundsInfoPath, mainWindow);
this.saveWindowState(boundsInfoPath, this.win);
} else { // Minimize or hide the window for close button.
event.preventDefault();
function hideWindow(window: BrowserWindow) {
@@ -154,9 +243,9 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean})
case 'linux':
if (Config.minimizeToTray) {
if (Config.alwaysMinimize) {
hideWindow(mainWindow);
hideWindow(this.win);
} else {
dialog.showMessageBox(mainWindow, {
dialog.showMessageBox(this.win, {
title: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.title', 'Minimize to Tray'),
message: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.message', '{appName} will continue to run in the system tray. This can be disabled in Settings.', {appName: app.name}),
type: 'info',
@@ -164,13 +253,13 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean})
checkboxLabel: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.checkboxLabel', 'Don\'t show again'),
}).then((result: {response: number; checkboxChecked: boolean}) => {
Config.set('alwaysMinimize', result.checkboxChecked);
hideWindow(mainWindow);
hideWindow(this.win!);
});
}
} else if (Config.alwaysClose) {
app.quit();
} else {
dialog.showMessageBox(mainWindow, {
dialog.showMessageBox(this.win, {
title: localizeMessage('main.windows.mainWindow.closeApp.dialog.title', 'Close Application'),
message: localizeMessage('main.windows.mainWindow.closeApp.dialog.message', 'Are you sure you want to quit?'),
detail: localizeMessage('main.windows.mainWindow.closeApp.dialog.detail', 'You will no longer receive notifications for messages. If you want to leave {appName} running in the system tray, you can enable this in Settings.', {appName: app.name}),
@@ -191,11 +280,11 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean})
break;
case 'darwin':
// need to leave fullscreen first, then hide the window
if (mainWindow.isFullScreen()) {
mainWindow.once('leave-full-screen', () => {
if (this.win.isFullScreen()) {
this.win.once('leave-full-screen', () => {
app.hide();
});
mainWindow.setFullScreen(false);
this.win.setFullScreen(false);
} else {
app.hide();
}
@@ -203,39 +292,35 @@ function createMainWindow(options: {linuxAppIcon: string; fullscreen?: boolean})
default:
}
}
});
// Register keyboard shortcuts
mainWindow.webContents.on('before-input-event', (event, input) => {
// Add Alt+Cmd+(Right|Left) as alternative to switch between servers
if (process.platform === 'darwin') {
if (input.alt && input.meta) {
if (input.key === 'ArrowRight') {
mainWindow.webContents.send(SELECT_NEXT_TAB);
}
if (input.key === 'ArrowLeft') {
mainWindow.webContents.send(SELECT_PREVIOUS_TAB);
}
}
}
});
// Only add shortcuts when window is in focus
mainWindow.on('focus', () => {
if (process.platform === 'linux') {
globalShortcut.registerAll(['Alt+F', 'Alt+E', 'Alt+V', 'Alt+H', 'Alt+W', 'Alt+P'], () => {
// do nothing because we want to supress the menu popping up
});
}
});
mainWindow.on('blur', () => {
globalShortcut.unregisterAll();
});
const contextMenu = new ContextMenu({}, mainWindow);
contextMenu.reload();
return mainWindow;
}
export default createMainWindow;
private onClosed = () => {
log.verbose('main window closed');
delete this.win;
this.ready = false;
}
private onUnresponsive = () => {
if (!this.win) {
throw new Error('BrowserWindow \'unresponsive\' event has been emitted');
}
dialog.showMessageBox(this.win, {
type: 'warning',
title: app.name,
message: localizeMessage('main.CriticalErrorHandler.unresponsive.dialog.message', 'The window is no longer responsive.\nDo you wait until the window becomes responsive again?'),
buttons: [
localizeMessage('label.no', 'No'),
localizeMessage('label.yes', 'Yes'),
],
defaultId: 0,
}).then(({response}) => {
if (response === 0) {
log.error('BrowserWindow \'unresponsive\' event has been emitted');
app.relaunch();
}
});
}
}
const mainWindow = new MainWindow();
export default mainWindow;

View File

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

View File

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

View File

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