
Additionally, migrate all of the URL related helper functions from `src/utils/utils.js` to the new `src/utils/url.js` file and migrate tests. Issue MM-21835 Fixes #1206
1217 lines
38 KiB
JavaScript
1217 lines
38 KiB
JavaScript
// Copyright (c) 2015-2016 Yuya Ochiai
|
||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||
// See LICENSE.txt for license information.
|
||
|
||
import path from 'path';
|
||
import fs from 'fs';
|
||
|
||
import electron, {nativeTheme} from 'electron';
|
||
import isDev from 'electron-is-dev';
|
||
import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer';
|
||
import log from 'electron-log';
|
||
import 'airbnb-js-shims/target/es2015';
|
||
|
||
import {protocols} from '../electron-builder.json';
|
||
|
||
import AutoLauncher from './main/AutoLauncher';
|
||
import CriticalErrorHandler from './main/CriticalErrorHandler';
|
||
import upgradeAutoLaunch from './main/autoLaunch';
|
||
|
||
import RegistryConfig from './common/config/RegistryConfig';
|
||
import Config from './common/config';
|
||
import CertificateStore from './main/certificateStore';
|
||
import TrustedOriginsStore from './main/trustedOrigins';
|
||
import createMainWindow from './main/mainWindow';
|
||
import appMenu from './main/menus/app';
|
||
import trayMenu from './main/menus/tray';
|
||
import downloadURL from './main/downloadURL';
|
||
import allowProtocolDialog from './main/allowProtocolDialog';
|
||
import AppStateManager from './main/AppStateManager';
|
||
import initCookieManager from './main/cookieManager';
|
||
import SpellChecker from './main/SpellChecker';
|
||
import UserActivityMonitor from './main/UserActivityMonitor';
|
||
import Utils from './utils/util';
|
||
import urlUtils from './utils/url';
|
||
import parseArgs from './main/ParseArgs';
|
||
import {
|
||
REQUEST_PERMISSION_CHANNEL,
|
||
GRANT_PERMISSION_CHANNEL,
|
||
DENY_PERMISSION_CHANNEL,
|
||
BASIC_AUTH_PERMISSION
|
||
} from './common/permissions';
|
||
|
||
// pull out required electron components like this
|
||
// as not all components can be referenced before the app is ready
|
||
const {
|
||
app,
|
||
Menu,
|
||
Tray,
|
||
ipcMain,
|
||
nativeImage,
|
||
dialog,
|
||
systemPreferences,
|
||
session,
|
||
BrowserWindow,
|
||
} = electron;
|
||
const criticalErrorHandler = new CriticalErrorHandler();
|
||
const assetsDir = path.resolve(app.getAppPath(), 'assets');
|
||
const loginCallbackMap = new Map();
|
||
const certificateRequests = new Map();
|
||
const userActivityMonitor = new UserActivityMonitor();
|
||
const certificateErrorCallbacks = new Map();
|
||
|
||
// Keep a global reference of the window object, if you don't, the window will
|
||
// be closed automatically when the JavaScript object is garbage collected.
|
||
let mainWindow = null;
|
||
let popupWindow = null;
|
||
let certificateStore = null;
|
||
let trustedOriginsStore = null;
|
||
let spellChecker = null;
|
||
let deeplinkingUrl = null;
|
||
let scheme = null;
|
||
let appState = null;
|
||
let registryConfig = null;
|
||
let config = null;
|
||
let trayIcon = null;
|
||
let trayImages = null;
|
||
let altLastPressed = false;
|
||
|
||
// supported custom login paths (oath, saml)
|
||
const customLoginRegexPaths = [
|
||
/^\/oauth\/authorize$/i,
|
||
/^\/oauth\/deauthorize$/i,
|
||
/^\/oauth\/access_token$/i,
|
||
/^\/oauth\/[A-Za-z0-9]+\/complete$/i,
|
||
/^\/oauth\/[A-Za-z0-9]+\/login$/i,
|
||
/^\/oauth\/[A-Za-z0-9]+\/signup$/i,
|
||
/^\/api\/v3\/oauth\/[A-Za-z0-9]+\/complete$/i,
|
||
/^\/signup\/[A-Za-z0-9]+\/complete$/i,
|
||
/^\/login\/[A-Za-z0-9]+\/complete$/i,
|
||
/^\/login\/sso\/saml$/i,
|
||
];
|
||
|
||
// tracking in progress custom logins
|
||
const customLogins = {};
|
||
|
||
const nixUA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari/537.36';
|
||
|
||
const popupUserAgent = {
|
||
darwin: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari/537.36',
|
||
win32: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari/537.36',
|
||
aix: nixUA,
|
||
freebsd: nixUA,
|
||
linux: nixUA,
|
||
openbsd: nixUA,
|
||
sunos: nixUA,
|
||
};
|
||
|
||
/**
|
||
* Main entry point for the application, ensures that everything initializes in the proper order
|
||
*/
|
||
async function initialize() {
|
||
process.on('uncaughtException', criticalErrorHandler.processUncaughtExceptionHandler.bind(criticalErrorHandler));
|
||
global.willAppQuit = false;
|
||
|
||
// initialization that can run before the app is ready
|
||
initializeArgs();
|
||
initializeConfig();
|
||
initializeAppEventListeners();
|
||
initializeBeforeAppReady();
|
||
|
||
// wait for registry config data to load and app ready event
|
||
await Promise.all([
|
||
registryConfig.init(),
|
||
app.whenReady(),
|
||
]);
|
||
|
||
// no need to continue initializing if app is quitting
|
||
if (global.willAppQuit) {
|
||
return;
|
||
}
|
||
|
||
// initialization that should run once the app is ready
|
||
initializeInterCommunicationEventListeners();
|
||
initializeAfterAppReady();
|
||
initializeMainWindowListeners();
|
||
}
|
||
|
||
// attempt to initialize the application
|
||
try {
|
||
initialize();
|
||
} catch (error) {
|
||
throw new Error(`App initialization failed: ${error.toString()}`);
|
||
}
|
||
|
||
//
|
||
// initialization sub functions
|
||
//
|
||
|
||
function initializeArgs() {
|
||
global.args = parseArgs(process.argv.slice(1));
|
||
|
||
// output the application version via cli when requested (-v or --version)
|
||
if (global.args.version) {
|
||
process.stdout.write(`v.${app.getVersion()}\n`);
|
||
process.exit(0); // eslint-disable-line no-process-exit
|
||
}
|
||
|
||
global.isDev = isDev && !global.args.disableDevMode; // this doesn't seem to be right and isn't used as the single source of truth
|
||
|
||
if (global.args.dataDir) {
|
||
app.setPath('userData', path.resolve(global.args.dataDir));
|
||
}
|
||
}
|
||
|
||
function initializeConfig() {
|
||
registryConfig = new RegistryConfig();
|
||
config = new Config(app.getPath('userData') + '/config.json');
|
||
config.on('update', handleConfigUpdate);
|
||
config.on('synchronize', handleConfigSynchronize);
|
||
}
|
||
|
||
function initializeAppEventListeners() {
|
||
app.on('second-instance', handleAppSecondInstance);
|
||
app.on('window-all-closed', handleAppWindowAllClosed);
|
||
app.on('browser-window-created', handleAppBrowserWindowCreated);
|
||
app.on('activate', handleAppActivate);
|
||
app.on('before-quit', handleAppBeforeQuit);
|
||
app.on('certificate-error', handleAppCertificateError);
|
||
app.on('select-client-certificate', handleSelectCertificate);
|
||
app.on('gpu-process-crashed', handleAppGPUProcessCrashed);
|
||
app.on('login', handleAppLogin);
|
||
app.on('will-finish-launching', handleAppWillFinishLaunching);
|
||
app.on('web-contents-created', handleAppWebContentsCreated);
|
||
}
|
||
|
||
function initializeBeforeAppReady() {
|
||
certificateStore = CertificateStore.load(path.resolve(app.getPath('userData'), 'certificate.json'));
|
||
trustedOriginsStore = new TrustedOriginsStore(path.resolve(app.getPath('userData'), 'trustedOrigins.json'));
|
||
trustedOriginsStore.load();
|
||
|
||
// prevent using a different working directory, which happens on windows running after installation.
|
||
const expectedPath = path.dirname(process.execPath);
|
||
if (process.cwd() !== expectedPath && !isDev) {
|
||
log.warn(`Current working directory is ${process.cwd()}, changing into ${expectedPath}`);
|
||
process.chdir(expectedPath);
|
||
}
|
||
|
||
// can only call this before the app is ready
|
||
if (config.enableHardwareAcceleration === false) {
|
||
app.disableHardwareAcceleration();
|
||
}
|
||
|
||
trayImages = getTrayImages();
|
||
|
||
// If there is already an instance, quit this one
|
||
const gotTheLock = app.requestSingleInstanceLock();
|
||
if (!gotTheLock) {
|
||
app.exit();
|
||
global.willAppQuit = true;
|
||
}
|
||
|
||
if (!config.spellCheckerLocale) {
|
||
config.set('spellCheckerLocale', SpellChecker.getSpellCheckerLocale(app.getLocale()));
|
||
}
|
||
|
||
allowProtocolDialog.init(mainWindow);
|
||
|
||
if (isDev) {
|
||
console.log('In development mode, deeplinking is disabled');
|
||
} else if (protocols && protocols[0] && protocols[0].schemes && protocols[0].schemes[0]) {
|
||
scheme = protocols[0].schemes[0];
|
||
app.setAsDefaultProtocolClient(scheme);
|
||
}
|
||
}
|
||
|
||
function initializeInterCommunicationEventListeners() {
|
||
ipcMain.on('reload-config', handleReloadConfig);
|
||
ipcMain.on('login-credentials', handleLoginCredentialsEvent);
|
||
ipcMain.on('login-cancel', handleCancelLoginEvent);
|
||
ipcMain.on('download-url', handleDownloadURLEvent);
|
||
ipcMain.on('notified', handleNotifiedEvent);
|
||
ipcMain.on('update-title', handleUpdateTitleEvent);
|
||
ipcMain.on('update-menu', handleUpdateMenuEvent);
|
||
ipcMain.on('update-dict', handleUpdateDictionaryEvent);
|
||
ipcMain.on('checkspell', handleCheckSpellingEvent);
|
||
ipcMain.on('get-spelling-suggestions', handleGetSpellingSuggestionsEvent);
|
||
ipcMain.on('get-spellchecker-locale', handleGetSpellcheckerLocaleEvent);
|
||
ipcMain.on('reply-on-spellchecker-is-ready', handleReplyOnSpellcheckerIsReadyEvent);
|
||
ipcMain.on('selected-client-certificate', handleSelectedCertificate);
|
||
ipcMain.on(GRANT_PERMISSION_CHANNEL, handlePermissionGranted);
|
||
ipcMain.on(DENY_PERMISSION_CHANNEL, handlePermissionDenied);
|
||
|
||
if (shouldShowTrayIcon()) {
|
||
ipcMain.on('update-unread', handleUpdateUnreadEvent);
|
||
}
|
||
if (process.platform !== 'darwin') {
|
||
ipcMain.on('open-app-menu', handleOpenAppMenu);
|
||
}
|
||
}
|
||
|
||
function initializeMainWindowListeners() {
|
||
mainWindow.on('closed', handleMainWindowClosed);
|
||
mainWindow.on('unresponsive', criticalErrorHandler.windowUnresponsiveHandler.bind(criticalErrorHandler));
|
||
mainWindow.webContents.on('crashed', handleMainWindowWebContentsCrashed);
|
||
}
|
||
|
||
//
|
||
// config event handlers
|
||
//
|
||
|
||
function handleConfigUpdate(configData) {
|
||
if (process.platform === 'win32' || process.platform === 'linux') {
|
||
const appLauncher = new AutoLauncher();
|
||
const autoStartTask = config.autostart ? appLauncher.enable() : appLauncher.disable();
|
||
autoStartTask.then(() => {
|
||
console.log('config.autostart has been configured:', config.autostart);
|
||
}).catch((err) => {
|
||
console.log('error:', err);
|
||
});
|
||
}
|
||
|
||
ipcMain.emit('update-menu', true, configData);
|
||
}
|
||
|
||
function handleConfigSynchronize() {
|
||
if (mainWindow) {
|
||
mainWindow.webContents.send('reload-config');
|
||
}
|
||
}
|
||
|
||
function handleReloadConfig() {
|
||
config.reload();
|
||
}
|
||
|
||
//
|
||
// app event handlers
|
||
//
|
||
|
||
// activate first app instance, subsequent instances will quit themselves
|
||
function handleAppSecondInstance(event, argv) {
|
||
// Protocol handler for win32
|
||
// argv: An array of the second instance’s (command line / deep linked) arguments
|
||
if (process.platform === 'win32') {
|
||
deeplinkingUrl = getDeeplinkingURL(argv);
|
||
if (deeplinkingUrl) {
|
||
mainWindow.webContents.send('protocol-deeplink', deeplinkingUrl);
|
||
}
|
||
}
|
||
|
||
// Someone tried to run a second instance, we should focus our window.
|
||
if (mainWindow) {
|
||
if (mainWindow.isMinimized()) {
|
||
mainWindow.restore();
|
||
} else {
|
||
mainWindow.show();
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleAppWindowAllClosed() {
|
||
// On OS X it is common for applications and their menu bar
|
||
// to stay active until the user quits explicitly with Cmd + Q
|
||
if (process.platform !== 'darwin') {
|
||
app.quit();
|
||
}
|
||
}
|
||
|
||
function handleAppBrowserWindowCreated(error, newWindow) {
|
||
// Screen cannot be required before app is ready
|
||
resizeScreen(electron.screen, newWindow);
|
||
}
|
||
|
||
function handleAppActivate() {
|
||
mainWindow.show();
|
||
}
|
||
|
||
function handleAppBeforeQuit() {
|
||
// Make sure tray icon gets removed if the user exits via CTRL-Q
|
||
if (trayIcon && process.platform === 'win32') {
|
||
trayIcon.destroy();
|
||
}
|
||
global.willAppQuit = true;
|
||
}
|
||
|
||
function handleSelectCertificate(event, webContents, url, list, callback) {
|
||
if (list.length > 1) {
|
||
event.preventDefault(); // prevent the app from getting the first certificate available
|
||
// store callback so it can be called with selected certificate
|
||
certificateRequests.set(url, callback);
|
||
|
||
// open modal for selecting certificate
|
||
mainWindow.webContents.send('select-user-certificate', url, list);
|
||
} else {
|
||
log.info(`There were ${list.length} candidate certificates. Skipping certificate selection`);
|
||
}
|
||
}
|
||
|
||
function handleSelectedCertificate(event, server, cert) {
|
||
const callback = certificateRequests.get(server);
|
||
if (!callback) {
|
||
log.error(`there was no callback associated with: ${server}`);
|
||
return;
|
||
}
|
||
if (typeof cert === 'undefined') {
|
||
log.info('user canceled certificate selection');
|
||
} else {
|
||
try {
|
||
callback(cert);
|
||
} catch (e) {
|
||
log.error(`There was a problem using the selected certificate: ${e}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleAppCertificateError(event, webContents, url, error, certificate, callback) {
|
||
const parsedURL = urlUtils.parseURL(url);
|
||
if (!parsedURL) {
|
||
return;
|
||
}
|
||
const origin = parsedURL.origin;
|
||
if (certificateStore.isTrusted(origin, certificate)) {
|
||
event.preventDefault();
|
||
callback(true);
|
||
} else {
|
||
// update the callback
|
||
const errorID = `${origin}:${error}`;
|
||
|
||
// if we are already showing that error, don't add more dialogs
|
||
if (certificateErrorCallbacks.has(errorID)) {
|
||
log.warn(`Ignoring already shown dialog for ${errorID}`);
|
||
certificateErrorCallbacks.set(errorID, callback);
|
||
return;
|
||
}
|
||
const extraDetail = certificateStore.isExisting(origin) ? 'Certificate is different from previous one.\n\n' : '';
|
||
const detail = `${extraDetail}origin: ${origin}\nError: ${error}`;
|
||
|
||
certificateErrorCallbacks.set(errorID, callback);
|
||
dialog.showMessageBox(mainWindow, {
|
||
title: 'Certificate Error',
|
||
message: 'There is a configuration issue with this Mattermost server, or someone is trying to intercept your connection. You also may need to sign into the Wi-Fi you are connected to using your web browser.',
|
||
type: 'error',
|
||
detail,
|
||
buttons: ['More Details', 'Cancel Connection'],
|
||
cancelId: 1,
|
||
}).then(
|
||
({response}) => {
|
||
if (response === 0) {
|
||
return dialog.showMessageBox(mainWindow, {
|
||
title: 'Certificate Not Trusted',
|
||
message: `Certificate from "${certificate.issuerName}" is not trusted.`,
|
||
detail: extraDetail,
|
||
type: 'error',
|
||
buttons: ['Trust Insecure Certificate', 'Cancel Connection'],
|
||
cancelId: 1,
|
||
});
|
||
}
|
||
return {response};
|
||
}).then(
|
||
({response: responseTwo}) => {
|
||
if (responseTwo === 0) {
|
||
certificateStore.add(origin, certificate);
|
||
certificateStore.save();
|
||
certificateErrorCallbacks.get(errorID)(true);
|
||
certificateErrorCallbacks.delete(errorID);
|
||
webContents.loadURL(url);
|
||
} else {
|
||
certificateErrorCallbacks.get(errorID)(false);
|
||
certificateErrorCallbacks.delete(errorID);
|
||
}
|
||
}).catch(
|
||
(dialogError) => {
|
||
log.error(`There was an error with the Certificate Error dialog: ${dialogError}`);
|
||
certificateErrorCallbacks.delete(errorID);
|
||
});
|
||
}
|
||
}
|
||
|
||
function handleAppGPUProcessCrashed(event, killed) {
|
||
console.log(`The GPU process has crashed (killed = ${killed})`);
|
||
}
|
||
|
||
function handleAppLogin(event, webContents, request, authInfo, callback) {
|
||
event.preventDefault();
|
||
const parsedURL = urlUtils.parseURL(request.url);
|
||
const server = urlUtils.getServer(parsedURL, config.teams);
|
||
|
||
loginCallbackMap.set(request.url, typeof callback === 'undefined' ? null : callback); // if callback is undefined set it to null instead so we know we have set it up with no value
|
||
if (isTrustedURL(request.url) || isCustomLoginURL(parsedURL, server) || trustedOriginsStore.checkPermission(request.url, BASIC_AUTH_PERMISSION)) {
|
||
mainWindow.webContents.send('login-request', request, authInfo);
|
||
} else {
|
||
mainWindow.webContents.send(REQUEST_PERMISSION_CHANNEL, request, authInfo, BASIC_AUTH_PERMISSION);
|
||
}
|
||
}
|
||
|
||
function handlePermissionGranted(event, url, permission) {
|
||
trustedOriginsStore.addPermission(url, permission);
|
||
trustedOriginsStore.save();
|
||
}
|
||
|
||
function handlePermissionDenied(event, url, permission, reason) {
|
||
log.warn(`Permission request denied: ${reason}`);
|
||
}
|
||
|
||
function handleAppWillFinishLaunching() {
|
||
// Protocol handler for osx
|
||
app.on('open-url', (event, url) => {
|
||
event.preventDefault();
|
||
deeplinkingUrl = getDeeplinkingURL([url]);
|
||
if (app.isReady()) {
|
||
function openDeepLink() {
|
||
try {
|
||
if (deeplinkingUrl) {
|
||
mainWindow.webContents.send('protocol-deeplink', deeplinkingUrl);
|
||
mainWindow.show();
|
||
}
|
||
} catch (err) {
|
||
setTimeout(openDeepLink, 1000);
|
||
}
|
||
}
|
||
|
||
openDeepLink();
|
||
}
|
||
});
|
||
}
|
||
|
||
function handleAppWebContentsCreated(dc, contents) {
|
||
// initialize custom login tracking
|
||
customLogins[contents.id] = {
|
||
inProgress: false,
|
||
};
|
||
|
||
contents.on('will-attach-webview', (event, webPreferences) => {
|
||
webPreferences.nodeIntegration = false;
|
||
webPreferences.contextIsolation = true;
|
||
});
|
||
|
||
contents.on('will-navigate', (event, url) => {
|
||
const contentID = event.sender.id;
|
||
const parsedURL = urlUtils.parseURL(url);
|
||
const server = urlUtils.getServer(parsedURL, config.teams);
|
||
|
||
if ((server !== null && (urlUtils.isTeamUrl(server.url, parsedURL) || urlUtils.isAdminUrl(server.url, parsedURL))) ||
|
||
isTrustedPopupWindow(event.sender)) {
|
||
return;
|
||
}
|
||
|
||
if (isCustomLoginURL(parsedURL, server)) {
|
||
return;
|
||
}
|
||
if (parsedURL.protocol === 'mailto:') {
|
||
return;
|
||
}
|
||
if (customLogins[contentID].inProgress) {
|
||
return;
|
||
}
|
||
|
||
log.info(`Prevented desktop from navigating to: ${url}`);
|
||
event.preventDefault();
|
||
});
|
||
|
||
// handle custom login requests (oath, saml):
|
||
// 1. are we navigating to a supported local custom login path from the `/login` page?
|
||
// - indicate custom login is in progress
|
||
// 2. are we finished with the custom login process?
|
||
// - indicate custom login is NOT in progress
|
||
contents.on('did-start-navigation', (event, url) => {
|
||
const contentID = event.sender.id;
|
||
const parsedURL = urlUtils.parseURL(url);
|
||
const server = urlUtils.getServer(parsedURL, config.teams);
|
||
|
||
if (!isTrustedURL(parsedURL)) {
|
||
return;
|
||
}
|
||
|
||
if (isCustomLoginURL(parsedURL, server)) {
|
||
customLogins[contentID].inProgress = true;
|
||
} else if (customLogins[contentID].inProgress) {
|
||
customLogins[contentID].inProgress = false;
|
||
}
|
||
});
|
||
|
||
contents.on('new-window', (event, url) => {
|
||
event.preventDefault();
|
||
|
||
const parsedURL = urlUtils.parseURL(url);
|
||
const server = urlUtils.getServer(parsedURL, config.teams);
|
||
|
||
if (!server) {
|
||
log.info(`Untrusted popup window blocked: ${url}`);
|
||
return;
|
||
}
|
||
if (urlUtils.isTeamUrl(server.url, parsedURL, true)) {
|
||
log.info(`${url} is a known team, preventing to open a new window`);
|
||
return;
|
||
}
|
||
if (urlUtils.isAdminUrl(server.url, parsedURL)) {
|
||
log.info(`${url} is an admin console page, preventing to open a new window`);
|
||
return;
|
||
}
|
||
if (popupWindow && !popupWindow.closed && popupWindow.getURL() === url) {
|
||
log.info(`Popup window already open at provided url: ${url}`);
|
||
return;
|
||
}
|
||
if (urlUtils.isPluginUrl(server.url, parsedURL) || urlUtils.isManagedResource(server.url, parsedURL)) {
|
||
if (!popupWindow || popupWindow.closed) {
|
||
popupWindow = new BrowserWindow({
|
||
backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
|
||
parent: mainWindow,
|
||
show: false,
|
||
center: true,
|
||
webPreferences: {
|
||
nodeIntegration: false,
|
||
contextIsolation: true,
|
||
},
|
||
});
|
||
popupWindow.once('ready-to-show', () => {
|
||
popupWindow.show();
|
||
});
|
||
popupWindow.once('closed', () => {
|
||
popupWindow = null;
|
||
});
|
||
}
|
||
|
||
if (urlUtils.isManagedResource(server.url, parsedURL)) {
|
||
popupWindow.loadURL(url);
|
||
} else {
|
||
// currently changing the userAgent for popup windows to allow plugins to go through google's oAuth
|
||
// should be removed once a proper oAuth2 implementation is setup.
|
||
popupWindow.loadURL(url, {
|
||
userAgent: popupUserAgent[process.platform],
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// implemented to temporarily help solve for https://community-daily.mattermost.com/core/pl/b95bi44r4bbnueqzjjxsi46qiw
|
||
contents.on('before-input-event', (event, input) => {
|
||
if (input.key === 'Alt' && input.type === 'keyUp' && altLastPressed) {
|
||
altLastPressed = false;
|
||
mainWindow.webContents.send('focus-three-dot-menu');
|
||
return;
|
||
}
|
||
|
||
// Hack to detect keyPress so that alt+<key> combinations don't default back to the 3-dot menu
|
||
if (input.key === 'Alt' && input.type === 'keyDown') {
|
||
altLastPressed = true;
|
||
} else {
|
||
altLastPressed = false;
|
||
}
|
||
|
||
if (!input.shift && !input.control && !input.alt && !input.meta) {
|
||
// hacky fix for https://mattermost.atlassian.net/browse/MM-19226
|
||
if ((input.key === 'Escape' || input.key === 'f') && input.type === 'keyDown') {
|
||
// only do this when in fullscreen on a mac
|
||
if (mainWindow.isFullScreen() && process.platform === 'darwin') {
|
||
mainWindow.webContents.send('exit-fullscreen');
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ((process.platform === 'darwin' && !input.meta) || (process.platform !== 'darwin' && !input.control)) {
|
||
return;
|
||
}
|
||
|
||
// handle certain keyboard shortcuts manually
|
||
switch (input.key) { // eslint-disable-line padded-blocks
|
||
|
||
// Manually handle zoom-in/out/reset keyboard shortcuts
|
||
// - temporary fix for https://mattermost.atlassian.net/browse/MM-19031 and https://mattermost.atlassian.net/browse/MM-19032
|
||
case '-':
|
||
mainWindow.webContents.send('zoom-out');
|
||
break;
|
||
case '=':
|
||
mainWindow.webContents.send('zoom-in');
|
||
break;
|
||
case '0':
|
||
mainWindow.webContents.send('zoom-reset');
|
||
break;
|
||
|
||
// Manually handle undo/redo keyboard shortcuts
|
||
// - temporary fix for https://mattermost.atlassian.net/browse/MM-19198
|
||
case 'z':
|
||
if (input.shift) {
|
||
mainWindow.webContents.send('redo');
|
||
} else {
|
||
mainWindow.webContents.send('undo');
|
||
}
|
||
break;
|
||
|
||
// Manually handle copy/cut/paste keyboard shortcuts
|
||
case 'c':
|
||
mainWindow.webContents.send('copy');
|
||
break;
|
||
case 'x':
|
||
mainWindow.webContents.send('cut');
|
||
break;
|
||
case 'v':
|
||
if (input.shift) {
|
||
mainWindow.webContents.send('paste-and-match');
|
||
} else {
|
||
mainWindow.webContents.send('paste');
|
||
}
|
||
break;
|
||
default:
|
||
// allows the input event to proceed if not handled by a case above
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
});
|
||
}
|
||
|
||
function initializeAfterAppReady() {
|
||
app.setAppUserModelId('Mattermost.Desktop'); // Use explicit AppUserModelID
|
||
|
||
const appStateJson = path.join(app.getPath('userData'), 'app-state.json');
|
||
appState = new AppStateManager(appStateJson);
|
||
if (wasUpdated(appState.lastAppVersion)) {
|
||
clearAppCache();
|
||
}
|
||
appState.lastAppVersion = app.getVersion();
|
||
|
||
if (!global.isDev) {
|
||
upgradeAutoLaunch();
|
||
}
|
||
|
||
if (global.isDev) {
|
||
installExtension(REACT_DEVELOPER_TOOLS).
|
||
then((name) => console.log(`Added Extension: ${name}`)).
|
||
catch((err) => console.log('An error occurred: ', err));
|
||
}
|
||
|
||
// Workaround for MM-22193
|
||
// From this post: https://github.com/electron/electron/issues/19468#issuecomment-549593139
|
||
// Electron 6 has a bug that affects users on Windows 10 using dark mode, causing the app to hang
|
||
// This workaround deletes a file that stops that from happening
|
||
if (process.platform === 'win32') {
|
||
const appUserDataPath = app.getPath('userData');
|
||
const devToolsExtensionsPath = path.join(appUserDataPath, 'DevTools Extensions');
|
||
try {
|
||
fs.unlinkSync(devToolsExtensionsPath);
|
||
} catch (_) {
|
||
// don't complain if the file doesn't exist
|
||
}
|
||
}
|
||
|
||
// Protocol handler for win32
|
||
if (process.platform === 'win32') {
|
||
const args = process.argv.slice(1);
|
||
if (Array.isArray(args) && args.length > 0) {
|
||
deeplinkingUrl = getDeeplinkingURL(args);
|
||
}
|
||
}
|
||
|
||
initCookieManager(session.defaultSession);
|
||
|
||
mainWindow = createMainWindow(config.data, {
|
||
trayIconShown: process.platform === 'win32' || config.showTrayIcon,
|
||
linuxAppIcon: path.join(assetsDir, 'appicon.png'),
|
||
deeplinkingUrl,
|
||
});
|
||
|
||
criticalErrorHandler.setMainWindow(mainWindow);
|
||
|
||
config.setRegistryConfigData(registryConfig.data);
|
||
mainWindow.registryConfigData = registryConfig.data;
|
||
|
||
// listen for status updates and pass on to renderer
|
||
userActivityMonitor.on('status', (status) => {
|
||
mainWindow.webContents.send('user-activity-update', status);
|
||
});
|
||
|
||
// start monitoring user activity (needs to be started after the app is ready)
|
||
userActivityMonitor.startMonitoring();
|
||
|
||
if (shouldShowTrayIcon()) {
|
||
// set up tray icon
|
||
trayIcon = new Tray(trayImages.normal);
|
||
if (process.platform === 'darwin') {
|
||
trayIcon.setPressedImage(trayImages.clicked.normal);
|
||
systemPreferences.subscribeNotification('AppleInterfaceThemeChangedNotification', () => {
|
||
switchMenuIconImages(trayImages, nativeTheme.shouldUseDarkColors);
|
||
trayIcon.setImage(trayImages.normal);
|
||
});
|
||
}
|
||
|
||
trayIcon.setToolTip(app.name);
|
||
trayIcon.on('click', () => {
|
||
if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
|
||
if (mainWindow.isMinimized()) {
|
||
mainWindow.restore();
|
||
} else {
|
||
mainWindow.show();
|
||
}
|
||
mainWindow.focus();
|
||
if (process.platform === 'darwin') {
|
||
app.dock.show();
|
||
}
|
||
} else {
|
||
mainWindow.focus();
|
||
}
|
||
});
|
||
|
||
trayIcon.on('right-click', () => {
|
||
trayIcon.popUpContextMenu();
|
||
});
|
||
trayIcon.on('balloon-click', () => {
|
||
if (process.platform === 'win32' || process.platform === 'darwin') {
|
||
if (mainWindow.isMinimized()) {
|
||
mainWindow.restore();
|
||
} else {
|
||
mainWindow.show();
|
||
}
|
||
}
|
||
|
||
if (process.platform === 'darwin') {
|
||
app.dock.show();
|
||
}
|
||
|
||
mainWindow.focus();
|
||
});
|
||
}
|
||
|
||
session.defaultSession.on('will-download', (event, item, webContents) => {
|
||
const filename = item.getFilename();
|
||
const fileElements = filename.split('.');
|
||
const filters = [];
|
||
if (fileElements.length > 1) {
|
||
filters.push({
|
||
name: `${fileElements[fileElements.length - 1]} files`,
|
||
extensions: [fileElements[fileElements.length - 1]],
|
||
});
|
||
}
|
||
|
||
// add default filter
|
||
filters.push({
|
||
name: 'All files',
|
||
extensions: ['*'],
|
||
});
|
||
item.setSaveDialogOptions({
|
||
title: filename,
|
||
defaultPath: path.resolve(config.combinedData.downloadLocation, filename),
|
||
filters,
|
||
});
|
||
|
||
item.on('done', (doneEvent, state) => {
|
||
if (state === 'completed') {
|
||
mainWindow.webContents.send('download-complete', {
|
||
fileName: filename,
|
||
path: item.savePath,
|
||
serverInfo: urlUtils.getServer(webContents.getURL(), config.teams),
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
ipcMain.emit('update-menu', true, config.data);
|
||
|
||
ipcMain.emit('update-dict');
|
||
|
||
// supported permission types
|
||
const supportedPermissionTypes = [
|
||
'media',
|
||
'geolocation',
|
||
'notifications',
|
||
'fullscreen',
|
||
'openExternal',
|
||
];
|
||
|
||
// handle permission requests
|
||
// - approve if a supported permission type and the request comes from the renderer or one of the defined servers
|
||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||
// is the requested permission type supported?
|
||
if (!supportedPermissionTypes.includes(permission)) {
|
||
callback(false);
|
||
return;
|
||
}
|
||
|
||
// is the request coming from the renderer?
|
||
if (webContents.id === mainWindow.webContents.id) {
|
||
callback(true);
|
||
return;
|
||
}
|
||
|
||
const requestingURL = webContents.getURL();
|
||
|
||
// is the requesting url trusted?
|
||
callback(isTrustedURL(requestingURL));
|
||
});
|
||
}
|
||
|
||
//
|
||
// ipc communication event handlers
|
||
//
|
||
|
||
function handleLoginCredentialsEvent(event, request, user, password) {
|
||
const callback = loginCallbackMap.get(request.url);
|
||
if (typeof callback === 'undefined') {
|
||
log.error(`Failed to retrieve login callback for ${request.url}`);
|
||
return;
|
||
}
|
||
if (callback != null) {
|
||
callback(user, password);
|
||
}
|
||
loginCallbackMap.delete(request.url);
|
||
}
|
||
|
||
function handleCancelLoginEvent(event, request) {
|
||
log.info(`Cancelling request for ${request ? request.url : 'unknown'}`);
|
||
handleLoginCredentialsEvent(event, request); // we use undefined to cancel the request
|
||
}
|
||
|
||
function handleDownloadURLEvent(event, url) {
|
||
downloadURL(mainWindow, url, (err) => {
|
||
if (err) {
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'error',
|
||
message: err.toString(),
|
||
});
|
||
log.error(err);
|
||
}
|
||
});
|
||
}
|
||
|
||
function handleNotifiedEvent() {
|
||
if (process.platform === 'win32' || process.platform === 'linux') {
|
||
if (config.notifications.flashWindow === 2) {
|
||
mainWindow.flashFrame(true);
|
||
}
|
||
}
|
||
|
||
if (process.platform === 'darwin' && config.notifications.bounceIcon) {
|
||
app.dock.bounce(config.notifications.bounceIconType);
|
||
}
|
||
}
|
||
|
||
function handleUpdateTitleEvent(event, arg) {
|
||
mainWindow.setTitle(arg.title);
|
||
}
|
||
|
||
function handleUpdateUnreadEvent(event, arg) {
|
||
if (process.platform === 'win32') {
|
||
const overlay = arg.overlayDataURL ? nativeImage.createFromDataURL(arg.overlayDataURL) : null;
|
||
if (mainWindow) {
|
||
mainWindow.setOverlayIcon(overlay, arg.description);
|
||
}
|
||
}
|
||
|
||
if (trayIcon && !trayIcon.isDestroyed()) {
|
||
if (arg.sessionExpired) {
|
||
// reuse the mention icon when the session is expired
|
||
trayIcon.setImage(trayImages.mention);
|
||
if (process.platform === 'darwin') {
|
||
trayIcon.setPressedImage(trayImages.clicked.mention);
|
||
}
|
||
trayIcon.setToolTip('Session Expired: Please sign in to continue receiving notifications.');
|
||
} else if (arg.mentionCount > 0) {
|
||
trayIcon.setImage(trayImages.mention);
|
||
if (process.platform === 'darwin') {
|
||
trayIcon.setPressedImage(trayImages.clicked.mention);
|
||
}
|
||
trayIcon.setToolTip(arg.mentionCount + ' unread mentions');
|
||
} else if (arg.unreadCount > 0) {
|
||
trayIcon.setImage(trayImages.unread);
|
||
if (process.platform === 'darwin') {
|
||
trayIcon.setPressedImage(trayImages.clicked.unread);
|
||
}
|
||
trayIcon.setToolTip(arg.unreadCount + ' unread channels');
|
||
} else {
|
||
trayIcon.setImage(trayImages.normal);
|
||
if (process.platform === 'darwin') {
|
||
trayIcon.setPressedImage(trayImages.clicked.normal);
|
||
}
|
||
trayIcon.setToolTip(app.name);
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleOpenAppMenu() {
|
||
Menu.getApplicationMenu().popup({
|
||
x: 18,
|
||
y: 18,
|
||
});
|
||
}
|
||
|
||
function handleCloseAppMenu(event) {
|
||
mainWindow.webContents.send('focus-on-webview', event);
|
||
}
|
||
|
||
function handleUpdateMenuEvent(event, configData) {
|
||
const aMenu = appMenu.createMenu(mainWindow, configData, global.isDev);
|
||
Menu.setApplicationMenu(aMenu);
|
||
aMenu.addListener('menu-will-close', handleCloseAppMenu);
|
||
|
||
// set up context menu for tray icon
|
||
if (shouldShowTrayIcon()) {
|
||
const tMenu = trayMenu.createMenu(mainWindow, configData, global.isDev);
|
||
if (process.platform === 'darwin' || process.platform === 'linux') {
|
||
// store the information, if the tray was initialized, for checking in the settings, if the application
|
||
// was restarted after setting "Show icon on menu bar"
|
||
if (trayIcon) {
|
||
trayIcon.setContextMenu(tMenu);
|
||
mainWindow.trayWasVisible = true;
|
||
} else {
|
||
mainWindow.trayWasVisible = false;
|
||
}
|
||
} else if (trayIcon) {
|
||
trayIcon.setContextMenu(tMenu);
|
||
}
|
||
}
|
||
}
|
||
|
||
// localeSelected might be null, if that's the case, use config's locale
|
||
function handleUpdateDictionaryEvent(_, localeSelected) {
|
||
if (config.useSpellChecker) {
|
||
const locale = localeSelected || config.spellCheckerLocale;
|
||
try {
|
||
spellChecker = new SpellChecker(
|
||
locale,
|
||
path.resolve(app.getAppPath(), 'node_modules/simple-spellchecker/dict'),
|
||
(err) => {
|
||
if (err) {
|
||
log.error(err);
|
||
}
|
||
});
|
||
} catch (e) {
|
||
log.error('couldn\'t load a spellchecker for locale');
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleCheckSpellingEvent(event, word) {
|
||
let res = null;
|
||
if (config.useSpellChecker && spellChecker.isReady() && word !== null) {
|
||
res = spellChecker.spellCheck(word);
|
||
}
|
||
event.returnValue = res;
|
||
}
|
||
|
||
function handleGetSpellingSuggestionsEvent(event, word) {
|
||
if (config.useSpellChecker && spellChecker.isReady() && word !== null) {
|
||
event.returnValue = spellChecker.getSuggestions(word, 10);
|
||
} else {
|
||
event.returnValue = [];
|
||
}
|
||
}
|
||
|
||
function handleGetSpellcheckerLocaleEvent(event) {
|
||
event.returnValue = config.spellCheckerLocale;
|
||
}
|
||
|
||
function handleReplyOnSpellcheckerIsReadyEvent(event) {
|
||
if (!spellChecker) {
|
||
return;
|
||
}
|
||
|
||
if (spellChecker.isReady()) {
|
||
event.sender.send('spellchecker-is-ready');
|
||
return;
|
||
}
|
||
spellChecker.once('ready', () => {
|
||
event.sender.send('spellchecker-is-ready');
|
||
});
|
||
}
|
||
|
||
//
|
||
// mainWindow event handlers
|
||
//
|
||
|
||
function handleMainWindowClosed() {
|
||
// Dereference the window object, usually you would store windows
|
||
// in an array if your app supports multi windows, this is the time
|
||
// when you should delete the corresponding element.
|
||
mainWindow = null;
|
||
}
|
||
|
||
function handleMainWindowWebContentsCrashed() {
|
||
throw new Error('webContents \'crashed\' event has been emitted');
|
||
}
|
||
|
||
//
|
||
// helper functions
|
||
//
|
||
|
||
function isTrustedURL(url) {
|
||
const parsedURL = urlUtils.parseURL(url);
|
||
if (!parsedURL) {
|
||
return false;
|
||
}
|
||
return urlUtils.getServer(parsedURL, config.teams) !== null;
|
||
}
|
||
|
||
function isTrustedPopupWindow(webContents) {
|
||
if (!webContents) {
|
||
return false;
|
||
}
|
||
if (!popupWindow) {
|
||
return false;
|
||
}
|
||
return BrowserWindow.fromWebContents(webContents) === popupWindow;
|
||
}
|
||
|
||
function isCustomLoginURL(url, server) {
|
||
const subpath = (server === null || typeof server === 'undefined') ? '' : server.url.pathname;
|
||
const parsedURL = urlUtils.parseURL(url);
|
||
if (!parsedURL) {
|
||
return false;
|
||
}
|
||
if (!isTrustedURL(parsedURL)) {
|
||
return false;
|
||
}
|
||
const urlPath = parsedURL.pathname;
|
||
if ((subpath !== '' || subpath !== '/') && urlPath.startsWith(subpath)) {
|
||
const replacement = subpath.endsWith('/') ? '/' : '';
|
||
const replacedPath = urlPath.replace(subpath, replacement);
|
||
for (const regexPath of customLoginRegexPaths) {
|
||
if (replacedPath.match(regexPath)) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// if there is no subpath, or we are adding the team and got redirected to the real server it'll be caught here
|
||
for (const regexPath of customLoginRegexPaths) {
|
||
if (urlPath.match(regexPath)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function getTrayImages() {
|
||
switch (process.platform) {
|
||
case 'win32':
|
||
return {
|
||
normal: nativeImage.createFromPath(path.resolve(assetsDir, 'windows/tray.ico')),
|
||
unread: nativeImage.createFromPath(path.resolve(assetsDir, 'windows/tray_unread.ico')),
|
||
mention: nativeImage.createFromPath(path.resolve(assetsDir, 'windows/tray_mention.ico')),
|
||
};
|
||
case 'darwin': {
|
||
const icons = {
|
||
light: {
|
||
normal: nativeImage.createFromPath(path.resolve(assetsDir, 'osx/MenuIcon.png')),
|
||
unread: nativeImage.createFromPath(path.resolve(assetsDir, 'osx/MenuIconUnread.png')),
|
||
mention: nativeImage.createFromPath(path.resolve(assetsDir, 'osx/MenuIconMention.png')),
|
||
},
|
||
clicked: {
|
||
normal: nativeImage.createFromPath(path.resolve(assetsDir, 'osx/ClickedMenuIcon.png')),
|
||
unread: nativeImage.createFromPath(path.resolve(assetsDir, 'osx/ClickedMenuIconUnread.png')),
|
||
mention: nativeImage.createFromPath(path.resolve(assetsDir, 'osx/ClickedMenuIconMention.png')),
|
||
},
|
||
};
|
||
switchMenuIconImages(icons, nativeTheme.shouldUseDarkColors);
|
||
return icons;
|
||
}
|
||
case 'linux': {
|
||
const theme = config.trayIconTheme;
|
||
try {
|
||
return {
|
||
normal: nativeImage.createFromPath(path.resolve(assetsDir, 'linux', theme, 'MenuIconTemplate.png')),
|
||
unread: nativeImage.createFromPath(path.resolve(assetsDir, 'linux', theme, 'MenuIconUnreadTemplate.png')),
|
||
mention: nativeImage.createFromPath(path.resolve(assetsDir, 'linux', theme, 'MenuIconMentionTemplate.png')),
|
||
};
|
||
} catch (e) {
|
||
//Fallback for invalid theme setting
|
||
return {
|
||
normal: nativeImage.createFromPath(path.resolve(assetsDir, 'linux', 'light', 'MenuIconTemplate.png')),
|
||
unread: nativeImage.createFromPath(path.resolve(assetsDir, 'linux', 'light', 'MenuIconUnreadTemplate.png')),
|
||
mention: nativeImage.createFromPath(path.resolve(assetsDir, 'linux', 'light', 'MenuIconMentionTemplate.png')),
|
||
};
|
||
}
|
||
}
|
||
default:
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function switchMenuIconImages(icons, isDarkMode) {
|
||
if (isDarkMode) {
|
||
icons.normal = icons.clicked.normal;
|
||
icons.unread = icons.clicked.unread;
|
||
icons.mention = icons.clicked.mention;
|
||
} else {
|
||
icons.normal = icons.light.normal;
|
||
icons.unread = icons.light.unread;
|
||
icons.mention = icons.light.mention;
|
||
}
|
||
}
|
||
|
||
function getDeeplinkingURL(args) {
|
||
if (Array.isArray(args) && args.length) {
|
||
// deeplink urls should always be the last argument, but may not be the first (i.e. Windows with the app already running)
|
||
const url = args[args.length - 1];
|
||
if (url && scheme && url.startsWith(scheme) && urlUtils.isValidURI(url)) {
|
||
return url;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function shouldShowTrayIcon() {
|
||
if (config.showTrayIcon === true || process.platform === 'win32') {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function wasUpdated(lastAppVersion) {
|
||
return lastAppVersion !== app.getVersion();
|
||
}
|
||
|
||
function clearAppCache() {
|
||
if (mainWindow) {
|
||
mainWindow.webContents.session.clearCache().then(mainWindow.reload);
|
||
} else {
|
||
//Wait for mainWindow
|
||
setTimeout(clearAppCache, 100);
|
||
}
|
||
}
|
||
|
||
function isWithinDisplay(state, display) {
|
||
const startsWithinDisplay = !(state.x > display.maxX || state.y > display.maxY || state.x < display.minX || state.y < display.minY);
|
||
if (!startsWithinDisplay) {
|
||
return false;
|
||
}
|
||
|
||
// is half the screen within the display?
|
||
const midX = state.x + (state.width / 2);
|
||
const midY = state.y + (state.height / 2);
|
||
return !(midX > display.maxX || midY > display.maxY);
|
||
}
|
||
|
||
function getValidWindowPosition(state) {
|
||
// Check if the previous position is out of the viewable area
|
||
// (e.g. because the screen has been plugged off)
|
||
const boundaries = Utils.getDisplayBoundaries();
|
||
const display = boundaries.find((boundary) => {
|
||
return isWithinDisplay(state, boundary);
|
||
});
|
||
|
||
if (typeof display === 'undefined') {
|
||
return {};
|
||
}
|
||
return {x: state.x, y: state.y};
|
||
}
|
||
|
||
function resizeScreen(screen, browserWindow) {
|
||
function handle() {
|
||
const position = browserWindow.getPosition();
|
||
const size = browserWindow.getSize();
|
||
const validPosition = getValidWindowPosition({
|
||
x: position[0],
|
||
y: position[1],
|
||
width: size[0],
|
||
height: size[1],
|
||
});
|
||
if (typeof validPosition.x !== 'undefined' || typeof validPosition.y !== 'undefined') {
|
||
browserWindow.setPosition(validPosition.x || 0, validPosition.y || 0);
|
||
} else {
|
||
browserWindow.center();
|
||
}
|
||
}
|
||
|
||
browserWindow.on('restore', handle);
|
||
handle();
|
||
}
|