2017-08-04 14:25:17 -04:00
|
|
|
const dispatch = require('../dispatch/central-dispatch');
|
2017-07-21 16:46:08 -04:00
|
|
|
const log = require('../util/log');
|
|
|
|
|
|
|
|
const BlockType = require('./block-type');
|
|
|
|
|
2017-10-04 15:16:27 -04:00
|
|
|
// These extensions are currently built into the VM repository but should not be loaded at startup.
|
|
|
|
// TODO: move these out into a separate repository?
|
|
|
|
// TODO: change extension spec so that library info, including extension ID, can be collected through static methods
|
2017-12-01 10:31:04 -05:00
|
|
|
const Scratch3PenBlocks = require('../extensions/scratch3_pen');
|
|
|
|
const Scratch3WeDo2Blocks = require('../extensions/scratch3_wedo2');
|
2017-11-13 16:22:37 -05:00
|
|
|
const Scratch3MusicBlocks = require('../extensions/scratch3_music');
|
2017-10-04 15:16:27 -04:00
|
|
|
const builtinExtensions = {
|
|
|
|
pen: Scratch3PenBlocks,
|
2017-11-07 11:27:33 -05:00
|
|
|
wedo2: Scratch3WeDo2Blocks,
|
|
|
|
music: Scratch3MusicBlocks
|
2017-10-04 15:16:27 -04:00
|
|
|
};
|
|
|
|
|
2017-07-21 16:46:08 -04:00
|
|
|
/**
|
|
|
|
* @typedef {object} ArgumentInfo - Information about an extension block argument
|
|
|
|
* @property {ArgumentType} type - the type of value this argument can take
|
|
|
|
* @property {*|undefined} default - the default value of this argument (default: blank)
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef {object} BlockInfo - Information about an extension block
|
|
|
|
* @property {string} opcode - the block opcode
|
|
|
|
* @property {string|object} text - the human-readable text on this block
|
|
|
|
* @property {BlockType|undefined} blockType - the type of block (default: BlockType.COMMAND)
|
|
|
|
* @property {int|undefined} branchCount - the number of branches this block controls, if conditional (default: 0)
|
|
|
|
* @property {Boolean|undefined} isTerminal - true if this block ends a stack (default: false)
|
|
|
|
* @property {Boolean|undefined} blockAllThreads - true if all threads must wait for this block to run (default: false)
|
|
|
|
* @property {object.<string,ArgumentInfo>|undefined} arguments - information about this block's arguments, if any
|
2017-08-04 14:25:17 -04:00
|
|
|
* @property {string|Function|undefined} func - the method for this block on the extension service (default: opcode)
|
2017-07-21 16:46:08 -04:00
|
|
|
* @property {Array.<string>|undefined} filter - the list of targets for which this block should appear (default: all)
|
2017-11-01 11:07:26 -04:00
|
|
|
* @property {Boolean|undefined} hideFromPalette - true if should not be appear in the palette. (default false)
|
2017-07-21 16:46:08 -04:00
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef {object} CategoryInfo - Information about a block category
|
|
|
|
* @property {string} id - the unique ID of this category
|
|
|
|
* @property {string} color1 - the primary color for this category, in '#rrggbb' format
|
|
|
|
* @property {string} color2 - the secondary color for this category, in '#rrggbb' format
|
|
|
|
* @property {string} color3 - the tertiary color for this category, in '#rrggbb' format
|
2017-09-04 18:48:51 -04:00
|
|
|
* @property {Array.<BlockInfo>} block - the blocks in this category
|
2017-08-22 17:28:38 -04:00
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef {object} PendingExtensionWorker - Information about an extension worker still initializing
|
|
|
|
* @property {string} extensionURL - the URL of the extension to be loaded by this worker
|
|
|
|
* @property {Function} resolve - function to call on successful worker startup
|
|
|
|
* @property {Function} reject - function to call on failed worker startup
|
2017-07-21 16:46:08 -04:00
|
|
|
*/
|
|
|
|
|
2017-07-14 15:37:59 -04:00
|
|
|
class ExtensionManager {
|
2017-10-04 15:16:27 -04:00
|
|
|
constructor (runtime) {
|
2017-07-14 15:37:59 -04:00
|
|
|
/**
|
|
|
|
* The ID number to provide to the next extension worker.
|
|
|
|
* @type {int}
|
|
|
|
*/
|
|
|
|
this.nextExtensionWorker = 0;
|
|
|
|
|
|
|
|
/**
|
2017-08-22 17:28:38 -04:00
|
|
|
* FIFO queue of extensions which have been requested but not yet loaded in a worker,
|
|
|
|
* along with promise resolution functions to call once the worker is ready or failed.
|
|
|
|
*
|
|
|
|
* @type {Array.<PendingExtensionWorker>}
|
|
|
|
*/
|
|
|
|
this.pendingExtensions = [];
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Map of worker ID to workers which have been allocated but have not yet finished initialization.
|
|
|
|
* @type {Array.<PendingExtensionWorker>}
|
2017-07-14 15:37:59 -04:00
|
|
|
*/
|
2017-08-22 17:28:38 -04:00
|
|
|
this.pendingWorkers = [];
|
2017-07-14 15:37:59 -04:00
|
|
|
|
2017-10-31 14:32:21 -04:00
|
|
|
/**
|
2017-10-31 15:35:18 -04:00
|
|
|
* Set of loaded extension URLs/IDs (equivalent for built-in extensions).
|
2017-10-31 14:32:21 -04:00
|
|
|
* @type {Set.<string>}
|
|
|
|
* @private
|
|
|
|
*/
|
2017-12-11 15:41:45 -05:00
|
|
|
this._loadedExtensions = new Map();
|
2017-10-31 14:32:21 -04:00
|
|
|
|
2017-10-04 15:16:27 -04:00
|
|
|
/**
|
|
|
|
* Keep a reference to the runtime so we can construct internal extension objects.
|
|
|
|
* TODO: remove this in favor of extensions accessing the runtime as a service.
|
|
|
|
* @type {Runtime}
|
|
|
|
*/
|
|
|
|
this.runtime = runtime;
|
|
|
|
|
2017-08-04 14:25:17 -04:00
|
|
|
dispatch.setService('extensions', this).catch(e => {
|
|
|
|
log.error(`ExtensionManager was unable to register extension service: ${JSON.stringify(e)}`);
|
|
|
|
});
|
2017-07-14 15:37:59 -04:00
|
|
|
}
|
|
|
|
|
2017-10-31 14:32:21 -04:00
|
|
|
/**
|
|
|
|
* Check whether an extension is registered or is in the process of loading. This is intended to control loading or
|
|
|
|
* adding extensions so it may return `true` before the extension is ready to be used. Use the promise returned by
|
|
|
|
* `loadExtensionURL` if you need to wait until the extension is truly ready.
|
2017-10-31 15:35:18 -04:00
|
|
|
* @param {string} extensionID - the ID of the extension.
|
2017-10-31 14:32:21 -04:00
|
|
|
* @returns {boolean} - true if loaded, false otherwise.
|
|
|
|
*/
|
|
|
|
isExtensionLoaded (extensionID) {
|
|
|
|
return this._loadedExtensions.has(extensionID);
|
|
|
|
}
|
|
|
|
|
2017-08-22 17:28:38 -04:00
|
|
|
/**
|
2017-10-04 15:16:27 -04:00
|
|
|
* Load an extension by URL or internal extension ID
|
|
|
|
* @param {string} extensionURL - the URL for the extension to load OR the ID of an internal extension
|
2017-08-22 17:28:38 -04:00
|
|
|
* @returns {Promise} resolved once the extension is loaded and initialized or rejected on failure
|
|
|
|
*/
|
2017-07-14 15:37:59 -04:00
|
|
|
loadExtensionURL (extensionURL) {
|
2017-10-04 15:16:27 -04:00
|
|
|
if (builtinExtensions.hasOwnProperty(extensionURL)) {
|
2017-10-31 15:35:18 -04:00
|
|
|
/** @TODO dupe handling for non-builtin extensions. See commit 670e51d33580e8a2e852b3b038bb3afc282f81b9 */
|
|
|
|
if (this.isExtensionLoaded(extensionURL)) {
|
|
|
|
const message = `Rejecting attempt to load a second extension with ID ${extensionURL}`;
|
|
|
|
log.warn(message);
|
|
|
|
return Promise.reject(new Error(message));
|
|
|
|
}
|
|
|
|
|
2017-10-04 15:16:27 -04:00
|
|
|
const extension = builtinExtensions[extensionURL];
|
|
|
|
const extensionInstance = new extension(this.runtime);
|
2017-12-11 15:41:45 -05:00
|
|
|
return this._registerInternalExtension(extensionInstance).then(serviceName => {
|
|
|
|
this._loadedExtensions.set(extensionURL, serviceName);
|
2017-10-31 16:00:19 -04:00
|
|
|
});
|
2017-10-04 15:16:27 -04:00
|
|
|
}
|
|
|
|
|
2017-08-22 17:28:38 -04:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
// If we `require` this at the global level it breaks non-webpack targets, including tests
|
2017-08-29 14:43:09 -04:00
|
|
|
const ExtensionWorker = require('worker-loader?name=extension-worker.js!./extension-worker');
|
2017-07-14 15:37:59 -04:00
|
|
|
|
2017-08-22 17:28:38 -04:00
|
|
|
this.pendingExtensions.push({extensionURL, resolve, reject});
|
|
|
|
dispatch.addWorker(new ExtensionWorker());
|
|
|
|
});
|
2017-07-14 15:37:59 -04:00
|
|
|
}
|
|
|
|
|
2017-12-11 15:41:45 -05:00
|
|
|
/**
|
|
|
|
* regenerate blockinfo for any loaded extensions
|
|
|
|
*/
|
|
|
|
refreshBlocks () {
|
|
|
|
this._loadedExtensions.forEach(serviceName => {
|
|
|
|
dispatch.call(serviceName, 'getInfo')
|
|
|
|
.then(info => {
|
|
|
|
dispatch.call('runtime', '_refreshExtensionPrimitives', info);
|
|
|
|
})
|
|
|
|
.catch(e => {
|
|
|
|
log.error(`Failed to refresh buildtin extension primitives: ${JSON.stringify(e)}`);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2017-07-14 15:37:59 -04:00
|
|
|
allocateWorker () {
|
|
|
|
const id = this.nextExtensionWorker++;
|
2017-08-22 17:28:38 -04:00
|
|
|
const workerInfo = this.pendingExtensions.shift();
|
|
|
|
this.pendingWorkers[id] = workerInfo;
|
|
|
|
return [id, workerInfo.extensionURL];
|
2017-07-14 15:37:59 -04:00
|
|
|
}
|
2017-07-21 16:46:08 -04:00
|
|
|
|
2017-10-31 14:28:56 -04:00
|
|
|
/**
|
|
|
|
* Collect extension metadata from the specified service and begin the extension registration process.
|
|
|
|
* @param {string} serviceName - the name of the service hosting the extension.
|
|
|
|
*/
|
2017-07-21 16:46:08 -04:00
|
|
|
registerExtensionService (serviceName) {
|
2017-08-04 14:25:17 -04:00
|
|
|
dispatch.call(serviceName, 'getInfo').then(info => {
|
2017-07-21 16:46:08 -04:00
|
|
|
this._registerExtensionInfo(serviceName, info);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2017-10-31 14:28:56 -04:00
|
|
|
/**
|
|
|
|
* Called by an extension worker to indicate that the worker has finished initialization.
|
|
|
|
* @param {int} id - the worker ID.
|
|
|
|
* @param {*?} e - the error encountered during initialization, if any.
|
|
|
|
*/
|
2017-08-22 17:28:38 -04:00
|
|
|
onWorkerInit (id, e) {
|
|
|
|
const workerInfo = this.pendingWorkers[id];
|
|
|
|
delete this.pendingWorkers[id];
|
|
|
|
if (e) {
|
|
|
|
workerInfo.reject(e);
|
|
|
|
} else {
|
|
|
|
workerInfo.resolve(id);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-09-27 00:00:59 -04:00
|
|
|
/**
|
|
|
|
* Register an internal (non-Worker) extension object
|
|
|
|
* @param {object} extensionObject - the extension object to register
|
2017-10-02 15:02:44 -04:00
|
|
|
* @returns {Promise} resolved once the extension is fully registered or rejected on failure
|
2017-09-27 00:00:59 -04:00
|
|
|
*/
|
|
|
|
_registerInternalExtension (extensionObject) {
|
|
|
|
const extensionInfo = extensionObject.getInfo();
|
2017-10-31 14:28:56 -04:00
|
|
|
const fakeWorkerId = this.nextExtensionWorker++;
|
|
|
|
const serviceName = `extension.${fakeWorkerId}.${extensionInfo.id}`;
|
2017-10-02 15:02:44 -04:00
|
|
|
return dispatch.setService(serviceName, extensionObject)
|
2017-12-11 15:41:45 -05:00
|
|
|
.then(() => {
|
|
|
|
dispatch.call('extensions', 'registerExtensionService', serviceName);
|
|
|
|
return serviceName;
|
|
|
|
});
|
2017-09-27 00:00:59 -04:00
|
|
|
}
|
|
|
|
|
2017-10-31 14:28:56 -04:00
|
|
|
/**
|
|
|
|
* Sanitize extension info then register its primitives with the VM.
|
|
|
|
* @param {string} serviceName - the name of the service hosting the extension
|
|
|
|
* @param {ExtensionInfo} extensionInfo - the extension's metadata
|
|
|
|
* @private
|
|
|
|
*/
|
2017-07-21 16:46:08 -04:00
|
|
|
_registerExtensionInfo (serviceName, extensionInfo) {
|
2017-08-04 14:25:17 -04:00
|
|
|
extensionInfo = this._prepareExtensionInfo(serviceName, extensionInfo);
|
|
|
|
dispatch.call('runtime', '_registerExtensionPrimitives', extensionInfo).catch(e => {
|
|
|
|
log.error(`Failed to register primitives for extension on service ${serviceName}: ${JSON.stringify(e)}`);
|
|
|
|
});
|
2017-07-21 16:46:08 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Modify the provided text as necessary to ensure that it may be used as an attribute value in valid XML.
|
|
|
|
* @param {string} text - the text to be sanitized
|
|
|
|
* @returns {string} - the sanitized text
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
_sanitizeID (text) {
|
|
|
|
return text.toString().replace(/[<"&]/, '_');
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Apply minor cleanup and defaults for optional extension fields.
|
|
|
|
* TODO: make the ID unique in cases where two copies of the same extension are loaded.
|
2017-08-04 14:25:17 -04:00
|
|
|
* @param {string} serviceName - the name of the service hosting this extension block
|
2017-07-21 16:46:08 -04:00
|
|
|
* @param {ExtensionInfo} extensionInfo - the extension info to be sanitized
|
|
|
|
* @returns {ExtensionInfo} - a new extension info object with cleaned-up values
|
|
|
|
* @private
|
|
|
|
*/
|
2017-08-04 14:25:17 -04:00
|
|
|
_prepareExtensionInfo (serviceName, extensionInfo) {
|
2017-07-21 16:46:08 -04:00
|
|
|
extensionInfo = Object.assign({}, extensionInfo);
|
|
|
|
extensionInfo.id = this._sanitizeID(extensionInfo.id);
|
|
|
|
extensionInfo.name = extensionInfo.name || extensionInfo.id;
|
|
|
|
extensionInfo.blocks = extensionInfo.blocks || [];
|
|
|
|
extensionInfo.targetTypes = extensionInfo.targetTypes || [];
|
2017-08-04 14:25:17 -04:00
|
|
|
extensionInfo.blocks = extensionInfo.blocks.reduce((result, blockInfo) => {
|
|
|
|
try {
|
|
|
|
result.push(this._prepareBlockInfo(serviceName, blockInfo));
|
|
|
|
} catch (e) {
|
|
|
|
// TODO: more meaningful error reporting
|
2017-10-06 16:37:59 -04:00
|
|
|
log.error(`Error processing block: ${e.message}, Block:\n${JSON.stringify(blockInfo)}`);
|
2017-08-04 14:25:17 -04:00
|
|
|
}
|
|
|
|
return result;
|
|
|
|
}, []);
|
2018-01-26 17:36:36 -05:00
|
|
|
extensionInfo.menus = extensionInfo.menus || [];
|
|
|
|
extensionInfo.menus = this._prepareMenuInfo(serviceName, extensionInfo.menus);
|
2017-07-21 16:46:08 -04:00
|
|
|
return extensionInfo;
|
|
|
|
}
|
2018-01-26 17:36:36 -05:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Prepare extension menus. e.g. setup binding for dynamic menu functions.
|
|
|
|
* @param {string} serviceName - the name of the service hosting this extension block
|
2018-02-16 13:49:26 -05:00
|
|
|
* @param {Array.<MenuInfo>} menus - the menu defined by the extension.
|
2018-01-26 17:36:36 -05:00
|
|
|
* @returns {Array.<MenuInfo>} - a menuInfo object with all preprocessing done.
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
_prepareMenuInfo (serviceName, menus) {
|
2018-02-16 13:49:26 -05:00
|
|
|
const menuNames = Object.getOwnPropertyNames(menus);
|
2018-01-26 17:36:36 -05:00
|
|
|
for (let i = 0; i < menuNames.length; i++) {
|
2018-02-16 13:49:26 -05:00
|
|
|
const item = menuNames[i];
|
2018-01-26 17:36:36 -05:00
|
|
|
// If the value is a string, it should be the name of a function in the
|
|
|
|
// extension object to call to populate the menu whenever it is opened.
|
|
|
|
// Set up the binding for the function object here so
|
|
|
|
// we can use it later when converting the menu for Scratch Blocks.
|
|
|
|
if (typeof menus[item] === 'string') {
|
2018-02-16 13:49:26 -05:00
|
|
|
const serviceObject = dispatch.services[serviceName];
|
2018-03-02 14:07:22 -05:00
|
|
|
// TODO: Fix this to use dispatch.call when extensions are running in workers.
|
|
|
|
menus[item] = serviceObject[menus[item]].bind(serviceObject);
|
2018-01-26 17:36:36 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return menus;
|
|
|
|
}
|
2017-07-21 16:46:08 -04:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Apply defaults for optional block fields.
|
2017-08-04 14:25:17 -04:00
|
|
|
* @param {string} serviceName - the name of the service hosting this extension block
|
2017-07-21 16:46:08 -04:00
|
|
|
* @param {BlockInfo} blockInfo - the block info from the extension
|
|
|
|
* @returns {BlockInfo} - a new block info object which has values for all relevant optional fields.
|
|
|
|
* @private
|
|
|
|
*/
|
2017-08-04 14:25:17 -04:00
|
|
|
_prepareBlockInfo (serviceName, blockInfo) {
|
2017-07-21 16:46:08 -04:00
|
|
|
blockInfo = Object.assign({}, {
|
|
|
|
blockType: BlockType.COMMAND,
|
|
|
|
terminal: false,
|
|
|
|
blockAllThreads: false,
|
|
|
|
arguments: {}
|
|
|
|
}, blockInfo);
|
|
|
|
blockInfo.opcode = this._sanitizeID(blockInfo.opcode);
|
|
|
|
blockInfo.func = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode;
|
2017-09-27 00:00:59 -04:00
|
|
|
blockInfo.text = blockInfo.text || blockInfo.opcode;
|
2017-10-04 18:40:25 -04:00
|
|
|
|
|
|
|
/**
|
|
|
|
* This is only here because the VM performs poorly when blocks return promises.
|
|
|
|
* @TODO make it possible for the VM to resolve a promise and continue during the same frame.
|
|
|
|
*/
|
|
|
|
if (dispatch._isRemoteService(serviceName)) {
|
|
|
|
blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func);
|
|
|
|
} else {
|
|
|
|
const serviceObject = dispatch.services[serviceName];
|
|
|
|
blockInfo.func = serviceObject[blockInfo.func].bind(serviceObject);
|
|
|
|
}
|
2017-07-21 16:46:08 -04:00
|
|
|
return blockInfo;
|
|
|
|
}
|
2017-07-14 15:37:59 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = ExtensionManager;
|