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

* Migrate mainWindow to singleton

* Migrate settingsWindow to singleton

* PR feedback

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

View File

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