/* global LazyLoader, DUMP */ 'use strict'; (function(exports) { /** * Turn of this flag to debug all BaseModule based modules. * @type {Boolean} */ var GLOBAL_DEBUG = false; /** * `_GAIA_DEVICE_TYPE_` is a placeholder and will be replaced by real * device type in build time, `system/build/build.js` does the trick. */ var DEVICE_TYPE = '_GAIA_DEVICE_TYPE_'; /** * This is used to store the constructors which are created * via BaseModule.create(). * constructor.name => constructor * @type {Object} */ var AVAILABLE_MODULES = {}; /** * BaseModule is a class skeleton which helps you to build a module with * * Centralized event handler * * Centralized settings observation * * Sub modules management including loading and starting * * Import preload files * * DOM rendering * * Consistent logging function with System boot time and module name * * Common publishing interface * @class BaseModule */ var BaseModule = function() {}; /** * The sub modules belong to this module. * BaseModule will load and then start these sub modules automatically. * The expressions can include path (e.g. 'path/to/ModuleName'). BaseModule * will load them from specified subdirectory. However, the module names * (without path) should be unique even they're under different folders. * * You can provide an object: * Define a list of default type modules are able to be loaded in anytime. * Define a list of device type modules can be loaded when running on a given * device-type. * { * default: ['base_module1', 'base_module2', ...], * device-type: ['module1', 'module2', ...] * } * * Or provide an array: * ['module1', 'module2', ...] * * @type {Object|Array} */ BaseModule.SUB_MODULES = {}; /** * All events of need to be listened. * BaseModule will add/remove the event listener in start/stop functions. * The function of '_handle_' form in this module will be invoked * when the event is caught. * @type {Array} */ BaseModule.EVENTS = []; /** * All mozSettings need to be observed. * BaseModule will observe and invoke the responsive * this['_observe_' + key] function. * @type {Array} */ BaseModule.SETTINGS = []; /** * This defines a list of file path needs to be imported * before the real start of this module. * * You can provide an object: * Define a list of default type modules are able to be loaded in anytime. * Define a list of device type modules can be loaded when running on a given * device-type. * { * default: ['base_module1', 'base_module2', ...], * device-type: ['module1', 'module2', ...] * } * * Or provide an array: * ['module1', 'module2', ...] * * @type {Object|Array} */ BaseModule.IMPORTS = {}; /** * This tells System the sandbox what methods you are going to * register and let the other to request. * * @example * var MyModule = function() {}; * MyModule.SERVICES = ['unlock']; * MyModule.prototype = Object.create(BaseModule.prototype); * MyModule.prototype.constructor = MyModule; * MyModule.prototype.name = 'MyModule'; * var m = new MyModule(); * m.start(); * // other module * Service.request('MyModule:unlock').then(function(result) { * }); * Service.request('unlock').then(function(result) { * // if the request is registered by only one module. * }); */ BaseModule.SERVICES = []; /** * The function or property exported here will be * synchronously queried by other module in system app. * If we are not started yet, they will get undefined. * * @example * var MyModule = function() {}; * MyModule.STATES = ['isActive']; * MyModule.prototype = Object.create(BaseModule.prototype); * MyModule.prototype.constructor = MyModule; * MyModule.prototype.name = 'MyModule'; * var m = new MyModule(); * m.start(); * // other module * console.log(Service.query('MyModule.isActive')); * // if the method name is unique. * console.log(Service.query('isActive')); * @type {Array} */ BaseModule.STATES = []; var SubmoduleMixin = { loadWhenIdle: function(modules) { return this.service.request('schedule', () => { var submodules = this._arraylizeModules(this.constructor.SUB_MODULES); var newModules = this._arraylizeModules(modules); this.constructor.SUB_MODULES = submodules.concat(newModules); return this._startSubModules(); }); }, _arraylizeModules: function(submodules) { var modules; var defaulModules; var deviceModules; if (submodules && Array.isArray(submodules)) { modules = submodules; } else if (submodules && typeof submodules === 'object') { defaulModules = submodules.default || []; deviceModules = submodules[DEVICE_TYPE] || []; modules = defaulModules.concat(deviceModules); } else { modules = []; } return modules; }, /** * Helper function to load and start the submodules defined in * |this.constructor.SUB_MODULES|. */ _startSubModules: function() { var submodules = this._arraylizeModules(this.constructor.SUB_MODULES); this.constructor.SUB_MODULES = submodules; if (submodules.length === 0) { return Promise.resolve(); } var unloaded = []; submodules.forEach(function(submodule) { if (BaseModule.defined(submodule) || window[submodule]) { var name = BaseModule.lowerCapital(submodule); if (!this[name]) { this._initializeSubModule(name, submodule); } } else { unloaded.push(submodule); } }, this); if (unloaded.length === 0) { this.baseSubModuleLoaded && this.baseSubModuleLoaded(); return; } this.debug('lazy loading submodules: ' + unloaded.concat()); return BaseModule.lazyLoad(unloaded).then(() => { this.debug('lazy loaded submodules: ' + unloaded.concat()); return Promise.all( unloaded .map(BaseModule.parsePath) .map(function(module) { var moduleName = BaseModule.lowerCapital(module.name); if (!this[moduleName]) { return this._initializeSubModule(moduleName, module.name); } else { return Promise.resolve(); } }, this)); }); }, _initializeSubModule: function(moduleName, module) { var constructor = AVAILABLE_MODULES[module] || window[module]; if (typeof(constructor) == 'function') { this.debug('instantiating submodule: ' + moduleName); this[moduleName] = new constructor(this); // If there is a custom submodule loaded handler, call it. // Otherwise we will start the submodule right away. if (typeof(this['_' + moduleName + '_loaded']) == 'function') { return this['_' + moduleName + '_loaded'](); } else if (this.lifeCycleState !== 'stopped') { return this[moduleName].start && this[moduleName].start(); } } else { this[moduleName] = constructor; // For the module which does not become class yet if (this[moduleName] && this[moduleName].start) { return this[moduleName].start(); } else if (this[moduleName] && this[moduleName].init) { // backward compatibility with init. return this[moduleName].init(); } else { return undefined; } } }, _stopSubModules: function() { if (!this.constructor.SUB_MODULES) { return; } this.constructor.SUB_MODULES.map(BaseModule.parsePath) .forEach(function(module) { var moduleName = BaseModule.lowerCapital(module.name); if (this[moduleName]) { this.debug('Stopping submodule: ' + moduleName); this[moduleName].stop && this[moduleName].stop(); } }, this); } }; /** * SettingsMixin will provide you the ability to watch * and store the settings in this._settings * @type {Object} */ var SettingMixin = { observe: function(name, value) { this.debug('observing ' + name + ' : ' + value); this._settings[name] = value; if (typeof(this['_observe_' + name]) == 'function') { this.debug('observer for ' + name + ' found, invoking.'); this['_observe_' + name](value); } }, _observeSettings: function() { if (!this.constructor.SETTINGS) { this.debug('No settings needed, skipping.'); return; } this._settings = {}; this.debug('~observing following settings: ' + this.constructor.SETTINGS.concat()); this.constructor.SETTINGS.forEach(function(setting) { this.service.request('SettingsCore:addObserver', setting, this); }, this); }, _unobserveSettings: function() { if (!this.constructor.SETTINGS) { return; } this.constructor.SETTINGS.forEach(function(setting) { this.service.request('SettingsCore:removeObserver', setting, this); }, this); } }; var EventMixin = { /** * Custom global event handler before the event is handled * by a specific handler. * Override it if necessary. */ _pre_handleEvent: function() { }, /** * Custom global event handler after the event is handled. * Override it if necessary. */ _post_handleEvent: function() { }, _subscribeEvents: function() { if (!this.constructor.EVENTS) { this.debug('No events wanted, skipping.'); return; } this.debug('event subcribing stage..'); this.constructor.EVENTS.forEach(function(event) { this.debug('subscribing ' + event); window.addEventListener(event, this); }, this); }, _unsubscribeEvents: function() { if (!this.constructor.EVENTS) { return; } this.constructor.EVENTS.forEach(function(event) { window.removeEventListener(event, this); }, this); }, handleEvent: function(evt) { if (typeof(this._pre_handleEvent) == 'function') { var shouldContinue = this._pre_handleEvent(evt); if (shouldContinue === false) { return; } } else { this.debug('no handle event pre found. skip'); } if (typeof(this['_handle_' + evt.type]) == 'function') { this.debug('handling ' + evt.type); this['_handle_' + evt.type](evt); } if (typeof(this._post_handleEvent) == 'function') { this._post_handleEvent(evt); } } }; var ServiceMixin = { _registerServices: function() { if (!this.constructor.SERVICES) { return; } this.constructor.SERVICES.forEach(function(service) { this.service.register(service, this); }, this); }, _unregisterServices: function() { if (!this.constructor.SERVICES) { return; } this.constructor.SERVICES.forEach(function(service) { this.service.unregister(service, this); }, this); } }; var StateMixin = { _registerStates: function() { if (!this.constructor.STATES) { return; } this.constructor.STATES.forEach(function(state) { this.service.registerState(state, this); }, this); }, _unregisterStates: function() { if (!this.constructor.STATES) { return; } this.constructor.STATES.forEach(function(state) { this.service.unregisterState(state, this); }, this); } }; BaseModule.defined = function(name) { return !!AVAILABLE_MODULES[name]; }; BaseModule.__clearDefined = function() { AVAILABLE_MODULES = []; }; /** * Mixin the prototype with give mixin object. * @param {Object} prototype The prototype of a class * @param {Object} mixin An object will be mixed into the prototype */ BaseModule.mixin = function (prototype, mixin) { for (var prop in mixin) { if (mixin.hasOwnProperty(prop)) { prototype[prop] = mixin[prop]; } } }; /** * Create a module based on base module and give properties. * The constructor will be placed in AVAILABLE_MODULES if you * specify an unique name in the prototype. * @example * var MyModule = function() {}; * BaseModule.create(MyModule, { * name: 'MyModule' * }); * var myModule = BaseModule.instantiate('MyModule'); * * @param {Function} constructor The constructor function. * @param {Object} prototype * The prototype which will be injected into the class. * @param {Object} properties * The property object which includes getter/setter. */ BaseModule.create = function(constructor, prototype, properties) { constructor.prototype = Object.create(BaseModule.prototype, properties); constructor.prototype.constructor = constructor; if (constructor.SETTINGS) { BaseModule.mixin(constructor.prototype, SettingMixin); } if (constructor.EVENTS) { BaseModule.mixin(constructor.prototype, EventMixin); } if (constructor.SERVICES) { BaseModule.mixin(constructor.prototype, ServiceMixin); } if (constructor.STATES) { BaseModule.mixin(constructor.prototype, StateMixin); } // Inject this anyway. BaseModule.mixin(constructor.prototype, SubmoduleMixin); if (prototype) { BaseModule.mixin(constructor.prototype, prototype); if (prototype.name) { AVAILABLE_MODULES[prototype.name] = constructor; } else { console.warn('No name give, impossible to instantiate without name.'); } } return constructor; }; /** * Create a new instance based on the module name given. * It will look up |AVAILABLE_MODULES|. * Note: this will instante multiple instances if called more than once. * Also it's impossible to pass arguments now. * @param {String} moduleName The module name * which comes from the prototype of the module. * @return {Object} Created instance. */ BaseModule.instantiate = function(moduleName) { if (moduleName in AVAILABLE_MODULES) { var args = Array.prototype.slice.call(arguments, 1); var constructor = function() { AVAILABLE_MODULES[moduleName].apply(this, args); }; constructor.prototype = AVAILABLE_MODULES[moduleName].prototype; return new constructor(); } return undefined; }; /** * Lazy load an list of modules * @param {Array} array A list of module names * @return {Promise} The promise of lazy loading; * it will be invoked once lazy loading is done. */ BaseModule.lazyLoad = function(array) { var self = this; return new Promise(function(resolve) { var fileList = []; array.forEach(function(module) { fileList.push(BaseModule.object2fileName(module)); }, self); LazyLoader.load(fileList, function() { resolve(); }); }); }; /** * A helper function to lowercase only the capital character. * @example * Service.lowerCapital('AppWindowManager'); * // appWindowManager * @param {String} str String to be lowercased on capital * @return {String} Captital lowerred string */ BaseModule.lowerCapital = function(str) { return str.charAt(0).toLowerCase() + str.slice(1); }; /** * A helper function to split module expressions into "path" and "name". * @example * Service.parsePath('path/to/ModuleName'); * // {path: 'path/to/', name: 'ModuleName'} * @param {String} str String to be splitted * @return {Object} The result object with members: "path" and "name". */ BaseModule.parsePath = function(str) { var [, path, name] = /^(.*\/|)(.+)$/.exec(str); return { path: path, name: name }; }; /** * A helper function to transform object name to file name * @example * Service.object2fileName('AppWindowManager'); * // 'js/app_window_manager.js' * Service.object2fileName('path/to/ModuleName'); * // 'js/path/to/module_name.js' * * @param {String} string Module name * @return {String} File name */ BaseModule.object2fileName = function(string) { var i = 0; var ch = ''; var module = BaseModule.parsePath(string); var moduleName = module.name; while (i <= moduleName.length) { var character = moduleName.charAt(i); if (character !== character.toLowerCase()) { if (ch === '') { ch += character.toLowerCase(); } else { ch += '_' + character.toLowerCase(); } } else { ch += character; } i++; } return '/js/' + module.path + ch + '.js'; }; BaseModule.prototype = { service: window.Service, DEBUG: false, TRACE: false, /** * The name of this module which is usually the constructor function name * and could be converted to the file name. * For example, AppWindowManager will be mapped to app_window_manager.js * This should be unique. * @type {String} */ name: '(Anonymous)', EVENT_PREFIX: '', /** * We are having three states: * * starting * * started * * stopped * @type {String} */ lifeCycleState: 'stopped', publish: function(event, detail, noPrefix) { var prefix = noPrefix ? '' : this.EVENT_PREFIX; var evt = new CustomEvent(prefix + event, { bubbles: true, detail: detail || this }); this.debug('publishing: ' + prefix + event); window.dispatchEvent(evt); }, /** * Basic log. */ debug: function() { if (this.DEBUG || GLOBAL_DEBUG) { console.log('[' + this.name + ']' + '[' + this.service.currentTime() + '] ' + Array.slice(arguments).concat()); if (this.TRACE) { console.trace(); } } else if (window.DUMP) { DUMP('[' + this.name + ']' + '[' + this.service.currentTime() + '] ' + Array.slice(arguments).concat()); } }, /** * Log some infomation. */ info: function() { if (this.DEBUG || GLOBAL_DEBUG) { console.info('[' + this.name + ']' + '[' + this.service.currentTime() + '] ' + Array.slice(arguments).concat()); } }, /** * Log some warning message. */ warn: function() { if (this.DEBUG || GLOBAL_DEBUG) { console.warn('[' + this.name + ']' + '[' + this.service.currentTime() + '] ' + Array.slice(arguments).concat()); } }, /** * Log some error message. */ error: function() { if (this.DEBUG || GLOBAL_DEBUG) { console.error('[' + this.name + ']' + '[' + this.service.currentTime() + '] ' + Array.slice(arguments).concat()); } }, writeSetting: function(settingObject) { this.debug('writing ' + JSON.stringify(settingObject) + ' to settings db'); return this.service.request('SettingsCore:set', settingObject); }, readSetting: function(name) { if (this._settings && this._settings[name]) { return Promise.resolve(this._settings[name]); } else { this.debug('reading ' + name + ' from settings db'); return this.service.request('SettingsCore:get', name); } }, /** * Custom start function, override it if necessary. * If you want to block the start process of this module, * return a promise here. */ _start: function() {}, /** * Custom stop function. Override it if necessary. */ _stop: function() {}, /** * The starting progress of a module has these steps: * * import javascript files * * lazy load submodules and instantiate once loaded. * * custom start function * * attach event listeners * * observe settings * * register services to System * * DOM elements rendering (not implemented) * The import is guranteed to happen before anything else. * The service registration is expected to happen after everything is done. * The ordering of the remaining parts should not depends each other. * * The start function will return a promise to let you know * when the module is started. * @example * var a = BaseModule.instantiate('A'); * a.start().then(() => { * console.log('started'); * }); * * Note: a module start promise will only be resolved * after all the steps are resolved, including * the custom start promise and the promises from all the submodules. * So when you see a module is started, that means all of its * submodules are started as well. * * @memberOf BaseModule.prototype */ start: function() { if (this.lifeCycleState !== 'stopped') { this.warn('already started'); return Promise.reject('already started'); } this.switchLifeCycle('starting'); return this.imports(); }, __imported: function() { // Do nothing if we are stopped. if (this.lifeCycleState === 'stopped') { this.warn('already stopped'); return Promise.resolve(); } this.debug('in imported'); return Promise.all([ // Parent module needs to know the events from the submodule. this._subscribeEvents && this._subscribeEvents(), this._startSubModules && this._startSubModules(), this._start(), this._observeSettings && this._observeSettings(), this._registerServices && this._registerServices(), this._registerStates && this._registerStates()]).then(() => { this.switchLifeCycle('started'); }); }, /** * The stopping of a module has these steps: * * unregister services to System sandbox * * lazy load submodules and instantiate once loaded. * * attach event listeners * * observe settings * * custom stop function */ stop: function() { if (this.lifeCycleState === 'stopped') { this.warn('already stopped'); return; } this._unregisterServices && this._unregisterServices(); this._unregisterStates && this._unregisterStates(); this._stopSubModules && this._stopSubModules(); this._unsubscribeEvents && this._unsubscribeEvents(); this._unobserveSettings && this._unobserveSettings(); this._stop(); this.switchLifeCycle('stopped'); }, switchLifeCycle: function(state) { if (this.lifeCycleState === state) { return; } this.debug('life cycle state change: ' + this.lifeCycleState + ' -> ' + state); this.lifeCycleState = state; this.publish(state); }, imports: function() { var imports = this._arraylizeModules(this.constructor.IMPORTS); this.constructor.IMPORTS = imports; if (imports.length === 0) { return this.__imported(); } this.debug(imports); this.debug('import loading.'); return LazyLoader.load(imports) .then(() => { this.debug('imported..'); return this.__imported(); }); } }; exports.BaseModule = BaseModule; }(window));