Home Reference Source

src/packages.js

/*
 * OS.js - JavaScript Cloud/Web Desktop Platform
 *
 * Copyright (c) Anders Evenrud <andersevenrud@gmail.com>
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice, this
 *    list of conditions and the following disclaimer
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
 * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * @author  Anders Evenrud <andersevenrud@gmail.com>
 * @license Simplified BSD License
 */

import Application from './application';
import Preloader from './utils/preloader';
import logger from './logger';
import {
  createPackageAvailabilityCheck,
  createManifestFromArray,
  filterMetadataFilesByType,
  metadataFilesToFilenames
} from './utils/packages';

/**
 * A registered package reference
 *
 * @typedef {Object} PackageReference
 * @property {PackageMetadata} metadata Package metadata
 * @property {Function} callback Callback to instanciate
 */

/**
 * A package metadata
 *
 * @typedef {Object} PackageMetadata
 * @property {string} name The package name
 * @property {string} [category] Package category
 * @property {string} [icon] Package icon
 * @property {boolean} [singleton] If only one instance allowed
 * @property {boolean} [autostart] Autostart on boot
 * @property {boolean} [hidden] Hide from launch menus etc.
 * @property {string} [server] Server script filename
 * @property {string[]} [groups] Only available for users in this group
 * @property {Object[]|string[]} [files] Files to preload
 * @property {{key: string}} title A map of locales and titles
 * @property {{key: string}} description A map of locales and titles
 */

/**
 * Package Launch Options
 *
 * @typedef {Object} PackageLaunchOptions
 * @property {boolean} [forcePreload=false] Force preload reloading
 */

/**
 * Package Manager
 *
 * Handles indexing, loading and launching of OS.js packages
 */
export default class Packages {

  /**
   * Create package manage
   *
   * @param {Core} core Core reference
   */
  constructor(core) {
    /**
     * Core instance reference
     * @type {Core}
     * @readonly
     */
    this.core = core;

    /**
     * A list of registered packages
     * @type {PackageReference[]}
     */
    this.packages = [];

    /**
     * The lost of loaded package metadata
     * @type {PackageMetadata[]}
     */
    this.metadata = [];

    /**
     * A list of running application names
     *
     * Mainly used for singleton awareness
     *
     * @private
     * @type {string[]}
     */
    this._running = [];

    /**
     * Preloader
     * @type {Preloader}
     * @readonly
     */
    this.preloader = new Preloader(core.$resourceRoot);

    /**
     * If inited
     * @type {boolean}
     */
    this.inited = false;
  }

  /**
   * Destroy package manager
   */
  destroy() {
    this.packages = [];
    this.metadata = [];

    this.preloader.destroy();
  }

  /**
   * Initializes package manager
   * @return {Promise<boolean>}
   */
  init() {
    logger.debug('Packages::init()');

    if (!this.inited) {
      this.core.on('osjs/core:started', () => this._autostart());
    }

    this.metadata = this.core.config('packages.metadata', [])
      .map(iter => ({type: 'application', ...iter}));

    this.inited = true;

    const manifest = this.core.config('packages.manifest');

    return manifest
      ? this.core.request(manifest, {}, 'json', true)
        .then(metadata => this.addPackages(metadata))
        .then(metadata => this._preloadBackgroundFiles(metadata))
        .then(() => true)
        .catch(error => logger.error(error))
      : Promise.resolve(true);
  }

  /**
   * Launches a (application) package
   *
   * @param {string} name Package name
   * @param {{foo: *}} [args={}] Launch arguments
   * @param {PackageLaunchOptions} [options={}] Launch options
   * @see PackageServiceProvider
   * @throws {Error}
   * @return {Promise<Application>}
   */
  launch(name, args = {}, options = {}) {
    logger.debug('Packages::launch()', name, args, options);

    const _ = this.core.make('osjs/locale').translate;
    const metadata = this.metadata.find(pkg => pkg.name === name);
    if (!metadata) {
      throw new Error(_('ERR_PACKAGE_NOT_FOUND', name));
    }

    if (['theme', 'icons', 'sounds'].indexOf(metadata.type) !== -1) {
      return this._launchTheme(name, metadata);
    }

    return this._launchApplication(name, metadata, args, options);
  }

  /**
   * Launches an application package
   *
   * @private
   * @param {string} name Application package name
   * @param {Metadata} metadata Application metadata
   * @param {{foo: *}} args Launch arguments
   * @param {PackageLaunchOptions} options Launch options
   * @return {Promise<Application>}
   */
  _launchApplication(name, metadata, args, options) {
    let signaled = false;

    if (metadata.singleton) {
      const foundApp = Application.getApplications()
        .find(app => app.metadata.name === metadata.name);

      if (foundApp) {
        foundApp.emit('attention', args, options);
        signaled = true;

        return Promise.resolve(foundApp);
      }

      const found = this._running.filter(n => n === name);

      if (found.length > 0) {
        return new Promise((resolve, reject) => {
          this.core.once(`osjs/application:${name}:launched`, a => {
            if (signaled) {
              resolve(a);
            } else {
              a.emit('attention', args, options);
              resolve(a);
            }
          });
        });
      }
    }

    this.core.emit('osjs/application:launch', name, args, options);

    this._running.push(name);

    return this._launch(name, metadata, args, options);
  }

  /**
   * Launches a (theme) package
   *
   * @private
   * @param {string} name Package name
   * @param {Metadata} metadata Application metadata
   * @throws {Error}
   * @return {Promise<object>}
   */
  _launchTheme(name, metadata) {
    const preloads = this._getPreloads(metadata, 'preload', 'theme');

    return this.preloader.load(preloads)
      .then(result => {
        return {
          elements: {},
          ...result,
          ...this.packages.find(pkg => pkg.metadata.name === name) || {}
        };
      });
  }

  /**
   * Returns preloads
   *
   * @private
   * @param {Metadata} metadata Application metadata
   * @param {string} fileType Files type
   * @param {string} packageType Package type
   * @return {string[]}
   */
  _getPreloads(metadata, fileType, packageType) {
    return metadataFilesToFilenames(
      filterMetadataFilesByType(metadata.files, fileType)
    ).map(f => this.core.url(f, {}, {type: packageType, ...metadata}));
  }

  /**
   * Wrapper for launching a (application) package
   *
   * @private
   * @param {string} name Package name
   * @param {{foo: *}} args Launch arguments
   * @param {PackageLaunchOptions} options Launch options
   * @return {Promise<Application>}
   */
  _launch(name, metadata, args, options) {
    const _ = this.core.make('osjs/locale').translate;
    const canLaunch = createPackageAvailabilityCheck(this.core);

    const dialog = e => {
      if (this.core.has('osjs/dialog')) {
        this.core.make('osjs/dialog', 'alert', {
          type: 'error',
          title: _('ERR_PACKAGE_EXCEPTION', name),
          message: _('ERR_PACKAGE_EXCEPTION', name),
          error: e
        }, () => { /* noop */});
      } else {
        alert(`${_('ERR_PACKAGE_EXCEPTION', name)}: ${e.stack || e}`);
      }
    };

    const fail = err => {
      this.core.emit('osjs/application:launched', name, false);
      this.core.emit(`osjs/application:${name}:launched`, false);

      dialog(err);

      throw new Error(err);
    };

    const preloads = this._getPreloads(metadata, 'preload', 'apps');

    const create = found => {
      let app;

      try {
        console.group('Packages::_launch()');
        app = found.callback(this.core, args, options, found.metadata);

        if (app instanceof Application) {
          app.on('destroy', () => {
            const foundIndex = this._running.findIndex(n => n === name);
            if (foundIndex !== -1) {
              this._running.splice(foundIndex, 1);
            }
          });
        } else {
          logger.warn('The application', name, 'did not return an Application instance from registration');
        }
      } catch (e) {
        dialog(e);

        logger.warn('Exception when launching', name, e);
      } finally {
        this.core.emit('osjs/application:launched', name, app);
        this.core.emit(`osjs/application:${name}:launched`, app);
        console.groupEnd();
      }

      return app;
    };

    if (!canLaunch(metadata)) {
      fail(_('ERR_PACKAGE_PERMISSION_DENIED', name));
    }

    return this.preloader.load(preloads, options.forcePreload === true)
      .then(({errors}) => {
        if (errors.length) {
          fail(_('ERR_PACKAGE_LOAD', name, errors.join(', ')));
        }

        const found = this.packages.find(pkg => pkg.metadata.name === name);
        if (!found) {
          fail(_('ERR_PACKAGE_NO_RUNTIME', name));
        }

        return create(found);
      });
  }

  /**
   * Autostarts tagged packages
   * @private
   */
  _autostart() {
    const meta = this.metadata
      .filter(pkg => pkg.autostart === true);

    const configured = this.core
      .config('application.autostart', [])
      .map(value => typeof value === 'string' ? {name: value} : value);

    [...meta, ...configured].forEach(({name, args}) => this.launch(name, args || {}));
  }

  /**
   * Registers a package
   *
   * @param {string} name Package name
   * @param {Function} callback Callback function to construct application instance
   * @throws {Error}
   */
  register(name, callback) {
    logger.debug('Packages::register()', name);

    const _ = this.core.make('osjs/locale').translate;
    const metadata = this.metadata.find(pkg => pkg.name === name);
    if (!metadata) {
      throw new Error(_('ERR_PACKAGE_NO_METADATA', name));
    }

    const foundIndex = this.packages.findIndex(pkg => pkg.metadata.name === name);
    if (foundIndex !== -1) {
      this.packages.splice(foundIndex, 1);
    }

    this.packages.push({
      metadata,
      callback
    });
  }

  /**
   * Adds a set of packages
   * @param {PackageMetadata[]} list Package list
   * @return {PackageMetadata[]} Current list of packages
   */
  addPackages(list) {
    if (list instanceof Array) {
      const override = this.core.config('packages.overrideMetadata', {});
      const append = createManifestFromArray(list)
        .map(meta => override[meta.name] ? {...meta, ...override[meta.name]} : meta);

      this.metadata = [...this.metadata, ...append];
    }

    return this.getPackages();
  }

  /**
   * Gets a list of packages (metadata)
   * @param {Function} [filter] A filter function
   * @return {PackageMetadata[]}
   */
  getPackages(filter) {
    filter = filter || (() => true);

    const metadata = this.metadata.map(m => ({...m}));
    const hidden = this.core.config('packages.hidden', []);
    const filterAvailable = createPackageAvailabilityCheck(this.core);

    const filterConfigHidden = iter => hidden instanceof Array
      ? hidden.indexOf(iter.name) === -1
      : true;

    return metadata
      .filter(filterAvailable)
      .filter(filterConfigHidden)
      .filter(filter);
  }

  /**
   * Gets a list of packages compatible with the given mime type
   * @param {string} mimeType MIME Type
   * @see PackageManager#getPackages
   * @return {PackageMetadata[]}
   */
  getCompatiblePackages(mimeType) {
    return this.getPackages(meta => {
      if (meta.mimes && !meta.hidden) {
        return !!meta.mimes.find(mime => {
          try {
            const re = new RegExp(mime);
            return re.test(mimeType);
          } catch (e) {
            logger.warn('Compability check failed', e);
          }

          return mime === mimeType;
        });
      }

      return false;
    });
  }

  /**
   * Preloads background files of a set of packages
   * @param {PackageMetadata[]} list Package list
   */
  _preloadBackgroundFiles(list) {
    const backgroundFiles = list.reduce((filenames, iter) => [
      ...filenames,
      ...this._getPreloads(iter, 'background', 'apps')
    ], []);

    return this.preloader.load(backgroundFiles)
      .then(({errors = []}) => {
        errors.forEach(error => logger.error(error));
      });
  }

  /**
   * Gets a list of running packages
   * @return {string[]}
   */
  running() {
    return this._running;
  }

  /**
   * Gets the package metadata for a given package name
   * @param {string} name
   * @returns {PackageMetadata}
   */
  getMetadataFromName(name) {
    const found = this.metadata.find(pkg => pkg.name === name);
    return found ? {...found} : null;
  }
}