meteor-desktop
Version:
Build a Meteor's desktop client with hot code push.
733 lines (638 loc) • 25.1 kB
JavaScript
/* eslint-disable import/no-unresolved,no-console */
/* eslint-disable global-require, import/no-dynamic-require */
import electron from 'electron';
import { EventEmitter as Events } from 'events';
import path from 'path';
import fs from 'fs-plus';
import shell from 'shelljs';
import semver from 'semver';
import assignIn from 'lodash/assignIn';
import Module from './modules/module';
import LoggerManager from './loggerManager';
import DesktopPathResolver from './desktopPathResolver';
import WindowSettings from './windowSettings';
import Squirrel from './squirrel'; // DEPRECATED
const { app, BrowserWindow, dialog } = electron;
const { join } = path;
/**
* This is the main app which is a skeleton for the whole integration.
* Here all the plugins/modules are loaded, local server is spawned and autoupdate is initialized.
* @class
*/
export default class App {
constructor() {
this.startup = true;
console.time('startup took');
// Fallback for Electron version lower than 5 which don't support registerSchemesAsPrivileged
if (semver.lt(process.versions.electron, '5.0.0-beta.0')) {
electron.protocol.registerStandardSchemes(['meteor'], { secure: true });
} else {
electron.protocol.registerSchemesAsPrivileged([
{ scheme: 'meteor', privileges: { standard: true, secure: true } }
]);
}
// Until user defined handling will be loaded it is good to register something
// temporarily.
this.catchUncaughtExceptions();
this.getOsSpecificValues();
this.loggerManager = new LoggerManager(this);
this.l = this.loggerManager.getMainLogger();
this.l.info('app data dir is:', this.userDataDir);
this.settings = {
devTools: false
};
this.desktopPath = DesktopPathResolver.resolveDesktopPath(this.userDataDir, this.l);
this.loadSettings();
if ('meteorDesktopVersion' in this.settings) {
this.l.debug(`skeleton version ${this.settings.meteorDesktopVersion}`);
}
this.window = null;
this.applySingleInstance();
// To make desktop.asar's downloaded through HCP work, we need to provide them a path to
// node_modules.
const nodeModulesPath = [__dirname, 'node_modules'];
// TODO: explain this
if (!this.isProduction()) {
nodeModulesPath.splice(1, 0, '..');
}
require('module').globalPaths.push(path.resolve(join(...nodeModulesPath)));
/**
* DEPRECATED
*/
if (Squirrel.handleSquirrelEvents(this.desktopPath)) {
app.quit();
return;
}
// This is needed for OSX - check Electron docs for more info.
if ('builderOptions' in this.settings && this.settings.builderOptions.appId) {
app.setAppUserModelId(this.settings.builderOptions.appId);
}
// System events emitter.
this.eventsBus = new Events();
this.desktop = null;
this.app = app;
this.windowAlreadyLoaded = false;
this.webContents = null;
this.modules = {};
this.localServer = null;
this.currentPort = null;
if (this.isProduction() && !this.settings.prodDebug) {
// In case anything depends on this...
process.env.NODE_ENV = 'production';
} else {
require('electron-debug')({
showDevTools: process.env.ELECTRON_ENV !== 'test',
enabled: (this.settings.devTools !== undefined) ? this.settings.devTools : true
});
}
this.prepareWindowSettings();
this.meteorAppVersionChange = false;
this.pendingDesktopVersion = null;
this.eventsBus.on('newVersionReady', (version, desktopVersion) => {
this.l.debug(`received newVersionReady ${desktopVersion ? '(desktop update present)' : ''}`);
this.meteorAppVersionChange = true;
this.pendingDesktopVersion = desktopVersion;
});
this.eventsBus.on('startupDidComplete', this.handleAppStartup.bind(this, true));
this.eventsBus.on('revertVersionReady', () => { this.meteorAppVersionChange = true; });
this.app.on('ready', this.onReady.bind(this));
this.app.on('window-all-closed', () => this.app.quit());
}
/**
* Applies single instance mode if enabled.
*/
applySingleInstance() {
if ('singleInstance' in this.settings && this.settings.singleInstance) {
this.l.verbose('setting single instance mode');
const isSecondInstance = app.makeSingleInstance(() => {
// Someone tried to run a second instance, we should focus our window.
if (this.window) {
if (this.window.isMinimized()) {
this.window.restore();
}
this.window.focus();
}
});
if (isSecondInstance) {
this.l.warn('current instance was terminated because another instance is running');
app.quit();
}
}
}
/**
* Prepares all the values that are dependant on os.
*/
getOsSpecificValues() {
this.os = {
isWindows: (process.platform === 'win32'),
isLinux: (process.platform === 'linux'),
isOsx: (process.platform === 'darwin')
};
this.userDataDir = app.getPath('userData');
}
/**
* Checks whether this is a production build.
* @returns {boolean}
* @api
*/
isProduction() {
return ('env' in this.settings && this.settings.env === 'prod');
}
/**
* Tries to load the settings.json.
*/
loadSettings() {
try {
this.settings = JSON.parse(
fs.readFileSync(join(this.desktopPath, 'settings.json')), 'UTF-8'
);
} catch (e) {
this.l.error(e);
dialog.showErrorBox('Application', 'Could not read settings.json. Please reinstall' +
' this application.');
if (this.app && this.app.quit) {
this.app.quit();
}
process.exit(1);
}
}
/**
* Removes default uncaught exception listener.
* But still leaves logging and emitting
* @api
*/
removeUncaughtExceptionListener() {
process.removeListener('uncaughtException', this.uncaughtExceptionHandler);
}
/**
* Logs the error and emits an unhandledException event on the events bus.
* @param error
*/
emitErrorAndLogIt(error) {
try {
this.l.error(error);
if (this.eventsBus) {
this.emit('unhandledException', error);
}
} catch (e) {
// Well...
}
}
/**
* Register on uncaughtExceptions so we can handle them.
*/
catchUncaughtExceptions() {
this.uncaughtExceptionHandler = this.uncaughtExceptionHandler.bind(this);
process.on('uncaughtException', this.emitErrorAndLogIt.bind(this));
process.on('uncaughtException', this.uncaughtExceptionHandler);
}
/**
* Default uncaught exception handler.
*/
uncaughtExceptionHandler() {
try {
this.window.close();
} catch (e) {
// Empty catch block... nasty...
}
setTimeout(() => {
dialog.showErrorBox('Application', 'Internal error occurred. Restart this ' +
'application. If the problem persists, contact support or try to reinstall.');
this.app.quit();
}, 500);
}
/**
* Applies dev, os specific and variables to window settings.
*/
prepareWindowSettings() {
if (!('window' in this.settings)) {
this.settings.window = {};
}
if (!this.isProduction()) {
WindowSettings.mergeWindowDevSettings(this.settings);
}
WindowSettings.mergeOsSpecificWindowSettings(this.settings, this.os);
WindowSettings.applyVars(this.settings.window, this.desktopPath);
}
/**
* Loads and initializes all plugins listed in settings.json.
*/
loadPlugins() {
if ('plugins' in this.settings) {
Object.keys(this.settings.plugins).forEach((plugin) => {
try {
this.l.debug(`loading plugin: ${plugin}`);
this.modules[plugin] = require(plugin).default;
const Plugin = this.modules[plugin];
this.modules[plugin] = new Plugin({
log: this.loggerManager.configureLogger(plugin),
skeletonApp: this,
appSettings: this.settings,
eventsBus: this.eventsBus,
modules: this.modules,
settings: typeof this.settings.plugins[plugin] === 'object' ?
this.settings.plugins[plugin] : {},
Module
});
} catch (e) {
// TODO: its probably safer not to exit here
// but a strategy for handling this would be better.
this.l.error(`error while loading plugin: ${e}`);
}
});
}
}
/**
* Loads and initializes internal and app modules.
*/
loadModules() {
// Load internal modules. Scan for files in /modules.
shell.ls(join(__dirname, 'modules', '*.js')).forEach((file) => {
if (!~file.indexOf('module.js')) {
this.loadModule(true, file);
}
});
// Now go through each directory in .desktop/modules.
let moduleDirectories = [];
try {
moduleDirectories = fs.readdirSync(join(this.desktopPath, 'modules'));
} catch (err) {
if (err.code === 'ENOENT') {
this.l.debug(`not loading custom app modules because .desktop/modules isn't a directory`);
} else {
throw err;
}
}
moduleDirectories.forEach((dirName) => {
try {
const modulePath = join(this.desktopPath, 'modules', dirName);
if (fs.lstatSync(modulePath).isDirectory()) {
this.loadModule(false, modulePath, dirName);
}
} catch (e) {
this.l.error(`error while trying to load module in dir ${dirName}: ${e}`);
this.l.debug(e.stack);
this.emit('moduleLoadFailed', dirName);
}
});
}
/**
* Tries to read a module's module.json file.
* @param modulePath
* @returns {{settings: {}, moduleName: *}}
*/
static readModuleConfiguration(modulePath) {
let settings = {};
let moduleName = null;
const moduleJson = JSON.parse(
fs.readFileSync(path.join(modulePath, 'module.json'), 'UTF-8')
);
if ('settings' in moduleJson) {
({ settings } = moduleJson);
}
if ('name' in moduleJson) {
moduleName = moduleJson.name;
}
// Inject extractedFilesPath.
if ('extract' in moduleJson) {
settings.extractedFilesPath =
join(__dirname, '..', 'extracted', moduleName);
}
return { settings, moduleName };
}
/**
* Load a module.
* @param {boolean} internal - whether that is an internal module
* @param {string} modulePath - path to the module
* @param {string} dirName - directory name of the module
*/
loadModule(internal, modulePath, dirName = '') {
let moduleName = path.parse(modulePath).name;
let settings = {};
let indexPath = '';
if (!internal) {
// module.json is mandatory, but we can live without it.
try {
const result = App.readModuleConfiguration(modulePath);
assignIn(settings, result.settings);
if (result.moduleName) {
({ moduleName } = result);
}
} catch (e) {
this.l.warn(`could not load ${path.join(modulePath, 'module.json')}`);
}
this.l.debug(`loading module: ${dirName} => ${moduleName}`);
indexPath = path.join(modulePath, 'index.js');
} else {
this.l.debug(`loading internal module: ${moduleName}`);
indexPath = modulePath;
}
const AppModule = require(indexPath).default;
if (internal && moduleName === 'autoupdate') {
settings = this.prepareAutoupdateSettings();
}
if (internal && moduleName === 'localServer') {
settings = {
localFilesystem: this.settings.exposeLocalFilesystem,
allowOriginLocalServer: this.settings.allowOriginLocalServer || false
};
}
this.modules[moduleName] = new AppModule({
log: this.loggerManager.configureLogger(moduleName),
skeletonApp: this,
appSettings: this.settings,
eventsBus: this.eventsBus,
modules: this.modules,
settings,
Module
});
}
/**
* Tries to load desktop.js.
*/
loadDesktopJs() {
try {
const desktopJsPath = join(this.desktopPath, 'desktop.js');
const Desktop = require(desktopJsPath).default;
this.desktop = new Desktop({
log: this.loggerManager.configureLogger('desktop'),
skeletonApp: this,
appSettings: this.settings,
eventsBus: this.eventsBus,
modules: this.modules,
Module
});
this.modules.desktop = this.desktop;
this.emit('desktopLoaded', this.desktop);
this.l.debug('desktop loaded');
} catch (e) {
this.l.error('could not load desktop.js', e);
}
}
/**
* Util function for emitting events on the event bus.
* @param {string} event - event name
* @param {[*]} args - event's arguments
*/
emit(event, ...args) {
try {
this.eventsBus.emit(event, ...args);
} catch (e) {
this.l.error(`error while emitting '${event}' event: ${e}`);
}
}
/**
* Checks wheteher object seems to be a promise.
* @param {Object} obj
* @returns {boolean}
*/
static isPromise(obj) {
return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function';
}
/**
* Util function for emitting events synchronously and waiting asynchronously for
* handlers to finish.
* @param {string} event - event name
* @param {[*]} args - event's arguments
*/
emitAsync(event, ...args) {
const promises = [];
try {
this.eventsBus.listeners(event).forEach((handler) => {
const result = handler(...args);
if (App.isPromise(result)) {
promises.push(result);
} else {
promises.push(Promise.resolve());
}
});
} catch (e) {
this.l.error(`error while emitting '${event}' event: ${e}`);
return Promise.reject(e);
}
return Promise.all(promises);
}
/**
* Initializes this app.
* Loads plugins.
* Loads modules.
* Loads desktop.js.
* Initializes local server.
*/
onReady() {
this.l.info('ready fired');
Squirrel.setUpAutoUpdater(this);
this.emit('beforePluginsLoad');
this.loadPlugins();
this.emit('beforeModulesLoad');
this.loadModules();
this.emit('beforeDesktopJsLoad');
// desktopLoaded event in emitted from the inside of loadDesktopJs
this.loadDesktopJs();
this.localServer = this.modules.localServer;
this.localServer.setCallbacks(
this.onStartupFailed.bind(this),
this.onServerReady.bind(this),
this.onServerRestarted.bind(this)
);
this.emit('beforeLocalServerInit');
this.localServer.init(
this.modules.autoupdate.getCurrentAssetBundle(),
this.desktopPath
);
this.emit('afterInitialization');
}
/**
* On server restart point chrome to the new port.
* @param {number} port - port on which the app is served
*/
onServerRestarted(port) {
this.emitAsync('beforeLoadUrl', port, this.currentPort)
.catch(() => {
this.l.warning('some of beforeLoadUrl event listeners have failed');
})
.then(() => {
this.currentPort = port;
this.webContents.loadURL('meteor://desktop');
});
}
/**
* Returns prepared autoupdate module settings.
* @returns {{dataPath: *, desktopBundlePath: String, bundleStorePath: *, initialBundlePath,
* webAppStartupTimeout: number}}
*/
prepareAutoupdateSettings() {
return {
dataPath: this.userDataDir,
desktopBundlePath: this.userDataDir,
bundleStorePath: this.userDataDir,
customHCPUrl: this.settings.customHCPUrl || null,
initialBundlePath: path.join(__dirname, '..', 'meteor.asar'),
webAppStartupTimeout: this.settings.webAppStartupTimeout ?
this.settings.webAppStartupTimeout : 20000
};
}
/**
* Handle startup failure.
* @param {number} code - error code from local server
*/
onStartupFailed(code) {
this.emit('startupFailed');
dialog.showErrorBox('Startup error', 'Could not initialize app. Please contact' +
` your support. Error code: ${code}`);
this.app.quit();
}
/**
* Starts the app loading in the browser.
* @param {number} port - port on which our local server is listening
*/
onServerReady(port) {
const windowSettings = {
width: 800,
height: 600,
webPreferences: {},
show: false
};
if (process.env.METEOR_DESKTOP_SHOW_MAIN_WINDOW_ON_STARTUP) {
windowSettings.show = true;
}
assignIn(windowSettings, this.settings.window);
// Emit windowSettings so that it can be modified if that is needed in any of the modules.
// I do not really like, that it can modified indirectly but until 1.0 it needs to stay
// this way.
this.emit('windowSettings', windowSettings);
windowSettings.webPreferences.nodeIntegration = false; // node integration must to be off
windowSettings.webPreferences.preload = join(__dirname, 'preload.js');
this.currentPort = port;
this.window = new BrowserWindow(windowSettings);
this.window.on('closed', () => {
this.window = null;
});
this.webContents = this.window.webContents;
if (this.settings.devtron && !this.isProduction()) {
this.webContents.on('did-finish-load', () => {
// Print some fancy status to the console if in development.
this.webContents.executeJavaScript(`
console.log('%c meteor-desktop ',
\`background:linear-gradient(#47848F,#DE4B4B);border:1px solid #3E0E02;
color:#fff;display:block;text-shadow:0 3px 0 rgba(0,0,0,0.5);
box-shadow:0 1px 0 rgba(255,255,255,0.4) inset,0 5px 3px -5px rgba(0,0,0,0.5),
0 -13px 5px -10px rgba(255,255,255,0.4) inset;
line-height:20px;text-align:center;font-weight:700;font-size:20px\`);
console.log(\`%cdesktop version: ${this.settings.desktopVersion}\\n` +
`desktop compatibility version: ${this.settings.compatibilityVersion}\\n` +
'meteor bundle version:' +
` ${this.modules.autoupdate.currentAssetBundle.getVersion()}\\n\`` +
', \'font-size: 9px;color:#222\');');
});
}
this.emit('windowCreated', this.window);
// Here we are catching reloads triggered by hot code push.
this.webContents.on('will-navigate', (event, url) => {
if (this.meteorAppVersionChange) {
this.l.debug(`will-navigate event to ${url}, assuming that this is HCP refresh`);
// We need to block it.
event.preventDefault();
this.meteorAppVersionChange = false;
this.updateToNewVersion();
}
});
// The app was loaded.
this.webContents.on('did-stop-loading', () => {
this.l.debug('received did-stop-loading');
this.handleAppStartup(false);
});
const urlStripLength = 'meteor://desktop'.length;
this.webContents.session.protocol
.registerStreamProtocol(
'meteor',
(request, callback) => {
const url = request.url.substr(urlStripLength);
this.modules.localServer.getStreamProtocolResponse(url)
.then(res => callback(res))
.catch((e) => {
callback(this.modules.localServer.getServerErrorResponse());
this.log.error(`error while trying to fetch ${url}: ${e.toString()}`);
});
},
(e) => {
if (e) {
this.l.error(`error while registering meteor:// protocol: ${e.toString()}`);
this.uncaughtExceptionHandler();
return;
}
this.l.debug('protocol meteor:// registered');
this.l.debug('opening meteor://desktop');
setTimeout(() => {
this.webContents.loadURL('meteor://desktop');
}, 100);
}
);
}
handleAppStartup(startupDidCompleteEvent) {
if (this.settings.showWindowOnStartupDidComplete) {
if (!startupDidCompleteEvent) {
return;
}
this.l.debug('received startupDidComplete');
}
this.l.info('assuming meteor webapp has loaded');
if (this.startup) {
console.timeEnd('startup took');
this.startup = false;
}
if (!this.windowAlreadyLoaded) {
if (this.meteorAppVersionChange) {
this.l.verbose('there is a new version downloaded already, performing HCP' +
' reset');
this.updateToNewVersion();
} else {
this.windowAlreadyLoaded = true;
this.l.debug('showing main window');
this.emit('beforeLoadFinish');
this.window.show();
this.window.focus();
if (this.settings.devtron && !this.isProduction()) {
this.webContents.executeJavaScript('Desktop.devtron.install()');
}
}
} else {
this.l.debug('window already loaded');
}
this.emit('loadingFinished');
}
/**
* Updates to the new version received from hot code push.
*/
updateToNewVersion() {
this.l.verbose('entering update to new HCP version procedure');
this.l.verbose(`${this.settings.desktopVersion} !== ${this.pendingDesktopVersion}`);
const desktopUpdate = this.settings.desktopHCP &&
this.settings.desktopVersion !== this.pendingDesktopVersion;
this.emit(
'beforeReload', this.modules.autoupdate.getPendingVersion(), desktopUpdate
);
if (desktopUpdate) {
this.l.info('relaunching to use different version of desktop.asar');
// Give winston a chance to write the logs.
setImmediate(() => {
app.relaunch({ args: process.argv.slice(1).concat('--hcp') });
app.quit();
});
} else {
// Firing reset routine.
this.l.debug('firing onReset from autoupdate');
this.modules.autoupdate.onReset();
// Reinitialize the local server.
this.l.debug('resetting local server');
this.localServer.init(
this.modules.autoupdate.getCurrentAssetBundle(),
this.desktopPath,
true
);
}
}
}
if (!process.env.METEOR_DESKTOP_UNIT_TEST) {
const appInstance = new App(); // eslint-disable-line no-unused-vars
}