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

555 lines
21 KiB
TypeScript

// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import fs from 'fs';
import os from 'os';
import path from 'path';
import type {BrowserWindowConstructorOptions, Event, Input} from 'electron';
import {app, BrowserWindow, dialog, globalShortcut, ipcMain, screen} from 'electron';
import {EventEmitter} from 'events';
import AppState from 'common/appState';
import {
SELECT_NEXT_TAB,
SELECT_PREVIOUS_TAB,
GET_FULL_SCREEN_STATUS,
FOCUS_THREE_DOT_MENU,
SERVERS_UPDATE,
UPDATE_APPSTATE_FOR_VIEW_ID,
UPDATE_MENTIONS,
MAXIMIZE_CHANGE,
MAIN_WINDOW_CREATED,
MAIN_WINDOW_RESIZED,
MAIN_WINDOW_FOCUSED,
VIEW_FINISHED_RESIZING,
TOGGLE_SECURE_INPUT,
} from 'common/communication';
import Config from 'common/config';
import {Logger} from 'common/log';
import ServerManager from 'common/servers/serverManager';
import {DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, MINIMUM_WINDOW_HEIGHT, MINIMUM_WINDOW_WIDTH, SECOND} from 'common/utils/constants';
import Utils from 'common/utils/util';
import * as Validator from 'common/Validator';
import {boundsInfoPath} from 'main/constants';
import {localizeMessage} from 'main/i18nManager';
import type {SavedWindowState} from 'types/mainWindow';
import ContextMenu from '../contextMenu';
import {getLocalPreload, getLocalURLString, isInsideRectangle} from '../utils';
const log = new Logger('MainWindow');
const ALT_MENU_KEYS = ['Alt+F', 'Alt+E', 'Alt+V', 'Alt+H', 'Alt+W', 'Alt+P'];
export class MainWindow extends EventEmitter {
private win?: BrowserWindow;
private savedWindowState?: Partial<SavedWindowState>;
private ready: boolean;
private isResizing: boolean;
private lastEmittedBounds?: Electron.Rectangle;
private isMaximized: boolean;
constructor() {
super();
// Create the browser window.
this.ready = false;
this.isResizing = false;
this.isMaximized = false;
ipcMain.handle(GET_FULL_SCREEN_STATUS, () => this.win?.isFullScreen());
ipcMain.on(VIEW_FINISHED_RESIZING, this.handleViewFinishedResizing);
ServerManager.on(SERVERS_UPDATE, this.handleUpdateConfig);
AppState.on(UPDATE_APPSTATE_FOR_VIEW_ID, this.handleUpdateAppStateForViewId);
}
init = () => {
// Can't call this before the app is ready
this.savedWindowState = this.getSavedWindowState();
const windowOptions: BrowserWindowConstructorOptions = Object.assign({}, this.savedWindowState, {
title: app.name,
fullscreenable: true,
show: false, // don't start the window until it is ready and only if it isn't hidden
paintWhenInitiallyHidden: true, // we want it to start painting to get info from the webapp
minWidth: MINIMUM_WINDOW_WIDTH,
minHeight: MINIMUM_WINDOW_HEIGHT,
frame: !this.isFramelessWindow(),
fullscreen: this.shouldStartFullScreen(),
titleBarStyle: 'hidden' as const,
trafficLightPosition: {x: 12, y: 12},
backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
webPreferences: {
disableBlinkFeatures: 'Auxclick',
preload: getLocalPreload('internalAPI.js'),
spellcheck: typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker,
},
});
log.debug('main window options', windowOptions);
if (process.platform === 'linux') {
windowOptions.icon = path.join(path.resolve(app.getAppPath(), 'assets'), 'linux', 'app_icon.png');
}
this.win = new BrowserWindow(windowOptions);
if (!this.win) {
throw new Error('unable to create main window');
}
this.win.setMenuBarVisibility(false);
this.win.once('ready-to-show', () => {
if (!this.win) {
return;
}
this.win.webContents.zoomLevel = 0;
if (Config.hideOnStart === false) {
this.win.show();
if (this.savedWindowState?.maximized) {
this.win.maximize();
}
}
this.ready = true;
});
this.win.once('restore', () => {
this.win?.restore();
});
this.win.on('close', this.onClose);
this.win.on('closed', this.onClosed);
this.win.on('focus', this.onFocus);
this.win.on('blur', this.onBlur);
this.win.on('unresponsive', this.onUnresponsive);
this.win.on('maximize', this.onMaximize);
this.win.on('unmaximize', this.onUnmaximize);
this.win.on('restore', this.onRestore);
this.win.on('enter-full-screen', this.onEnterFullScreen);
this.win.on('leave-full-screen', this.onLeaveFullScreen);
this.win.on('will-resize', this.onWillResize);
this.win.on('resized', this.onResized);
if (process.platform === 'win32') {
// We don't want this on macOS, it's an alias of 'move'
// This is mostly a fix for Windows 11 snapping
this.win.on('moved', this.onResized);
}
if (process.platform === 'linux') {
this.win.on('resize', this.onResize);
}
this.win.webContents.on('before-input-event', this.onBeforeInputEvent);
// Should not allow the main window to generate a window of its own
this.win.webContents.setWindowOpenHandler(() => ({action: 'deny'}));
if (process.env.MM_DEBUG_SETTINGS) {
this.win.webContents.openDevTools({mode: 'detach'});
}
const contextMenu = new ContextMenu({}, this.win);
contextMenu.reload();
const localURL = getLocalURLString('index.html');
this.win.loadURL(localURL).catch(
(reason) => {
log.error('failed to load', reason);
});
this.emit(MAIN_WINDOW_CREATED);
};
get isReady() {
return this.ready;
}
get = () => {
return this.win;
};
show = () => {
if (this.win && this.isReady) {
// There's a bug on Windows in Electron where if the window is snapped, it will unsnap when you call show()
// See here: https://github.com/electron/electron/issues/25359
// So to make sure we always show the window on macOS/Linux (need for workspace switching)
// We make an exception here
if (process.platform === 'win32') {
if (this.win.isVisible()) {
this.win.focus();
} else {
this.win.show();
}
} else {
this.win.show();
}
} else {
this.init();
}
};
getBounds = (): Electron.Rectangle | undefined => {
if (!this.win) {
return undefined;
}
// 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.win.getSize();
return {...this.win.getContentBounds(), width: size[0], height: size[1]};
}
return this.win.getContentBounds();
};
focusThreeDotMenu = () => {
if (this.win) {
this.win.webContents.focus();
this.win.webContents.send(FOCUS_THREE_DOT_MENU);
}
};
sendToRenderer = (channel: string, ...args: unknown[]) => {
this.sendToRendererWithRetry(3, channel, ...args);
};
private sendToRendererWithRetry = (maxRetries: number, channel: string, ...args: unknown[]) => {
if (!this.win || !this.isReady) {
if (maxRetries > 0) {
log.debug(`Can't send ${channel}, will retry`);
setTimeout(() => {
this.sendToRendererWithRetry(maxRetries - 1, channel, ...args);
}, SECOND);
} else {
log.error(`Unable to send the message to the main window for message type ${channel}`);
}
return;
}
this.win.webContents.send(channel, ...args);
};
private shouldStartFullScreen = () => {
if (global?.args?.fullscreen !== undefined) {
return global.args.fullscreen;
}
if (Config.startInFullscreen) {
return Config.startInFullscreen;
}
return this.savedWindowState?.fullscreen || false;
};
private isFramelessWindow = () => {
return os.platform() === 'darwin' || (os.platform() === 'win32' && Utils.isVersionGreaterThanOrEqualTo(os.release(), '6.2'));
};
private getSavedWindowState = (): Partial<SavedWindowState> => {
try {
let savedWindowState: SavedWindowState | null = JSON.parse(fs.readFileSync(boundsInfoPath, 'utf-8'));
savedWindowState = Validator.validateBoundsInfo(savedWindowState);
if (!savedWindowState) {
throw new Error('Provided bounds info file does not validate, using defaults instead.');
}
const matchingScreen = screen.getDisplayMatching(savedWindowState);
log.debug('matching screen for main window', matchingScreen);
if (!(matchingScreen && (isInsideRectangle(matchingScreen.bounds, savedWindowState) || savedWindowState.maximized))) {
throw new Error('Provided bounds info are outside the bounds of your screen, using defaults instead.');
}
// We check for the monitor's scale factor when we want to set these bounds
// But only if it's not the primary monitor, otherwise it works fine as is
// This is due to a long running Electron issue: https://github.com/electron/electron/issues/10862
// This only needs to be done on Windows, it causes strange behaviour on Mac otherwise
const scaleFactor = process.platform === 'win32' && matchingScreen.id !== screen.getPrimaryDisplay().id ? matchingScreen.scaleFactor : 1;
return {
...savedWindowState,
width: Math.floor(savedWindowState.width / scaleFactor),
height: Math.floor(savedWindowState.height / scaleFactor),
};
} catch (e) {
log.error(e);
// Follow Electron's defaults, except for window dimensions which targets 1024x768 screen resolution.
return {
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
};
}
};
private saveWindowState = (file: string, window: BrowserWindow) => {
const windowState: SavedWindowState = {
...window.getBounds(),
maximized: window.isMaximized(),
fullscreen: window.isFullScreen(),
};
try {
log.debug('saving window state', windowState);
fs.writeFileSync(file, JSON.stringify(windowState));
} catch (e) {
// [Linux] error happens only when the window state is changed before the config dir is created.
log.error('failed to save window state', e);
}
};
private onBeforeInputEvent = (event: Event, input: Input) => {
// Register keyboard shortcuts
// Add Alt+Cmd+(Right|Left) as alternative to switch between servers
if (this.win && process.platform === 'darwin') {
if (input.alt && input.meta) {
if (input.key === 'ArrowRight') {
this.win.webContents.send(SELECT_NEXT_TAB);
}
if (input.key === 'ArrowLeft') {
this.win.webContents.send(SELECT_PREVIOUS_TAB);
}
}
}
};
private onFocus = () => {
// Only add shortcuts when window is in focus
if (process.platform === 'linux') {
globalShortcut.registerAll(ALT_MENU_KEYS, () => {
// do nothing because we want to supress the menu popping up
});
}
this.emit(MAIN_WINDOW_RESIZED, this.getBounds());
this.emit(MAIN_WINDOW_FOCUSED);
};
private onBlur = () => {
if (!this.win) {
return;
}
globalShortcut.unregisterAll();
this.emit(MAIN_WINDOW_RESIZED, this.getBounds());
ipcMain.emit(TOGGLE_SECURE_INPUT, null, false);
// App should save bounds when a window is closed.
// However, 'close' is not fired in some situations(shutdown, ctrl+c)
// because main process is killed in such situations.
// 'blur' event was effective in order to avoid this.
// Ideally, app should detect that OS is shutting down.
this.saveWindowState(boundsInfoPath, this.win);
};
private onClose = (event: Event) => {
log.debug('onClose');
if (!this.win) {
return;
}
if (global.willAppQuit) { // when [Ctrl|Cmd]+Q
this.saveWindowState(boundsInfoPath, this.win);
} else { // Minimize or hide the window for close button.
event.preventDefault();
function hideWindow(window: BrowserWindow) {
window.blur(); // To move focus to the next top-level window in Windows
window.hide();
}
switch (process.platform) {
case 'win32':
case 'linux':
if (Config.minimizeToTray) {
if (Config.alwaysMinimize) {
hideWindow(this.win);
} else {
dialog.showMessageBox(this.win, {
title: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.title', 'Minimize to Tray'),
message: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.message', '{appName} will continue to run in the system tray. This can be disabled in Settings.', {appName: app.name}),
type: 'info',
checkboxChecked: true,
checkboxLabel: localizeMessage('main.windows.mainWindow.minimizeToTray.dialog.checkboxLabel', 'Don\'t show again'),
}).then((result: {response: number; checkboxChecked: boolean}) => {
Config.set('alwaysMinimize', result.checkboxChecked);
hideWindow(this.win!);
});
}
} else if (Config.alwaysClose) {
app.quit();
} else {
dialog.showMessageBox(this.win, {
title: localizeMessage('main.windows.mainWindow.closeApp.dialog.title', 'Close Application'),
message: localizeMessage('main.windows.mainWindow.closeApp.dialog.message', 'Are you sure you want to quit?'),
detail: localizeMessage('main.windows.mainWindow.closeApp.dialog.detail', 'You will no longer receive notifications for messages. If you want to leave {appName} running in the system tray, you can enable this in Settings.', {appName: app.name}),
type: 'question',
buttons: [
localizeMessage('label.yes', 'Yes'),
localizeMessage('label.no', 'No'),
],
checkboxChecked: true,
checkboxLabel: localizeMessage('main.windows.mainWindow.closeApp.dialog.checkboxLabel', 'Don\'t ask again'),
}).then((result: {response: number; checkboxChecked: boolean}) => {
Config.set('alwaysClose', result.checkboxChecked && result.response === 0);
if (result.response === 0) {
app.quit();
}
});
}
break;
case 'darwin':
// need to leave fullscreen first, then hide the window
if (this.win.isFullScreen()) {
this.win.once('leave-full-screen', () => {
app.hide();
});
this.win.setFullScreen(false);
} else {
app.hide();
}
break;
default:
}
}
};
private onClosed = () => {
log.verbose('main window closed');
delete this.win;
this.ready = false;
};
private onUnresponsive = () => {
if (!this.win) {
throw new Error('BrowserWindow \'unresponsive\' event has been emitted');
}
dialog.showMessageBox(this.win, {
type: 'warning',
title: app.name,
message: localizeMessage('main.CriticalErrorHandler.unresponsive.dialog.message', 'The window is no longer responsive.\nDo you wait until the window becomes responsive again?'),
buttons: [
localizeMessage('label.no', 'No'),
localizeMessage('label.yes', 'Yes'),
],
defaultId: 0,
}).then(({response}) => {
if (response === 0) {
log.error('BrowserWindow \'unresponsive\' event has been emitted');
app.relaunch();
}
});
};
private emitBounds = (bounds?: Electron.Rectangle, force?: boolean) => {
// Workaround since the window bounds aren't updated immediately when the window is maximized for some reason
// We also don't want to force too many resizes so we throttle here
setTimeout(() => {
const newBounds = bounds ?? this.getBounds();
if (!force && newBounds?.height === this.lastEmittedBounds?.height && newBounds?.width === this.lastEmittedBounds?.width) {
return;
}
// For some reason on Linux I've seen the menu bar popup again
this.win?.setMenuBarVisibility(false);
this.emit(MAIN_WINDOW_RESIZED, newBounds);
this.lastEmittedBounds = newBounds;
}, 10);
};
private onMaximize = () => {
this.isMaximized = true;
this.win?.webContents.send(MAXIMIZE_CHANGE, true);
this.emitBounds();
};
private onUnmaximize = () => {
this.isMaximized = false;
this.win?.webContents.send(MAXIMIZE_CHANGE, false);
this.emitBounds();
};
private onRestore = () => {
if (this.isMaximized && !this.win?.isMaximized()) {
this.win?.maximize();
}
};
private onEnterFullScreen = () => {
this.win?.webContents.send('enter-full-screen');
this.emitBounds();
};
private onLeaveFullScreen = () => {
this.win?.webContents.send('leave-full-screen');
this.emitBounds();
};
/**
* Resizing code
*/
private onWillResize = (event: Event, newBounds: Electron.Rectangle) => {
log.silly('onWillResize', newBounds);
/**
* Fixes an issue on win11 related to Snap where the first "will-resize" event would return the same bounds
* causing the "resize" event to not fire
*/
const prevBounds = this.getBounds();
if (prevBounds?.height === newBounds.height && prevBounds?.width === newBounds.width) {
log.debug('prevented resize');
event.preventDefault();
return;
}
// Workaround for macOS to stop the window from sending too many resize calls to the BrowserViews
if (process.platform === 'darwin' && this.isResizing) {
log.debug('prevented resize');
event.preventDefault();
return;
}
this.isResizing = true;
this.emitBounds(newBounds);
};
private onResize = () => {
log.silly('onResize');
// Workaround for macOS to stop the window from sending too many resize calls to the BrowserViews
if (process.platform === 'darwin' && this.isResizing) {
return;
}
this.emitBounds();
};
private onResized = () => {
log.debug('onResized');
// Because this is the final window state after a resize, we force the size here
this.emitBounds(this.getBounds(), true);
this.isResizing = false;
};
private handleViewFinishedResizing = () => {
this.isResizing = false;
};
/**
* Server Manager update handler
*/
private handleUpdateConfig = () => {
this.win?.webContents.send(SERVERS_UPDATE);
};
/**
* App State update handler
*/
private handleUpdateAppStateForViewId = (viewId: string, isExpired: boolean, newMentions: number, newUnreads: boolean) => {
this.win?.webContents.send(UPDATE_MENTIONS, viewId, newMentions, newUnreads, isExpired);
};
}
const mainWindow = new MainWindow();
export default mainWindow;