From 4a98dce51e7ff7413905876db826cafe933c3170 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Mon, 6 Dec 2021 11:08:39 -0500 Subject: [PATCH] [MM-40330] Unit tests for main/windows (#1885) * [MM-40330] Unit tests for main/windows * Merge'd --- src/main/allowProtocolDialog.test.js | 6 +- src/main/allowProtocolDialog.ts | 4 +- src/main/appState.ts | 2 +- src/main/authManager.ts | 10 +- src/main/badge.test.js | 12 +- src/main/badge.ts | 2 +- src/main/certificateManager.ts | 2 +- src/main/main.ts | 2 +- src/main/menus/app.test.js | 2 +- src/main/menus/app.ts | 2 +- src/main/menus/tray.ts | 2 +- src/main/notifications/index.test.js | 2 +- src/main/notifications/index.ts | 12 +- src/main/tray/tray.ts | 2 +- src/main/views/MattermostView.ts | 2 +- src/main/views/modalManager.ts | 2 +- src/main/views/teamDropdownView.ts | 2 +- src/main/views/webContentEvents.ts | 2 +- src/main/windows/mainWindow.test.js | 341 ++++++++ src/main/windows/windowManager.test.js | 815 +++++++++++++++++++ src/main/windows/windowManager.ts | 1007 ++++++++++++------------ 21 files changed, 1698 insertions(+), 535 deletions(-) create mode 100644 src/main/windows/mainWindow.test.js create mode 100644 src/main/windows/windowManager.test.js diff --git a/src/main/allowProtocolDialog.test.js b/src/main/allowProtocolDialog.test.js index 24eaa1d9..698ee12c 100644 --- a/src/main/allowProtocolDialog.test.js +++ b/src/main/allowProtocolDialog.test.js @@ -6,7 +6,7 @@ import fs from 'fs'; import {shell, dialog} from 'electron'; -import {getMainWindow} from './windows/windowManager'; +import WindowManager from './windows/windowManager'; import {AllowProtocolDialog} from './allowProtocolDialog'; @@ -120,7 +120,7 @@ describe('main/allowProtocolDialog', () => { }); it('should not open message box if main window is missing', () => { - getMainWindow.mockImplementation(() => null); + WindowManager.getMainWindow.mockImplementation(() => null); allowProtocolDialog.handleDialogEvent('mattermost:', 'mattermost://community.mattermost.com'); expect(shell.openExternal).not.toBeCalled(); expect(dialog.showMessageBox).not.toBeCalled(); @@ -128,7 +128,7 @@ describe('main/allowProtocolDialog', () => { describe('main window not null', () => { beforeEach(() => { - getMainWindow.mockImplementation(() => ({})); + WindowManager.getMainWindow.mockImplementation(() => ({})); }); it('should open the window but not save when clicking Yes', async () => { diff --git a/src/main/allowProtocolDialog.ts b/src/main/allowProtocolDialog.ts index abf36485..68f6839b 100644 --- a/src/main/allowProtocolDialog.ts +++ b/src/main/allowProtocolDialog.ts @@ -13,7 +13,7 @@ import log from 'electron-log'; import {protocols} from '../../electron-builder.json'; import * as Validator from './Validator'; -import {getMainWindow} from './windows/windowManager'; +import WindowManager from './windows/windowManager'; const allowedProtocolFile = path.resolve(app.getPath('userData'), 'allowedProtocols.json'); @@ -52,7 +52,7 @@ export class AllowProtocolDialog { shell.openExternal(URL); return; } - const mainWindow = getMainWindow(); + const mainWindow = WindowManager.getMainWindow(); if (!mainWindow) { return; } diff --git a/src/main/appState.ts b/src/main/appState.ts index 8432f216..778ef922 100644 --- a/src/main/appState.ts +++ b/src/main/appState.ts @@ -6,7 +6,7 @@ import {ipcMain} from 'electron'; import {UPDATE_MENTIONS, UPDATE_TRAY, UPDATE_BADGE, SESSION_EXPIRED, UPDATE_DROPDOWN_MENTIONS} from 'common/communication'; -import * as WindowManager from './windows/windowManager'; +import WindowManager from './windows/windowManager'; const status = { unreads: new Map(), diff --git a/src/main/authManager.ts b/src/main/authManager.ts index 8068d9d5..f9a117cd 100644 --- a/src/main/authManager.ts +++ b/src/main/authManager.ts @@ -10,12 +10,10 @@ import {LoginModalData} from 'types/auth'; import {BASIC_AUTH_PERMISSION} from 'common/permissions'; import urlUtils from 'common/utils/url'; -import * as WindowManager from 'main/windows/windowManager'; - -import modalManager from './views/modalManager'; -import {getLocalURLString, getLocalPreload} from './utils'; - -import TrustedOriginsStore from './trustedOrigins'; +import modalManager from 'main/views/modalManager'; +import TrustedOriginsStore from 'main/trustedOrigins'; +import {getLocalURLString, getLocalPreload} from 'main/utils'; +import WindowManager from 'main/windows/windowManager'; const modalPreload = getLocalPreload('modalPreload.js'); const loginModalHtml = getLocalURLString('loginModal.html'); diff --git a/src/main/badge.test.js b/src/main/badge.test.js index 7a2c71bb..ec19ceaf 100644 --- a/src/main/badge.test.js +++ b/src/main/badge.test.js @@ -6,7 +6,7 @@ import {app} from 'electron'; import * as Badge from './badge'; -import {setOverlayIcon} from './windows/windowManager'; +import WindowManager from './windows/windowManager'; jest.mock('electron', () => ({ app: { @@ -28,28 +28,28 @@ describe('main/badge', () => { describe('showBadgeWindows', () => { it('should show dot when session expired', () => { Badge.showBadgeWindows(true, 7, false); - expect(setOverlayIcon).toBeCalledWith('•', expect.any(String), expect.any(Boolean)); + expect(WindowManager.setOverlayIcon).toBeCalledWith('•', expect.any(String), expect.any(Boolean)); }); it('should show mention count when has mention count', () => { Badge.showBadgeWindows(false, 50, false); - expect(setOverlayIcon).toBeCalledWith('50', expect.any(String), false); + expect(WindowManager.setOverlayIcon).toBeCalledWith('50', expect.any(String), false); }); it('should show 99+ when has mention count over 99', () => { Badge.showBadgeWindows(false, 200, false); - expect(setOverlayIcon).toBeCalledWith('99+', expect.any(String), true); + expect(WindowManager.setOverlayIcon).toBeCalledWith('99+', expect.any(String), true); }); it('should not show dot when has unreads but setting is off', () => { Badge.showBadgeWindows(false, 0, true); - expect(setOverlayIcon).not.toBeCalledWith('•', expect.any(String), expect.any(Boolean)); + expect(WindowManager.setOverlayIcon).not.toBeCalledWith('•', expect.any(String), expect.any(Boolean)); }); it('should show dot when has unreads', () => { Badge.setUnreadBadgeSetting(true); Badge.showBadgeWindows(false, 0, true); - expect(setOverlayIcon).toBeCalledWith('•', expect.any(String), expect.any(Boolean)); + expect(WindowManager.setOverlayIcon).toBeCalledWith('•', expect.any(String), expect.any(Boolean)); Badge.setUnreadBadgeSetting(false); }); }); diff --git a/src/main/badge.ts b/src/main/badge.ts index 9f12a71a..35d73be2 100644 --- a/src/main/badge.ts +++ b/src/main/badge.ts @@ -6,7 +6,7 @@ import {app} from 'electron'; import {UPDATE_BADGE} from 'common/communication'; -import * as WindowManager from './windows/windowManager'; +import WindowManager from './windows/windowManager'; import * as AppState from './appState'; const MAX_WIN_COUNT = 99; diff --git a/src/main/certificateManager.ts b/src/main/certificateManager.ts index 32a980cd..3e47c6ac 100644 --- a/src/main/certificateManager.ts +++ b/src/main/certificateManager.ts @@ -5,7 +5,7 @@ import {Certificate, WebContents} from 'electron'; import {CertificateModalData} from 'types/certificate'; -import * as WindowManager from './windows/windowManager'; +import WindowManager from './windows/windowManager'; import modalManager from './views/modalManager'; import {getLocalURLString, getLocalPreload} from './utils'; diff --git a/src/main/main.ts b/src/main/main.ts index fb354bed..c0f74a6e 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -63,7 +63,7 @@ import allowProtocolDialog from './allowProtocolDialog'; import AppVersionManager from './AppVersionManager'; import initCookieManager from './cookieManager'; import UserActivityMonitor from './UserActivityMonitor'; -import * as WindowManager from './windows/windowManager'; +import WindowManager from './windows/windowManager'; import {displayMention, displayDownloadCompleted} from './notifications'; import parseArgs from './ParseArgs'; diff --git a/src/main/menus/app.test.js b/src/main/menus/app.test.js index 722c61e2..aca33c5f 100644 --- a/src/main/menus/app.test.js +++ b/src/main/menus/app.test.js @@ -3,7 +3,7 @@ 'use strict'; -import * as WindowManager from 'main/windows/windowManager'; +import WindowManager from 'main/windows/windowManager'; import {createTemplate} from './app'; diff --git a/src/main/menus/app.ts b/src/main/menus/app.ts index 6a1038aa..9c1c70a0 100644 --- a/src/main/menus/app.ts +++ b/src/main/menus/app.ts @@ -9,7 +9,7 @@ import {SHOW_NEW_SERVER_MODAL} from 'common/communication'; import Config from 'common/config'; import {TabType, getTabDisplayName} from 'common/tabs/TabView'; -import * as WindowManager from 'main/windows/windowManager'; +import WindowManager from 'main/windows/windowManager'; export function createTemplate(config: Config) { const separatorItem: MenuItemConstructorOptions = { diff --git a/src/main/menus/tray.ts b/src/main/menus/tray.ts index 6ecd1524..cf27a2d5 100644 --- a/src/main/menus/tray.ts +++ b/src/main/menus/tray.ts @@ -6,7 +6,7 @@ import {Menu, MenuItem, MenuItemConstructorOptions} from 'electron'; import {CombinedConfig} from 'types/config'; -import * as WindowManager from '../windows/windowManager'; +import WindowManager from '../windows/windowManager'; export function createTemplate(config: CombinedConfig) { const teams = config.teams; diff --git a/src/main/notifications/index.test.js b/src/main/notifications/index.test.js index 3635dd06..30020bf7 100644 --- a/src/main/notifications/index.test.js +++ b/src/main/notifications/index.test.js @@ -8,7 +8,7 @@ import {Notification, shell} from 'electron'; import {PLAY_SOUND} from 'common/communication'; import {TAB_MESSAGING} from 'common/tabs/TabView'; -import * as WindowManager from '../windows/windowManager'; +import WindowManager from '../windows/windowManager'; import {displayMention, displayDownloadCompleted, currentNotifications} from './index'; diff --git a/src/main/notifications/index.ts b/src/main/notifications/index.ts index 4bea76fa..e7df5215 100644 --- a/src/main/notifications/index.ts +++ b/src/main/notifications/index.ts @@ -9,7 +9,7 @@ import {MentionData} from 'types/notification'; import {PLAY_SOUND} from 'common/communication'; import {TAB_MESSAGING} from 'common/tabs/TabView'; -import * as windowManager from '../windows/windowManager'; +import WindowManager from '../windows/windowManager'; import {Mention} from './Mention'; import {DownloadNotification} from './Download'; @@ -21,7 +21,7 @@ export function displayMention(title: string, body: string, channel: {id: string log.error('notification not supported'); return; } - const serverName = windowManager.getServerNameByWebContentsId(webcontents.id); + const serverName = WindowManager.getServerNameByWebContentsId(webcontents.id); const options = { title: `${serverName}: ${title}`, @@ -45,15 +45,15 @@ export function displayMention(title: string, body: string, channel: {id: string } const notificationSound = mention.getNotificationSound(); if (notificationSound) { - windowManager.sendToRenderer(PLAY_SOUND, notificationSound); + WindowManager.sendToRenderer(PLAY_SOUND, notificationSound); } - windowManager.flashFrame(true); + WindowManager.flashFrame(true); }); mention.on('click', () => { log.info('notification click', serverName, mention); if (serverName) { - windowManager.switchTab(serverName, TAB_MESSAGING); + WindowManager.switchTab(serverName, TAB_MESSAGING); webcontents.send('notification-clicked', {channel, teamId, url}); } }); @@ -68,7 +68,7 @@ export function displayDownloadCompleted(fileName: string, path: string, serverN const download = new DownloadNotification(fileName, serverName); download.on('show', () => { - windowManager.flashFrame(true); + WindowManager.flashFrame(true); }); download.on('click', () => { diff --git a/src/main/tray/tray.ts b/src/main/tray/tray.ts index cead62a2..704011cf 100644 --- a/src/main/tray/tray.ts +++ b/src/main/tray/tray.ts @@ -6,7 +6,7 @@ import {app, nativeImage, Tray, systemPreferences, nativeTheme} from 'electron'; import {UPDATE_TRAY} from 'common/communication'; -import * as WindowManager from '../windows/windowManager'; +import WindowManager from '../windows/windowManager'; import * as AppState from '../appState'; const assetsDir = path.resolve(app.getAppPath(), 'assets'); diff --git a/src/main/views/MattermostView.ts b/src/main/views/MattermostView.ts index 04fdcb24..2c6fda4b 100644 --- a/src/main/views/MattermostView.ts +++ b/src/main/views/MattermostView.ts @@ -27,7 +27,7 @@ import {TabView} from 'common/tabs/TabView'; import {ServerInfo} from 'main/server/serverInfo'; import ContextMenu from '../contextMenu'; import {getWindowBoundaries, getLocalPreload, composeUserAgent} from '../utils'; -import * as WindowManager from '../windows/windowManager'; +import WindowManager from '../windows/windowManager'; import * as appState from '../appState'; import WebContentsEventManager from './webContentEvents'; diff --git a/src/main/views/modalManager.ts b/src/main/views/modalManager.ts index e7784735..60100da7 100644 --- a/src/main/views/modalManager.ts +++ b/src/main/views/modalManager.ts @@ -17,7 +17,7 @@ import { GET_MODAL_UNCLOSEABLE, } from 'common/communication'; -import * as WindowManager from '../windows/windowManager'; +import WindowManager from '../windows/windowManager'; import {ModalView} from './modalView'; diff --git a/src/main/views/teamDropdownView.ts b/src/main/views/teamDropdownView.ts index 1449e3d1..ddef0aa3 100644 --- a/src/main/views/teamDropdownView.ts +++ b/src/main/views/teamDropdownView.ts @@ -17,7 +17,7 @@ import { 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 * as WindowManager from '../windows/windowManager'; +import WindowManager from '../windows/windowManager'; export default class TeamDropdownView { view: BrowserView; diff --git a/src/main/views/webContentEvents.ts b/src/main/views/webContentEvents.ts index e46ed549..7db56baa 100644 --- a/src/main/views/webContentEvents.ts +++ b/src/main/views/webContentEvents.ts @@ -10,7 +10,7 @@ import urlUtils from 'common/utils/url'; import ContextMenu from 'main/contextMenu'; -import * as WindowManager from '../windows/windowManager'; +import WindowManager from '../windows/windowManager'; import {protocols} from '../../../electron-builder.json'; diff --git a/src/main/windows/mainWindow.test.js b/src/main/windows/mainWindow.test.js new file mode 100644 index 00000000..90e32846 --- /dev/null +++ b/src/main/windows/mainWindow.test.js @@ -0,0 +1,341 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import fs from 'fs'; + +import path from 'path'; + +import {BrowserWindow, screen, app, globalShortcut} from 'electron'; + +import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB} from 'common/communication'; +import {DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH} from 'common/utils/constants'; + +import ContextMenu from '../contextMenu'; +import * as Validator from '../Validator'; + +import createMainWindow from './mainWindow'; + +jest.mock('path', () => ({ + join: jest.fn(), +})); + +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(), + hide: jest.fn(), + }, + BrowserWindow: jest.fn(), + ipcMain: { + handle: jest.fn(), + }, + screen: { + getDisplayMatching: jest.fn(), + }, + globalShortcut: { + register: jest.fn(), + registerAll: jest.fn(), + }, +})); + +jest.mock('electron-log', () => ({})); + +jest.mock('global', () => ({ + willAppQuit: false, +})); + +jest.mock('fs', () => ({ + readFileSync: jest.fn(), + writeFileSync: jest.fn(), +})); + +jest.mock('../Validator', () => ({ + validateBoundsInfo: jest.fn(), +})); + +jest.mock('../contextMenu', () => jest.fn()); + +jest.mock('../utils', () => ({ + getLocalPreload: jest.fn(), + getLocalURLString: jest.fn(), +})); + +'use strict'; + +describe('main/windows/mainWindow', () => { + describe('createMainWindow', () => { + const baseWindow = { + setMenuBarVisibility: jest.fn(), + loadURL: jest.fn(), + once: jest.fn(), + on: jest.fn(), + maximize: jest.fn(), + show: jest.fn(), + hide: jest.fn(), + blur: jest.fn(), + minimize: jest.fn(), + webContents: { + on: jest.fn(), + send: jest.fn(), + }, + isMaximized: jest.fn(), + isFullScreen: jest.fn(), + getBounds: jest.fn(), + }; + + beforeEach(() => { + baseWindow.loadURL.mockImplementation(() => ({ + catch: jest.fn(), + })); + BrowserWindow.mockImplementation(() => baseWindow); + 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}})); + Validator.validateBoundsInfo.mockImplementation((data) => data); + ContextMenu.mockImplementation(() => ({ + reload: jest.fn(), + })); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should set window size using bounds read from file', () => { + createMainWindow({}, {}); + expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ + x: 400, + y: 300, + width: 1280, + height: 700, + maximized: false, + fullscreen: false, + })); + }); + + it('should set default window size when failing to read bounds from file', () => { + fs.readFileSync.mockImplementation(() => 'just a bunch of garbage'); + createMainWindow({}, {}); + expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({ + width: DEFAULT_WINDOW_WIDTH, + height: DEFAULT_WINDOW_HEIGHT, + })); + }); + + 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({}, {}); + 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, + once: jest.fn().mockImplementation((event, cb) => { + if (event === 'ready-to-show') { + cb(); + } + }), + }; + BrowserWindow.mockImplementation(() => window); + fs.readFileSync.mockImplementation(() => '{"x":400,"y":300,"width":1280,"height":700,"maximized":true,"fullscreen":false}'); + createMainWindow({}, {}); + expect(window.webContents.zoomLevel).toStrictEqual(0); + expect(window.maximize).toBeCalled(); + }); + + it('should save window state on close if the app will quit', () => { + global.willAppQuit = true; + const window = { + ...baseWindow, + on: jest.fn().mockImplementation((event, cb) => { + if (event === 'close') { + cb({preventDefault: jest.fn()}); + } + }), + }; + BrowserWindow.mockImplementation(() => window); + createMainWindow({}, {}); + global.willAppQuit = false; + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('should hide window on close for Windows if app wont quit', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'win32', + }); + const window = { + ...baseWindow, + on: jest.fn().mockImplementation((event, cb) => { + if (event === 'close') { + cb({preventDefault: jest.fn()}); + } + }), + }; + BrowserWindow.mockImplementation(() => window); + createMainWindow({}, {}); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(window.hide).toHaveBeenCalled(); + }); + + it('should hide window on close for Linux if app wont quit and config item is set', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + const window = { + ...baseWindow, + on: jest.fn().mockImplementation((event, cb) => { + if (event === 'close') { + cb({preventDefault: jest.fn()}); + } + }), + }; + BrowserWindow.mockImplementation(() => window); + createMainWindow({minimizeToTray: true}, {}); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(window.hide).toHaveBeenCalled(); + }); + + it('should minimize window on close for Linux if app wont quit and config item is not set', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + const window = { + ...baseWindow, + on: jest.fn().mockImplementation((event, cb) => { + if (event === 'close') { + cb({preventDefault: jest.fn()}); + } + }), + }; + BrowserWindow.mockImplementation(() => window); + createMainWindow({}, {}); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(window.minimize).toHaveBeenCalled(); + }); + + it('should hide window on close for Mac if app wont quit and window is not full screen', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + const window = { + ...baseWindow, + on: jest.fn().mockImplementation((event, cb) => { + if (event === 'close') { + cb({preventDefault: jest.fn()}); + } + }), + }; + BrowserWindow.mockImplementation(() => window); + createMainWindow({}, {}); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(app.hide).toHaveBeenCalled(); + }); + + it('should leave full screen and then hide window on close for Mac if app wont quit and window is full screen', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + const window = { + ...baseWindow, + isFullScreen: jest.fn().mockImplementation(() => true), + setFullScreen: jest.fn(), + once: jest.fn().mockImplementation((event, cb) => { + if (event === 'leave-full-screen') { + cb(); + } + }), + on: jest.fn().mockImplementation((event, cb) => { + if (event === 'close') { + cb({preventDefault: jest.fn()}); + } + }), + }; + BrowserWindow.mockImplementation(() => window); + createMainWindow({}, {}); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(window.once).toHaveBeenCalledWith('leave-full-screen', expect.any(Function)); + expect(app.hide).toHaveBeenCalled(); + expect(window.setFullScreen).toHaveBeenCalledWith(false); + }); + + it('should select tabs using alt+cmd+arrow keys on Mac', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + const window = { + ...baseWindow, + webContents: { + ...baseWindow.webContents, + on: jest.fn().mockImplementation((event, cb) => { + if (event === 'before-input-event') { + cb(null, {alt: true, meta: true, key: 'ArrowRight'}); + cb(null, {alt: true, meta: true, key: 'ArrowLeft'}); + } + }), + }, + }; + BrowserWindow.mockImplementation(() => window); + createMainWindow({}, {}); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(window.webContents.send).toHaveBeenCalledWith(SELECT_NEXT_TAB); + expect(window.webContents.send).toHaveBeenCalledWith(SELECT_PREVIOUS_TAB); + }); + + it('should add override shortcuts for the top menu on Linux to stop it showing up', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + const window = { + ...baseWindow, + on: jest.fn().mockImplementation((event, cb) => { + if (event === 'focus') { + cb(); + } + }), + }; + BrowserWindow.mockImplementation(() => window); + createMainWindow({}, {}); + 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)); + }); + }); +}); diff --git a/src/main/windows/windowManager.test.js b/src/main/windows/windowManager.test.js new file mode 100644 index 00000000..a41fd97d --- /dev/null +++ b/src/main/windows/windowManager.test.js @@ -0,0 +1,815 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable max-lines */ +'use strict'; + +import {app, systemPreferences} from 'electron'; + +import {getTabViewName, TAB_MESSAGING} from 'common/tabs/TabView'; +import urlUtils from 'common/utils/url'; + +import {getAdjustedWindowBoundaries} from 'main/utils'; + +import {WindowManager} from './windowManager'; +import createMainWindow from './mainWindow'; +import {createSettingsWindow} from './settingsWindow'; + +jest.mock('path', () => ({ + resolve: jest.fn(), + join: jest.fn(), +})); + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn(), + on: jest.fn(), + emit: jest.fn(), + }, + app: { + getAppPath: jest.fn(), + quit: jest.fn(), + dock: { + show: jest.fn(), + bounce: jest.fn(), + }, + }, + systemPreferences: { + getUserDefault: jest.fn(), + }, +})); + +jest.mock('electron-log', () => ({ + error: jest.fn(), + info: jest.fn(), +})); + +jest.mock('common/utils/url', () => ({ + isTeamUrl: jest.fn(), + isAdminUrl: jest.fn(), + getView: jest.fn(), +})); +jest.mock('common/tabs/TabView', () => ({ + getTabViewName: jest.fn(), + TAB_MESSAGING: 'tab-messaging', +})); +jest.mock('../utils', () => ({ + getAdjustedWindowBoundaries: jest.fn(), +})); +jest.mock('../views/viewManager', () => ({ + ViewManager: jest.fn(), +})); +jest.mock('../CriticalErrorHandler', () => jest.fn()); +jest.mock('../views/teamDropdownView', () => jest.fn()); +jest.mock('./settingsWindow', () => ({ + createSettingsWindow: jest.fn(), +})); +jest.mock('./mainWindow', () => jest.fn()); + +describe('main/windows/windowManager', () => { + describe('setConfig', () => { + const windowManager = new WindowManager(); + + beforeEach(() => { + windowManager.viewManager = { + reloadConfiguration: jest.fn(), + }; + }); + + it('should reload config on set', () => { + windowManager.setConfig({some: 'config item'}); + expect(windowManager.viewManager.reloadConfiguration).toHaveBeenCalled(); + }); + }); + + describe('showSettingsWindow', () => { + const windowManager = new WindowManager(); + windowManager.config = {}; + 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.config = {}; + windowManager.viewManager = { + handleDeepLink: jest.fn(), + updateMainWindow: jest.fn(), + }; + windowManager.initializeViewManager = jest.fn(); + + afterEach(() => { + delete windowManager.mainWindow; + }); + + 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(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(), + }; + createMainWindow.mockReturnValue(window); + windowManager.showMainWindow(); + expect(windowManager.mainWindow).toBe(window); + expect(window.on).toHaveBeenCalled(); + }); + + it('should open deep link when provided', () => { + const window = { + on: jest.fn(), + }; + createMainWindow.mockReturnValue(window); + windowManager.showMainWindow('mattermost://server-1.com/subpath'); + expect(windowManager.viewManager.handleDeepLink).toHaveBeenCalledWith('mattermost://server-1.com/subpath'); + }); + }); + + describe('handleResizeMainWindow', () => { + const windowManager = new WindowManager(); + const view = { + setBounds: jest.fn(), + tab: { + url: 'http://server-1.com', + }, + view: { + webContents: { + getURL: jest.fn(), + }, + }, + }; + windowManager.viewManager = { + getCurrentView: () => view, + setLoadingScreenBounds: jest.fn(), + }; + windowManager.mainWindow = { + getContentBounds: () => ({width: 800, height: 600}), + getSize: () => [1000, 900], + }; + windowManager.teamDropdown = { + updateWindowBounds: jest.fn(), + }; + + beforeEach(() => { + jest.useFakeTimers(); + getAdjustedWindowBoundaries.mockImplementation((width, height) => ({width, height})); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should update loading screen and team dropdown bounds', () => { + windowManager.handleResizeMainWindow(); + expect(windowManager.viewManager.setLoadingScreenBounds).toHaveBeenCalled(); + expect(windowManager.teamDropdown.updateWindowBounds).toHaveBeenCalled(); + }); + + it('should use getContentBounds when the platform is not linux', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'win32', + }); + windowManager.handleResizeMainWindow(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(view.setBounds).toHaveBeenCalledWith({width: 800, height: 600}); + }); + + it('should use getSize when the platform is linux', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + windowManager.handleResizeMainWindow(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(view.setBounds).not.toHaveBeenCalled(); + jest.runAllTimers(); + expect(view.setBounds).toHaveBeenCalledWith({width: 1000, height: 900}); + }); + }); + + describe('restoreMain', () => { + const windowManager = new WindowManager(); + windowManager.mainWindow = { + isVisible: jest.fn(), + isMinimized: jest.fn(), + restore: jest.fn(), + show: jest.fn(), + focus: jest.fn(), + }; + + afterEach(() => { + jest.resetAllMocks(); + delete windowManager.settingsWindow; + }); + + it('should restore main window if minimized', () => { + windowManager.mainWindow.isMinimized.mockReturnValue(true); + windowManager.restoreMain(); + expect(windowManager.mainWindow.restore).toHaveBeenCalled(); + }); + + it('should show main window if not visible or minimized', () => { + windowManager.mainWindow.isVisible.mockReturnValue(false); + windowManager.mainWindow.isMinimized.mockReturnValue(false); + windowManager.restoreMain(); + expect(windowManager.mainWindow.show).toHaveBeenCalled(); + }); + + it('should focus main window if visible and not minimized', () => { + windowManager.mainWindow.isVisible.mockReturnValue(true); + windowManager.mainWindow.isMinimized.mockReturnValue(false); + windowManager.restoreMain(); + expect(windowManager.mainWindow.focus).toHaveBeenCalled(); + }); + + it('should focus settings window regardless of main window state if it exists', () => { + windowManager.settingsWindow = { + focus: jest.fn(), + }; + + windowManager.mainWindow.isVisible.mockReturnValue(false); + windowManager.mainWindow.isMinimized.mockReturnValue(false); + windowManager.restoreMain(); + expect(windowManager.settingsWindow.focus).toHaveBeenCalled(); + windowManager.settingsWindow.focus.mockClear(); + + windowManager.mainWindow.isVisible.mockReturnValue(true); + windowManager.mainWindow.isMinimized.mockReturnValue(false); + windowManager.restoreMain(); + expect(windowManager.settingsWindow.focus).toHaveBeenCalled(); + windowManager.settingsWindow.focus.mockClear(); + + windowManager.mainWindow.isVisible.mockReturnValue(false); + windowManager.mainWindow.isMinimized.mockReturnValue(true); + windowManager.restoreMain(); + expect(windowManager.settingsWindow.focus).toHaveBeenCalled(); + windowManager.settingsWindow.focus.mockClear(); + + windowManager.mainWindow.isVisible.mockReturnValue(true); + windowManager.mainWindow.isMinimized.mockReturnValue(true); + windowManager.restoreMain(); + expect(windowManager.settingsWindow.focus).toHaveBeenCalled(); + windowManager.settingsWindow.focus.mockClear(); + }); + + it('should call macOS show on macOS', () => { + windowManager.mainWindow.isVisible.mockReturnValue(false); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + windowManager.restoreMain(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(app.dock.show).toHaveBeenCalled(); + }); + }); + + describe('flashFrame', () => { + const windowManager = new WindowManager(); + windowManager.mainWindow = { + flashFrame: jest.fn(), + }; + windowManager.settingsWindow = { + flashFrame: jest.fn(), + }; + + afterEach(() => { + jest.resetAllMocks(); + delete windowManager.config; + }); + + 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', () => { + windowManager.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); + expect(windowManager.settingsWindow.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', () => { + windowManager.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'); + }); + }); + + describe('handleDoubleClick', () => { + const windowManager = new WindowManager(); + const mainWindow = { + isMinimized: jest.fn(), + restore: jest.fn(), + minimize: jest.fn(), + isMaximized: jest.fn(), + unmaximize: jest.fn(), + maximize: jest.fn(), + }; + const settingsWindow = { + isMinimized: jest.fn(), + restore: jest.fn(), + minimize: jest.fn(), + isMaximized: jest.fn(), + unmaximize: jest.fn(), + maximize: jest.fn(), + }; + + beforeEach(() => { + systemPreferences.getUserDefault.mockReturnValue('Maximize'); + }); + + afterEach(() => { + jest.resetAllMocks(); + delete windowManager.mainWindow; + delete windowManager.settingsWindow; + }); + + it('should do nothing when the windows arent set', () => { + windowManager.handleDoubleClick(null, 'settings'); + expect(settingsWindow.isMaximized).not.toHaveBeenCalled(); + + windowManager.handleDoubleClick(); + expect(mainWindow.isMaximized).not.toHaveBeenCalled(); + }); + + it('should maximize when not maximized and vice versa', () => { + windowManager.mainWindow = mainWindow; + + windowManager.mainWindow.isMaximized.mockReturnValue(false); + windowManager.handleDoubleClick(); + expect(mainWindow.maximize).toHaveBeenCalled(); + + windowManager.mainWindow.isMaximized.mockReturnValue(true); + windowManager.handleDoubleClick(); + expect(mainWindow.unmaximize).toHaveBeenCalled(); + }); + + it('mac - should minimize when not minimized and vice versa when setting is set', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + windowManager.flashFrame(true); + + systemPreferences.getUserDefault.mockReturnValue('Minimize'); + windowManager.settingsWindow = settingsWindow; + + windowManager.settingsWindow.isMinimized.mockReturnValue(false); + windowManager.handleDoubleClick(null, 'settings'); + expect(settingsWindow.minimize).toHaveBeenCalled(); + + windowManager.settingsWindow.isMinimized.mockReturnValue(true); + windowManager.handleDoubleClick(null, 'settings'); + expect(settingsWindow.restore).toHaveBeenCalled(); + + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + }); + }); + + describe('switchServer', () => { + const windowManager = new WindowManager(); + windowManager.config = { + teams: [ + { + name: 'server-1', + order: 1, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + { + name: 'tab-3', + order: 1, + isOpen: true, + }, + ], + }, { + name: 'server-2', + order: 0, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + { + name: 'tab-3', + order: 1, + isOpen: true, + }, + ], + lastActiveTab: 2, + }, + ], + }; + windowManager.viewManager = { + showByName: jest.fn(), + }; + const map = windowManager.config.teams.reduce((arr, item) => { + item.tabs.forEach((tab) => { + arr.push([`${item.name}_${tab.name}`, {}]); + }); + return arr; + }, []); + windowManager.viewManager.views = new Map(map); + + beforeEach(() => { + jest.useFakeTimers(); + getTabViewName.mockImplementation((server, tab) => `${server}_${tab}`); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should do nothing if cannot find the server', () => { + windowManager.switchServer('server-3'); + expect(getTabViewName).not.toBeCalled(); + expect(windowManager.viewManager.showByName).not.toBeCalled(); + }); + + it('should show first open tab in order when last active not defined', () => { + windowManager.switchServer('server-1'); + expect(windowManager.viewManager.showByName).toHaveBeenCalledWith('server-1_tab-3'); + }); + + it('should show last active tab of chosen server', () => { + windowManager.switchServer('server-2'); + expect(windowManager.viewManager.showByName).toHaveBeenCalledWith('server-2_tab-2'); + }); + + it('should wait for view to exist if specified', () => { + windowManager.viewManager.views.delete('server-1_tab-3'); + windowManager.switchServer('server-1', true); + expect(windowManager.viewManager.showByName).not.toBeCalled(); + + jest.advanceTimersByTime(200); + expect(windowManager.viewManager.showByName).not.toBeCalled(); + + windowManager.viewManager.views.set('server-1_tab-3', {}); + jest.advanceTimersByTime(200); + expect(windowManager.viewManager.showByName).toBeCalledWith('server-1_tab-3'); + }); + }); + + describe('handleHistory', () => { + const windowManager = new WindowManager(); + windowManager.viewManager = { + getCurrentView: jest.fn(), + }; + + it('should only go to offset if it can', () => { + const view = { + view: { + webContents: { + goToOffset: jest.fn(), + canGoToOffset: () => false, + }, + }, + }; + windowManager.viewManager.getCurrentView.mockReturnValue(view); + + windowManager.handleHistory(null, 1); + expect(view.view.webContents.goToOffset).not.toBeCalled(); + + windowManager.viewManager.getCurrentView.mockReturnValue({ + ...view, + view: { + ...view.view, + webContents: { + ...view.view.webContents, + canGoToOffset: () => true, + }, + }, + }); + + windowManager.handleHistory(null, 1); + expect(view.view.webContents.goToOffset).toBeCalled(); + }); + + it('should load base URL if an error occurs', () => { + const view = { + load: jest.fn(), + tab: { + url: 'http://server-1.com', + }, + view: { + webContents: { + goToOffset: jest.fn(), + canGoToOffset: () => true, + }, + }, + }; + view.view.webContents.goToOffset.mockImplementation(() => { + throw new Error('hi'); + }); + windowManager.viewManager.getCurrentView.mockReturnValue(view); + + windowManager.handleHistory(null, 1); + expect(view.load).toBeCalledWith('http://server-1.com'); + }); + }); + + describe('selectTab', () => { + const windowManager = new WindowManager(); + windowManager.config = { + teams: [ + { + name: 'server-1', + order: 1, + tabs: [ + { + name: 'tab-1', + order: 0, + isOpen: false, + }, + { + name: 'tab-2', + order: 2, + isOpen: true, + }, + { + name: 'tab-3', + order: 1, + isOpen: true, + }, + ], + }, + ], + }; + windowManager.viewManager = { + getCurrentView: jest.fn(), + }; + windowManager.switchTab = jest.fn(); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should select next server when open', () => { + windowManager.viewManager.getCurrentView.mockReturnValue({ + tab: { + server: { + name: 'server-1', + }, + type: 'tab-3', + }, + }); + + windowManager.selectTab((order) => order + 1); + expect(windowManager.switchTab).toBeCalledWith('server-1', 'tab-2'); + }); + + it('should select previous server when open', () => { + windowManager.viewManager.getCurrentView.mockReturnValue({ + tab: { + server: { + name: 'server-1', + }, + type: 'tab-2', + }, + }); + + windowManager.selectTab((order, length) => (length + (order - 1))); + expect(windowManager.switchTab).toBeCalledWith('server-1', 'tab-3'); + }); + + it('should skip over closed tab', () => { + windowManager.viewManager.getCurrentView.mockReturnValue({ + tab: { + server: { + name: 'server-1', + }, + type: 'tab-2', + }, + }); + + windowManager.selectTab((order) => order + 1); + expect(windowManager.switchTab).toBeCalledWith('server-1', 'tab-3'); + }); + }); + + describe('handleBrowserHistoryPush', () => { + const windowManager = new WindowManager(); + windowManager.config = { + teams: [ + { + name: 'server-1', + url: 'http://server-1.com', + order: 0, + tabs: [ + { + name: 'tab-messaging', + order: 0, + isOpen: true, + }, + { + name: 'other_type_1', + order: 2, + isOpen: true, + }, + { + name: 'other_type_2', + order: 1, + isOpen: false, + }, + ], + }, + ], + }; + const view1 = { + name: 'server-1_tab-messaging', + isLoggedIn: true, + tab: { + type: TAB_MESSAGING, + server: { + url: 'http://server-1.com', + }, + }, + view: { + webContents: { + send: jest.fn(), + }, + }, + }; + const view2 = { + ...view1, + name: 'server-1_other_type_1', + tab: { + ...view1.tab, + type: 'other_type_1', + }, + view: { + webContents: { + send: jest.fn(), + }, + }, + }; + const view3 = { + ...view1, + name: 'server-1_other_type_2', + tab: { + ...view1.tab, + type: 'other_type_2', + }, + view: { + webContents: { + send: jest.fn(), + }, + }, + }; + windowManager.viewManager = { + views: new Map([ + ['server-1_tab-messaging', view1], + ['server-1_other_type_1', view2], + ]), + closedViews: new Map([ + ['server-1_other_type_2', view3], + ]), + openClosedTab: jest.fn(), + showByName: jest.fn(), + }; + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should open closed view if pushing to it', () => { + urlUtils.getView.mockReturnValue({name: 'server-1_other_type_2'}); + windowManager.viewManager.openClosedTab.mockImplementation((name) => { + const view = windowManager.viewManager.closedViews.get(name); + windowManager.viewManager.closedViews.delete(name); + windowManager.viewManager.views.set(name, view); + }); + + windowManager.handleBrowserHistoryPush(null, 'server-1_tab-messaging', '/other_type_2/subpath'); + expect(windowManager.viewManager.openClosedTab).toBeCalledWith('server-1_other_type_2', 'http://server-1.com/other_type_2/subpath'); + expect(windowManager.viewManager.showByName).toBeCalledWith('server-1_other_type_2'); + }); + + it('should open redirect view if different from current view', () => { + urlUtils.getView.mockReturnValue({name: 'server-1_other_type_1'}); + windowManager.handleBrowserHistoryPush(null, 'server-1_tab-messaging', '/other_type_1/subpath'); + expect(windowManager.viewManager.showByName).toBeCalledWith('server-1_other_type_1'); + }); + + it('should ignore redirects to "/" to Messages from other tabs', () => { + urlUtils.getView.mockReturnValue({name: 'server-1_tab-messaging'}); + windowManager.handleBrowserHistoryPush(null, 'server-1_other_type_1', '/'); + expect(view1.view.webContents.send).not.toBeCalled(); + }); + }); +}); diff --git a/src/main/windows/windowManager.ts b/src/main/windows/windowManager.ts index de86a709..bcafa724 100644 --- a/src/main/windows/windowManager.ts +++ b/src/main/windows/windowManager.ts @@ -1,6 +1,7 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ import path from 'path'; import {app, BrowserWindow, nativeImage, systemPreferences, ipcMain, IpcMainEvent, IpcMainInvokeEvent} from 'electron'; import log from 'electron-log'; @@ -38,558 +39,566 @@ import createMainWindow from './mainWindow'; // singleton module to manage application's windows -type WindowManagerStatus = { +export class WindowManager { + assetsDir: string; + mainWindow?: BrowserWindow; settingsWindow?: BrowserWindow; config?: CombinedConfig; viewManager?: ViewManager; teamDropdown?: TeamDropdownView; currentServerName?: string; -}; -const status: WindowManagerStatus = {}; -const assetsDir = path.resolve(app.getAppPath(), 'assets'); + constructor() { + this.assetsDir = path.resolve(app.getAppPath(), 'assets'); -ipcMain.on(HISTORY, handleHistory); -ipcMain.handle(GET_LOADING_SCREEN_DATA, handleLoadingScreenDataRequest); -ipcMain.handle(GET_DARK_MODE, handleGetDarkMode); -ipcMain.on(REACT_APP_INITIALIZED, handleReactAppInitialized); -ipcMain.on(LOADING_SCREEN_ANIMATION_FINISHED, handleLoadingScreenAnimationFinished); -ipcMain.on(BROWSER_HISTORY_PUSH, handleBrowserHistoryPush); -ipcMain.on(APP_LOGGED_IN, handleAppLoggedIn); -ipcMain.on(APP_LOGGED_OUT, handleAppLoggedOut); -ipcMain.handle(GET_VIEW_NAME, handleGetViewName); -ipcMain.handle(GET_VIEW_WEBCONTENTS_ID, handleGetWebContentsId); - -export function setConfig(data: CombinedConfig) { - if (data) { - status.config = data; + ipcMain.on(HISTORY, this.handleHistory); + ipcMain.handle(GET_LOADING_SCREEN_DATA, this.handleLoadingScreenDataRequest); + ipcMain.handle(GET_DARK_MODE, this.handleGetDarkMode); + ipcMain.on(REACT_APP_INITIALIZED, this.handleReactAppInitialized); + ipcMain.on(LOADING_SCREEN_ANIMATION_FINISHED, this.handleLoadingScreenAnimationFinished); + ipcMain.on(BROWSER_HISTORY_PUSH, this.handleBrowserHistoryPush); + ipcMain.on(APP_LOGGED_IN, this.handleAppLoggedIn); + ipcMain.on(APP_LOGGED_OUT, this.handleAppLoggedOut); + ipcMain.handle(GET_VIEW_NAME, this.handleGetViewName); + ipcMain.handle(GET_VIEW_WEBCONTENTS_ID, this.handleGetWebContentsId); } - if (status.viewManager && status.config) { - status.viewManager.reloadConfiguration(status.config.teams || []); - } -} -export function showSettingsWindow() { - if (status.settingsWindow) { - status.settingsWindow.show(); - } else { - if (!status.mainWindow) { - showMainWindow(); + setConfig = (data: CombinedConfig) => { + if (data) { + this.config = data; } - const withDevTools = Boolean(process.env.MM_DEBUG_SETTINGS) || false; - - if (!status.config) { - return; + if (this.viewManager && this.config) { + this.viewManager.reloadConfiguration(this.config.teams || []); } - status.settingsWindow = createSettingsWindow(status.mainWindow!, status.config, withDevTools); - status.settingsWindow.on('closed', () => { - delete status.settingsWindow; - }); } -} -export function showMainWindow(deeplinkingURL?: string | URL) { - if (status.mainWindow) { - if (status.mainWindow.isVisible()) { - status.mainWindow.focus(); + showSettingsWindow = () => { + if (this.settingsWindow) { + this.settingsWindow.show(); } else { - status.mainWindow.show(); - } - } else { - if (!status.config) { - return; - } - status.mainWindow = createMainWindow(status.config, { - linuxAppIcon: path.join(assetsDir, 'linux', 'app_icon.png'), - }); - - if (!status.mainWindow) { - log.error('unable to create main window'); - app.quit(); - } - - // window handlers - status.mainWindow.on('closed', () => { - log.warn('main window closed'); - delete status.mainWindow; - }); - status.mainWindow.on('unresponsive', () => { - const criticalErrorHandler = new CriticalErrorHandler(); - criticalErrorHandler.setMainWindow(status.mainWindow!); - criticalErrorHandler.windowUnresponsiveHandler(); - }); - status.mainWindow.on('maximize', handleMaximizeMainWindow); - status.mainWindow.on('unmaximize', handleUnmaximizeMainWindow); - status.mainWindow.on('resize', handleResizeMainWindow); - status.mainWindow.on('focus', focusBrowserView); - status.mainWindow.on('enter-full-screen', () => sendToRenderer('enter-full-screen')); - status.mainWindow.on('leave-full-screen', () => sendToRenderer('leave-full-screen')); - - if (process.env.MM_DEBUG_SETTINGS) { - status.mainWindow.webContents.openDevTools({mode: 'detach'}); - } - - if (status.viewManager) { - status.viewManager.updateMainWindow(status.mainWindow); - } - - status.teamDropdown = new TeamDropdownView(status.mainWindow, status.config.teams, status.config.darkMode, status.config.enableServerManagement); - } - initializeViewManager(); - - if (deeplinkingURL) { - status.viewManager!.handleDeepLink(deeplinkingURL); - } -} - -export function getMainWindow(ensureCreated?: boolean) { - if (ensureCreated && !status.mainWindow) { - showMainWindow(); - } - return status.mainWindow; -} - -function handleMaximizeMainWindow() { - sendToRenderer(MAXIMIZE_CHANGE, true); -} - -function handleUnmaximizeMainWindow() { - sendToRenderer(MAXIMIZE_CHANGE, false); -} - -function handleResizeMainWindow() { - if (!(status.viewManager && status.mainWindow)) { - return; - } - const currentView = status.viewManager.getCurrentView(); - let bounds: Partial; - - // 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 = status.mainWindow.getSize(); - bounds = {width: size[0], height: size[1]}; - } else { - bounds = status.mainWindow.getContentBounds(); - } - - const setBoundsFunction = () => { - if (currentView) { - currentView.setBounds(getAdjustedWindowBoundaries(bounds.width!, bounds.height!, !(urlUtils.isTeamUrl(currentView.tab.url, currentView.view.webContents.getURL()) || urlUtils.isAdminUrl(currentView.tab.url, currentView.view.webContents.getURL())))); - } - }; - - // Another workaround since the window doesn't update properly under Linux for some reason - // See above comment - if (process.platform === 'linux') { - setTimeout(setBoundsFunction, 10); - } else { - setBoundsFunction(); - } - status.viewManager.setLoadingScreenBounds(); - status.teamDropdown?.updateWindowBounds(); -} - -export function sendToRenderer(channel: string, ...args: any[]) { - if (!status.mainWindow) { - showMainWindow(); - } - status.mainWindow!.webContents.send(channel, ...args); - if (status.settingsWindow && status.settingsWindow.isVisible()) { - status.settingsWindow.webContents.send(channel, ...args); - } -} - -export function sendToMattermostViews(channel: string, ...args: any[]) { - if (status.viewManager) { - status.viewManager.sendToAllViews(channel, ...args); - } -} - -export function restoreMain() { - log.info('restoreMain'); - if (!status.mainWindow) { - showMainWindow(); - } - if (!status.mainWindow!.isVisible() || status.mainWindow!.isMinimized()) { - if (status.mainWindow!.isMinimized()) { - status.mainWindow!.restore(); - } else { - status.mainWindow!.show(); - } - if (status.settingsWindow) { - status.settingsWindow.focus(); - } else { - status.mainWindow!.focus(); - } - if (process.platform === 'darwin') { - app.dock.show(); - } - } else if (status.settingsWindow) { - status.settingsWindow.focus(); - } else { - status.mainWindow!.focus(); - } -} - -export function flashFrame(flash: boolean) { - if (process.platform === 'linux' || process.platform === 'win32') { - if (status.config?.notifications.flashWindow) { - status.mainWindow?.flashFrame(flash); - if (status.settingsWindow) { - // main might be hidden behind the settings - status.settingsWindow.flashFrame(flash); + if (!this.mainWindow) { + this.showMainWindow(); } + const withDevTools = Boolean(process.env.MM_DEBUG_SETTINGS) || false; + + if (!this.config) { + return; + } + this.settingsWindow = createSettingsWindow(this.mainWindow!, this.config, withDevTools); + this.settingsWindow.on('closed', () => { + delete this.settingsWindow; + }); } } - if (process.platform === 'darwin' && status.config?.notifications.bounceIcon) { - app.dock.bounce(status.config?.notifications.bounceIconType); - } -} -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'); + showMainWindow = (deeplinkingURL?: string | URL) => { + if (this.mainWindow) { + if (this.mainWindow.isVisible()) { + this.mainWindow.focus(); + } else { + this.mainWindow.show(); + } + } else { + if (!this.config) { + return; + } + this.mainWindow = createMainWindow(this.config, { + linuxAppIcon: path.join(this.assetsDir, 'linux', 'app_icon.png'), + }); - if (!ctx) { - log.error('Could not create canvas context'); - return null; + if (!this.mainWindow) { + log.error('unable to create main window'); + app.quit(); + return; + } + + // window handlers + this.mainWindow.on('closed', () => { + log.warn('main window closed'); + delete this.mainWindow; + }); + this.mainWindow.on('unresponsive', () => { + const criticalErrorHandler = new CriticalErrorHandler(); + criticalErrorHandler.setMainWindow(this.mainWindow!); + criticalErrorHandler.windowUnresponsiveHandler(); + }); + this.mainWindow.on('maximize', this.handleMaximizeMainWindow); + this.mainWindow.on('unmaximize', this.handleUnmaximizeMainWindow); + this.mainWindow.on('resize', this.handleResizeMainWindow); + 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')); + + 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, this.config.teams, this.config.darkMode, this.config.enableServerManagement); + } + this.initializeViewManager(); + + if (deeplinkingURL) { + this.viewManager!.handleDeepLink(deeplinkingURL); + } } - // 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(text: string, small: boolean) { - const win = status.mainWindow; - if (!win) { - return null; + getMainWindow = (ensureCreated?: boolean) => { + if (ensureCreated && !this.mainWindow) { + this.showMainWindow(); + } + return this.mainWindow; } - // 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 = ${drawBadge}; - window.drawBadge('${text || ''}', ${safeSmall}); - `; - return win.webContents.executeJavaScript(code); -} + on = this.mainWindow?.on; -export async function setOverlayIcon(badgeText: string | undefined, description: string, small: boolean) { - if (process.platform === 'win32') { - let overlay = null; - if (status.mainWindow) { - if (badgeText) { - try { - const dataUrl = await createDataURL(badgeText, small); - overlay = nativeImage.createFromDataURL(dataUrl); - } catch (err) { - log.error(`Couldn't generate a badge: ${err}`); + handleMaximizeMainWindow = () => { + this.sendToRenderer(MAXIMIZE_CHANGE, true); + } + + handleUnmaximizeMainWindow = () => { + this.sendToRenderer(MAXIMIZE_CHANGE, false); + } + + handleResizeMainWindow = () => { + if (!(this.viewManager && this.mainWindow)) { + return; + } + const currentView = this.viewManager.getCurrentView(); + let bounds: Partial; + + // 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(); + bounds = {width: size[0], height: size[1]}; + } else { + bounds = this.mainWindow.getContentBounds(); + } + + const setBoundsFunction = () => { + if (currentView) { + currentView.setBounds(getAdjustedWindowBoundaries(bounds.width!, bounds.height!, !(urlUtils.isTeamUrl(currentView.tab.url, currentView.view.webContents.getURL()) || urlUtils.isAdminUrl(currentView.tab.url, currentView.view.webContents.getURL())))); + } + }; + + // Another workaround since the window doesn't update properly under Linux for some reason + // See above comment + if (process.platform === 'linux') { + setTimeout(setBoundsFunction, 10); + } else { + setBoundsFunction(); + } + this.viewManager.setLoadingScreenBounds(); + this.teamDropdown?.updateWindowBounds(); + } + + sendToRenderer = (channel: string, ...args: any[]) => { + if (!this.mainWindow) { + this.showMainWindow(); + } + this.mainWindow!.webContents.send(channel, ...args); + if (this.settingsWindow && this.settingsWindow.isVisible()) { + this.settingsWindow.webContents.send(channel, ...args); + } + } + + sendToAll = (channel: string, ...args: any[]) => { + this.sendToRenderer(channel, ...args); + if (this.settingsWindow) { + this.settingsWindow.webContents.send(channel, ...args); + } + + // TODO: should we include popups? + } + + sendToMattermostViews = (channel: string, ...args: any[]) => { + if (this.viewManager) { + this.viewManager.sendToAllViews(channel, ...args); + } + } + + restoreMain = () => { + log.info('restoreMain'); + if (!this.mainWindow) { + this.showMainWindow(); + } + if (!this.mainWindow!.isVisible() || this.mainWindow!.isMinimized()) { + if (this.mainWindow!.isMinimized()) { + this.mainWindow!.restore(); + } else { + this.mainWindow!.show(); + } + if (this.settingsWindow) { + this.settingsWindow.focus(); + } else { + this.mainWindow!.focus(); + } + if (process.platform === 'darwin') { + app.dock.show(); + } + } else if (this.settingsWindow) { + this.settingsWindow.focus(); + } else { + this.mainWindow!.focus(); + } + } + + flashFrame = (flash: boolean) => { + if (process.platform === 'linux' || process.platform === 'win32') { + if (this.config?.notifications.flashWindow) { + this.mainWindow?.flashFrame(flash); + if (this.settingsWindow) { + // main might be hidden behind the settings + this.settingsWindow.flashFrame(flash); } } - status.mainWindow.setOverlayIcon(overlay, description); + } + if (process.platform === 'darwin' && this.config?.notifications.bounceIcon) { + app.dock.bounce(this.config?.notifications.bounceIconType); } } -} -export function handleDoubleClick(e: IpcMainEvent, windowType?: string) { - let action = 'Maximize'; - if (process.platform === 'darwin') { - action = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string'); - } - const win = (windowType === 'settings') ? status.settingsWindow : status.mainWindow; - if (!win) { - return; - } - switch (action) { - case 'Minimize': - if (win.isMinimized()) { - win.restore(); - } else { - win.minimize(); + 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; } - break; - case 'Maximize': - default: - if (win.isMaximized()) { - win.unmaximize(); - } else { - win.maximize(); + + // 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; } - break; - } -} -function initializeViewManager() { - if (!status.viewManager && status.config && status.mainWindow) { - status.viewManager = new ViewManager(status.config, status.mainWindow); - status.viewManager.load(); - status.viewManager.showInitial(); - initializeCurrentServerName(); + // 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); } -} -export function initializeCurrentServerName() { - if (status.config && !status.currentServerName) { - status.currentServerName = (status.config.teams.find((team) => team.order === status.config?.lastActiveTeam) || status.config.teams.find((team) => team.order === 0))?.name; - } -} - -export function switchServer(serverName: string, waitForViewToExist = false) { - showMainWindow(); - const server = status.config?.teams.find((team) => team.name === serverName); - if (!server) { - log.error('Cannot find server in config'); - return; - } - status.currentServerName = serverName; - let nextTab = server.tabs.find((tab) => tab.isOpen && tab.order === (server.lastActiveTab || 0)); - if (!nextTab) { - const openTabs = server.tabs.filter((tab) => tab.isOpen); - nextTab = openTabs.find((e) => e.order === 0) || openTabs[0]; - } - const tabViewName = getTabViewName(serverName, nextTab.name); - if (waitForViewToExist) { - const timeout = setInterval(() => { - if (status.viewManager?.views.has(tabViewName)) { - status.viewManager?.showByName(tabViewName); - clearTimeout(timeout); - } - }, 100); - } else { - status.viewManager?.showByName(tabViewName); - } - ipcMain.emit(UPDATE_SHORTCUT_MENU); -} - -export function switchTab(serverName: string, tabName: string) { - showMainWindow(); - const tabViewName = getTabViewName(serverName, tabName); - status.viewManager?.showByName(tabViewName); -} - -export function focusBrowserView() { - if (status.viewManager) { - status.viewManager.focus(); - } else { - log.error('Trying to call focus when the viewmanager has not yet been initialized'); - } -} - -export function openBrowserViewDevTools() { - if (status.viewManager) { - status.viewManager.openViewDevTools(); - } -} - -export function focusThreeDotMenu() { - if (status.mainWindow) { - status.mainWindow.webContents.focus(); - status.mainWindow.webContents.send(FOCUS_THREE_DOT_MENU); - } -} - -function handleLoadingScreenDataRequest() { - return { - darkMode: status.config?.darkMode || false, - }; -} - -function handleReactAppInitialized(e: IpcMainEvent, view: string) { - if (status.viewManager) { - status.viewManager.setServerInitialized(view); - } -} - -function handleLoadingScreenAnimationFinished() { - if (status.viewManager) { - status.viewManager.hideLoadingScreen(); - } -} - -export function updateLoadingScreenDarkMode(darkMode: boolean) { - if (status.viewManager) { - status.viewManager.updateLoadingScreenDarkMode(darkMode); - } -} - -export function getServerNameByWebContentsId(webContentsId: number) { - const view = status.viewManager?.findViewByWebContent(webContentsId); - return view?.tab.server.name; -} - -export function close() { - const focused = BrowserWindow.getFocusedWindow(); - focused?.close(); -} -export function maximize() { - const focused = BrowserWindow.getFocusedWindow(); - if (focused) { - focused.maximize(); - } -} -export function minimize() { - const focused = BrowserWindow.getFocusedWindow(); - if (focused) { - focused.minimize(); - } -} -export function restore() { - const focused = BrowserWindow.getFocusedWindow(); - if (focused) { - focused.restore(); - } - if (focused?.isFullScreen()) { - focused.setFullScreen(false); - } -} - -export function reload() { - const currentView = status.viewManager?.getCurrentView(); - if (currentView) { - status.viewManager?.showLoadingScreen(); - currentView.reload(); - } -} - -export function sendToFind() { - const currentView = status.viewManager?.getCurrentView(); - if (currentView) { - currentView.view.webContents.sendInputEvent({type: 'keyDown', keyCode: 'F', modifiers: [process.platform === 'darwin' ? 'cmd' : 'ctrl', 'shift']}); - } -} - -function handleHistory(event: IpcMainEvent, offset: number) { - if (status.viewManager) { - const activeView = status.viewManager.getCurrentView(); - if (activeView && activeView.view.webContents.canGoToOffset(offset)) { - try { - activeView.view.webContents.goToOffset(offset); - } catch (error) { - log.error(error); - activeView.load(activeView.tab.url); + 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); } } } -} -export function selectNextTab() { - const currentView = status.viewManager?.getCurrentView(); - if (!currentView) { - return; + isMainWindow = (window: BrowserWindow) => { + return this.mainWindow && this.mainWindow === window; } - const currentTeamTabs = status.config?.teams.find((team) => team.name === currentView.tab.server.name)?.tabs; - const filteredTabs = currentTeamTabs?.filter((tab) => tab.isOpen); - const currentTab = currentTeamTabs?.find((tab) => tab.name === currentView.tab.type); - if (!currentTeamTabs || !currentTab || !filteredTabs) { - return; + handleDoubleClick = (e: IpcMainEvent, windowType?: string) => { + let action = 'Maximize'; + if (process.platform === 'darwin') { + action = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string'); + } + const win = (windowType === 'settings') ? this.settingsWindow : this.mainWindow; + if (!win) { + return; + } + switch (action) { + case 'Minimize': + if (win.isMinimized()) { + win.restore(); + } else { + win.minimize(); + } + break; + case 'Maximize': + default: + if (win.isMaximized()) { + win.unmaximize(); + } else { + win.maximize(); + } + break; + } } - let currentOrder = currentTab.order; - let nextIndex = -1; - while (nextIndex === -1) { - const nextOrder = ((currentOrder + 1) % currentTeamTabs.length); - nextIndex = filteredTabs.findIndex((tab) => tab.order === nextOrder); - currentOrder = nextOrder; + initializeViewManager = () => { + if (!this.viewManager && this.config && this.mainWindow) { + this.viewManager = new ViewManager(this.config, this.mainWindow); + this.viewManager.load(); + this.viewManager.showInitial(); + this.initializeCurrentServerName(); + } } - const newTab = filteredTabs[nextIndex]; - switchTab(currentView.tab.server.name, newTab.name); -} - -export function selectPreviousTab() { - const currentView = status.viewManager?.getCurrentView(); - if (!currentView) { - return; + initializeCurrentServerName = () => { + if (this.config && !this.currentServerName) { + this.currentServerName = (this.config.teams.find((team) => team.order === this.config?.lastActiveTeam) || this.config.teams.find((team) => team.order === 0))?.name; + } } - const currentTeamTabs = status.config?.teams.find((team) => team.name === currentView.tab.server.name)?.tabs; - const filteredTabs = currentTeamTabs?.filter((tab) => tab.isOpen); - const currentTab = currentTeamTabs?.find((tab) => tab.name === currentView.tab.type); - if (!currentTeamTabs || !currentTab || !filteredTabs) { - return; + switchServer = (serverName: string, waitForViewToExist = false) => { + this.showMainWindow(); + const server = this.config?.teams.find((team) => team.name === serverName); + if (!server) { + log.error('Cannot find server in config'); + return; + } + this.currentServerName = serverName; + let nextTab = server.tabs.find((tab) => tab.isOpen && tab.order === (server.lastActiveTab || 0)); + if (!nextTab) { + const openTabs = server.tabs.filter((tab) => tab.isOpen); + nextTab = openTabs.find((e) => e.order === 0) || openTabs.concat().sort((a, b) => a.order - b.order)[0]; + } + const tabViewName = getTabViewName(serverName, nextTab.name); + if (waitForViewToExist) { + const timeout = setInterval(() => { + if (this.viewManager?.views.has(tabViewName)) { + this.viewManager?.showByName(tabViewName); + clearTimeout(timeout); + } + }, 100); + } else { + this.viewManager?.showByName(tabViewName); + } + ipcMain.emit(UPDATE_SHORTCUT_MENU); } - // js modulo operator returns a negative number if result is negative, so we have to ensure it's positive - let currentOrder = currentTab.order; - let nextIndex = -1; - while (nextIndex === -1) { - const nextOrder = ((currentTeamTabs.length + (currentOrder - 1)) % currentTeamTabs.length); - nextIndex = filteredTabs.findIndex((tab) => tab.order === nextOrder); - currentOrder = nextOrder; + switchTab = (serverName: string, tabName: string) => { + this.showMainWindow(); + const tabViewName = getTabViewName(serverName, tabName); + this.viewManager?.showByName(tabViewName); } - const newTab = filteredTabs[nextIndex]; - switchTab(currentView.tab.server.name, newTab.name); -} - -function handleGetDarkMode() { - return status.config?.darkMode; -} - -function handleBrowserHistoryPush(e: IpcMainEvent, viewName: string, pathName: string) { - const currentView = status.viewManager?.views.get(viewName); - const redirectedViewName = urlUtils.getView(`${currentView?.tab.server.url}${pathName}`, status.config!.teams)?.name || viewName; - if (status.viewManager?.closedViews.has(redirectedViewName)) { - status.viewManager.openClosedTab(redirectedViewName, `${currentView?.tab.server.url}${pathName}`); - } - let redirectedView = status.viewManager?.views.get(redirectedViewName) || currentView; - if (redirectedView !== currentView && redirectedView?.tab.server.name === status.currentServerName && redirectedView?.isLoggedIn) { - log.info('redirecting to a new view', redirectedView?.name || viewName); - status.viewManager?.showByName(redirectedView?.name || viewName); - } else { - redirectedView = currentView; + focusBrowserView = () => { + if (this.viewManager) { + this.viewManager.focus(); + } else { + log.error('Trying to call focus when the viewmanager has not yet been initialized'); + } } - // Special case check for Channels to not force a redirect to "/", causing a refresh - if (!(redirectedView !== currentView && redirectedView?.tab.type === TAB_MESSAGING && pathName === '/')) { - redirectedView?.view.webContents.send(BROWSER_HISTORY_PUSH, pathName); + openBrowserViewDevTools = () => { + if (this.viewManager) { + this.viewManager.openViewDevTools(); + } + } + + focusThreeDotMenu = () => { + if (this.mainWindow) { + this.mainWindow.webContents.focus(); + this.mainWindow.webContents.send(FOCUS_THREE_DOT_MENU); + } + } + + handleLoadingScreenDataRequest = () => { + return { + darkMode: this.config?.darkMode || false, + }; + } + + handleReactAppInitialized = (e: IpcMainEvent, view: string) => { + if (this.viewManager) { + this.viewManager.setServerInitialized(view); + } + } + + handleLoadingScreenAnimationFinished = () => { + if (this.viewManager) { + this.viewManager.hideLoadingScreen(); + } + } + + updateLoadingScreenDarkMode = (darkMode: boolean) => { + if (this.viewManager) { + this.viewManager.updateLoadingScreenDarkMode(darkMode); + } + } + + getViewNameByWebContentsId = (webContentsId: number) => { + const view = this.viewManager?.findViewByWebContent(webContentsId); + return view?.name; + } + + getServerNameByWebContentsId = (webContentsId: number) => { + const view = this.viewManager?.findViewByWebContent(webContentsId); + return view?.tab.server.name; + } + + close = () => { + const focused = BrowserWindow.getFocusedWindow(); + focused?.close(); + } + maximize = () => { + const focused = BrowserWindow.getFocusedWindow(); + if (focused) { + focused.maximize(); + } + } + minimize = () => { + const focused = BrowserWindow.getFocusedWindow(); + if (focused) { + focused.minimize(); + } + } + restore = () => { + const focused = BrowserWindow.getFocusedWindow(); + if (focused) { + focused.restore(); + } + if (focused?.isFullScreen()) { + focused.setFullScreen(false); + } + } + + reload = () => { + const currentView = this.viewManager?.getCurrentView(); + if (currentView) { + this.viewManager?.showLoadingScreen(); + currentView.reload(); + } + } + + sendToFind = () => { + const currentView = this.viewManager?.getCurrentView(); + if (currentView) { + currentView.view.webContents.sendInputEvent({type: 'keyDown', keyCode: 'F', modifiers: [process.platform === 'darwin' ? 'cmd' : 'ctrl', 'shift']}); + } + } + + handleHistory = (event: IpcMainEvent, offset: number) => { + if (this.viewManager) { + const activeView = this.viewManager.getCurrentView(); + if (activeView && activeView.view.webContents.canGoToOffset(offset)) { + try { + activeView.view.webContents.goToOffset(offset); + } catch (error) { + log.error(error); + activeView.load(activeView.tab.url); + } + } + } + } + + selectNextTab = () => { + this.selectTab((order) => order + 1); + } + + selectPreviousTab = () => { + this.selectTab((order, length) => (length + (order - 1))); + } + + selectTab = (fn: (order: number, length: number) => number) => { + const currentView = this.viewManager?.getCurrentView(); + if (!currentView) { + return; + } + + const currentTeamTabs = this.config?.teams.find((team) => team.name === currentView.tab.server.name)?.tabs; + const filteredTabs = currentTeamTabs?.filter((tab) => tab.isOpen); + const currentTab = currentTeamTabs?.find((tab) => tab.name === currentView.tab.type); + if (!currentTeamTabs || !currentTab || !filteredTabs) { + return; + } + + let currentOrder = currentTab.order; + let nextIndex = -1; + while (nextIndex === -1) { + const nextOrder = (fn(currentOrder, currentTeamTabs.length) % currentTeamTabs.length); + nextIndex = filteredTabs.findIndex((tab) => tab.order === nextOrder); + currentOrder = nextOrder; + } + + const newTab = filteredTabs[nextIndex]; + this.switchTab(currentView.tab.server.name, newTab.name); + } + + handleGetDarkMode = () => { + return this.config?.darkMode; + } + + handleBrowserHistoryPush = (e: IpcMainEvent, viewName: string, pathName: string) => { + const currentView = this.viewManager?.views.get(viewName); + const redirectedViewName = urlUtils.getView(`${currentView?.tab.server.url}${pathName}`, this.config!.teams)?.name || viewName; + if (this.viewManager?.closedViews.has(redirectedViewName)) { + this.viewManager.openClosedTab(redirectedViewName, `${currentView?.tab.server.url}${pathName}`); + } + let redirectedView = this.viewManager?.views.get(redirectedViewName) || currentView; + if (redirectedView !== currentView && redirectedView?.tab.server.name === this.currentServerName && redirectedView?.isLoggedIn) { + log.info('redirecting to a new view', redirectedView?.name || viewName); + this.viewManager?.showByName(redirectedView?.name || viewName); + } else { + redirectedView = currentView; + } + + // Special case check for Channels to not force a redirect to "/", causing a refresh + if (!(redirectedView !== currentView && redirectedView?.tab.type === TAB_MESSAGING && pathName === '/')) { + redirectedView?.view.webContents.send(BROWSER_HISTORY_PUSH, pathName); + } + } + + getCurrentTeamName = () => { + return this.currentServerName; + } + + handleAppLoggedIn = (event: IpcMainEvent, viewName: string) => { + const view = this.viewManager?.views.get(viewName); + if (view) { + view.isLoggedIn = true; + this.viewManager?.reloadViewIfNeeded(viewName); + } + } + + handleAppLoggedOut = (event: IpcMainEvent, viewName: string) => { + const view = this.viewManager?.views.get(viewName); + if (view) { + view.isLoggedIn = false; + } + } + + handleGetViewName = (event: IpcMainInvokeEvent) => { + return this.getViewNameByWebContentsId(event.sender.id); + } + + handleGetWebContentsId = (event: IpcMainInvokeEvent) => { + return event.sender.id; } } -export function getCurrentTeamName() { - return status.currentServerName; -} - -function handleAppLoggedIn(event: IpcMainEvent, viewName: string) { - const view = status.viewManager?.views.get(viewName); - if (view) { - view.isLoggedIn = true; - status.viewManager?.reloadViewIfNeeded(viewName); - } -} - -function handleAppLoggedOut(event: IpcMainEvent, viewName: string) { - const view = status.viewManager?.views.get(viewName); - if (view) { - view.isLoggedIn = false; - } -} - -function handleGetViewName(event: IpcMainInvokeEvent) { - const view = status.viewManager?.findViewByWebContent(event.sender.id); - return view?.name; -} -function handleGetWebContentsId(event: IpcMainInvokeEvent) { - return event.sender.id; -} - +const windowManager = new WindowManager(); +export default windowManager;