Files
mattermostest/src/main/windows/windowManager.ts

582 lines
19 KiB
TypeScript

// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'path';
import {app, BrowserWindow, nativeImage, systemPreferences, ipcMain, IpcMainEvent} from 'electron';
import log from 'electron-log';
import {CombinedConfig} from 'types/config';
import {
MAXIMIZE_CHANGE,
HISTORY,
GET_LOADING_SCREEN_DATA,
REACT_APP_INITIALIZED,
LOADING_SCREEN_ANIMATION_FINISHED,
FOCUS_THREE_DOT_MENU,
GET_DARK_MODE,
UPDATE_SHORTCUT_MENU,
BROWSER_HISTORY_PUSH,
APP_LOGGED_IN,
} from 'common/communication';
import urlUtils from 'common/utils/url';
import {getTabViewName, TAB_MESSAGING} from 'common/tabs/TabView';
import {getAdjustedWindowBoundaries} from '../utils';
import {ViewManager} from '../views/viewManager';
import CriticalErrorHandler from '../CriticalErrorHandler';
import TeamDropdownView from '../views/teamDropdownView';
import {createSettingsWindow} from './settingsWindow';
import createMainWindow from './mainWindow';
// singleton module to manage application's windows
type WindowManagerStatus = {
mainWindow?: BrowserWindow;
settingsWindow?: BrowserWindow;
config?: CombinedConfig;
viewManager?: ViewManager;
teamDropdown?: TeamDropdownView;
currentServerName?: string;
};
const status: WindowManagerStatus = {};
const 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);
export function setConfig(data: CombinedConfig) {
if (data) {
status.config = data;
}
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();
}
const withDevTools = Boolean(process.env.MM_DEBUG_SETTINGS) || false;
if (!status.config) {
return;
}
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();
} 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;
}
export const on = status.mainWindow?.on;
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<Electron.Rectangle>;
// 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 sendToAll(channel: string, ...args: any[]) {
sendToRenderer(channel, ...args);
if (status.settingsWindow) {
status.settingsWindow.webContents.send(channel, ...args);
}
// TODO: should we include popups?
}
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 (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');
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(text: string, small: boolean) {
const win = status.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 = ${drawBadge};
window.drawBadge('${text || ''}', ${safeSmall});
`;
return win.webContents.executeJavaScript(code);
}
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}`);
}
}
status.mainWindow.setOverlayIcon(overlay, description);
}
}
}
export function isMainWindow(window: BrowserWindow) {
return status.mainWindow && status.mainWindow === window;
}
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();
}
break;
case 'Maximize':
default:
if (win.isMaximized()) {
win.unmaximize();
} else {
win.maximize();
}
break;
}
}
function initializeViewManager() {
if (!status.viewManager && status.config && status.mainWindow) {
status.viewManager = new ViewManager(status.config, status.mainWindow);
status.viewManager.load();
status.viewManager.showInitial();
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 getViewNameByWebContentsId(webContentsId: number) {
const view = status.viewManager?.findViewByWebContent(webContentsId);
return view?.name;
}
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']});
}
}
export 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);
}
}
}
}
export function selectNextTab() {
const currentView = status.viewManager?.getCurrentView();
if (!currentView) {
return;
}
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;
}
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;
}
const newTab = filteredTabs[nextIndex];
switchTab(currentView.tab.server.name, newTab.name);
}
export function selectPreviousTab() {
const currentView = status.viewManager?.getCurrentView();
if (!currentView) {
return;
}
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;
}
// 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;
}
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}`);
}
const redirectedView = status.viewManager?.views.get(redirectedViewName) || currentView;
if (redirectedView !== currentView && redirectedView?.tab.server.name === status.currentServerName) {
log.info('redirecting to a new view', redirectedView?.name || viewName);
status.viewManager?.showByName(redirectedView?.name || viewName);
}
// 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);
}
}
export function getCurrentTeamName() {
return status.currentServerName;
}
function handleAppLoggedIn(event: IpcMainEvent, viewName: string) {
status.viewManager?.reloadViewIfNeeded(viewName);
}