scratch-vm/src/extension-support/extension-manager.js

370 lines
16 KiB
JavaScript

const dispatch = require('../dispatch/central-dispatch');
const log = require('../util/log');
const maybeFormatMessage = require('../util/maybe-format-message');
const BlockType = require('./block-type');
// 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
const Scratch3PenBlocks = require('../extensions/scratch3_pen');
const Scratch3WeDo2Blocks = require('../extensions/scratch3_wedo2');
const Scratch3MusicBlocks = require('../extensions/scratch3_music');
const Scratch3MicroBitBlocks = require('../extensions/scratch3_microbit');
const Scratch3SpeakBlocks = require('../extensions/scratch3_speak');
const Scratch3TranslateBlocks = require('../extensions/scratch3_translate');
const Scratch3VideoSensingBlocks = require('../extensions/scratch3_video_sensing');
const Scratch3SpeechBlocks = require('../extensions/scratch3_speech');
const builtinExtensions = {
pen: Scratch3PenBlocks,
wedo2: Scratch3WeDo2Blocks,
music: Scratch3MusicBlocks,
microbit: Scratch3MicroBitBlocks,
speak: Scratch3SpeakBlocks,
translate: Scratch3TranslateBlocks,
videoSensing: Scratch3VideoSensingBlocks,
speech: Scratch3SpeechBlocks
};
/**
* @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} ConvertedBlockInfo - Raw extension block data paired with processed data ready for scratch-blocks
* @property {ExtensionBlockMetadata} info - the raw block info
* @property {object} json - the scratch-blocks JSON definition for this block
* @property {string} xml - the scratch-blocks XML definition for this block
*/
/**
* @typedef {object} CategoryInfo - Information about a block category
* @property {string} id - the unique ID of this category
* @property {string} name - the human-readable name of this category
* @property {string|undefined} blockIconURI - optional URI for the block icon image
* @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
* @property {Array.<ConvertedBlockInfo>} blocks - the blocks, separators, etc. in this category
* @property {Array.<object>} menus - the menus provided by this category
*/
/**
* @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
*/
class ExtensionManager {
constructor (runtime) {
/**
* The ID number to provide to the next extension worker.
* @type {int}
*/
this.nextExtensionWorker = 0;
/**
* 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>}
*/
this.pendingWorkers = [];
/**
* Set of loaded extension URLs/IDs (equivalent for built-in extensions).
* @type {Set.<string>}
* @private
*/
this._loadedExtensions = new Map();
/**
* 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;
dispatch.setService('extensions', this).catch(e => {
log.error(`ExtensionManager was unable to register extension service: ${JSON.stringify(e)}`);
});
}
/**
* 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.
* @param {string} extensionID - the ID of the extension.
* @returns {boolean} - true if loaded, false otherwise.
*/
isExtensionLoaded (extensionID) {
return this._loadedExtensions.has(extensionID);
}
/**
* 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
* @returns {Promise} resolved once the extension is loaded and initialized or rejected on failure
*/
loadExtensionURL (extensionURL) {
if (builtinExtensions.hasOwnProperty(extensionURL)) {
/** @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));
}
const extension = builtinExtensions[extensionURL];
const extensionInstance = new extension(this.runtime);
return this._registerInternalExtension(extensionInstance).then(serviceName => {
this._loadedExtensions.set(extensionURL, serviceName);
});
}
return new Promise((resolve, reject) => {
// If we `require` this at the global level it breaks non-webpack targets, including tests
const ExtensionWorker = require('worker-loader?name=extension-worker.js!./extension-worker');
this.pendingExtensions.push({extensionURL, resolve, reject});
dispatch.addWorker(new ExtensionWorker());
});
}
/**
* 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)}`);
});
});
}
allocateWorker () {
const id = this.nextExtensionWorker++;
const workerInfo = this.pendingExtensions.shift();
this.pendingWorkers[id] = workerInfo;
return [id, workerInfo.extensionURL];
}
/**
* Collect extension metadata from the specified service and begin the extension registration process.
* @param {string} serviceName - the name of the service hosting the extension.
*/
registerExtensionService (serviceName) {
dispatch.call(serviceName, 'getInfo').then(info => {
this._registerExtensionInfo(serviceName, info);
});
}
/**
* 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.
*/
onWorkerInit (id, e) {
const workerInfo = this.pendingWorkers[id];
delete this.pendingWorkers[id];
if (e) {
workerInfo.reject(e);
} else {
workerInfo.resolve(id);
}
}
/**
* Register an internal (non-Worker) extension object
* @param {object} extensionObject - the extension object to register
* @returns {Promise} resolved once the extension is fully registered or rejected on failure
*/
_registerInternalExtension (extensionObject) {
const extensionInfo = extensionObject.getInfo();
const fakeWorkerId = this.nextExtensionWorker++;
const serviceName = `extension_${fakeWorkerId}_${extensionInfo.id}`;
return dispatch.setService(serviceName, extensionObject)
.then(() => {
dispatch.call('extensions', 'registerExtensionService', serviceName);
return serviceName;
});
}
/**
* 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
*/
_registerExtensionInfo (serviceName, extensionInfo) {
extensionInfo = this._prepareExtensionInfo(serviceName, extensionInfo);
dispatch.call('runtime', '_registerExtensionPrimitives', extensionInfo).catch(e => {
log.error(`Failed to register primitives for extension on service ${serviceName}:`, e);
});
}
/**
* 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.
* @param {string} serviceName - the name of the service hosting this extension block
* @param {ExtensionInfo} extensionInfo - the extension info to be sanitized
* @returns {ExtensionInfo} - a new extension info object with cleaned-up values
* @private
*/
_prepareExtensionInfo (serviceName, extensionInfo) {
extensionInfo = Object.assign({}, extensionInfo);
if (!/^[a-z0-9]+$/i.test(extensionInfo.id)) {
throw new Error('Invalid extension id');
}
extensionInfo.name = extensionInfo.name || extensionInfo.id;
extensionInfo.blocks = extensionInfo.blocks || [];
extensionInfo.targetTypes = extensionInfo.targetTypes || [];
extensionInfo.blocks = extensionInfo.blocks.reduce((results, blockInfo) => {
try {
let result;
switch (blockInfo) {
case '---': // separator
result = '---';
break;
default: // an ExtensionBlockMetadata object
result = this._prepareBlockInfo(serviceName, blockInfo);
break;
}
results.push(result);
} catch (e) {
// TODO: more meaningful error reporting
log.error(`Error processing block: ${e.message}, Block:\n${JSON.stringify(blockInfo)}`);
}
return results;
}, []);
extensionInfo.menus = extensionInfo.menus || [];
extensionInfo.menus = this._prepareMenuInfo(serviceName, extensionInfo.menus);
return extensionInfo;
}
/**
* Prepare extension menus. e.g. setup binding for dynamic menu functions.
* @param {string} serviceName - the name of the service hosting this extension block
* @param {Array.<MenuInfo>} menus - the menu defined by the extension.
* @returns {Array.<MenuInfo>} - a menuInfo object with all preprocessing done.
* @private
*/
_prepareMenuInfo (serviceName, menus) {
const menuNames = Object.getOwnPropertyNames(menus);
for (let i = 0; i < menuNames.length; i++) {
const item = menuNames[i];
// 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') {
const serviceObject = dispatch.services[serviceName];
const menuName = menus[item];
menus[item] = this._getExtensionMenuItems.bind(this, serviceObject, menuName);
}
}
return menus;
}
/**
* Fetch the items for a particular extension menu, providing the target ID for context.
* @param {object} extensionObject - the extension object providing the menu.
* @param {string} menuName - the name of the menu function to call.
* @returns {Array} menu items ready for scratch-blocks.
* @private
*/
_getExtensionMenuItems (extensionObject, menuName) {
// Fetch the items appropriate for the target currently being edited. This assumes that menus only
// collect items when opened by the user while editing a particular target.
const editingTarget = this.runtime.getEditingTarget() || this.runtime.getTargetForStage();
const editingTargetID = editingTarget ? editingTarget.id : null;
const extensionMessageContext = this.runtime.makeMessageContextForTarget(editingTarget);
// TODO: Fix this to use dispatch.call when extensions are running in workers.
const menuFunc = extensionObject[menuName];
const menuItems = menuFunc.call(extensionObject, editingTargetID).map(
item => {
item = maybeFormatMessage(item, extensionMessageContext);
if (typeof item === 'object') {
return [
maybeFormatMessage(item.text, extensionMessageContext),
item.value
];
}
return item;
});
if (!menuItems || menuItems.length < 1) {
throw new Error(`Extension menu returned no items: ${menuName}`);
}
return menuItems;
}
/**
* Apply defaults for optional block fields.
* @param {string} serviceName - the name of the service hosting this extension block
* @param {ExtensionBlockMetadata} blockInfo - the block info from the extension
* @returns {ExtensionBlockMetadata} - a new block info object which has values for all relevant optional fields.
* @private
*/
_prepareBlockInfo (serviceName, blockInfo) {
blockInfo = Object.assign({}, {
blockType: BlockType.COMMAND,
terminal: false,
blockAllThreads: false,
arguments: {}
}, blockInfo);
blockInfo.opcode = this._sanitizeID(blockInfo.opcode);
blockInfo.text = blockInfo.text || blockInfo.opcode;
if (blockInfo.blockType !== BlockType.EVENT) {
blockInfo.func = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode;
/**
* 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 Scratch "tick"
*/
if (dispatch._isRemoteService(serviceName)) {
blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func);
} else {
const serviceObject = dispatch.services[serviceName];
const func = serviceObject[blockInfo.func];
if (func) {
blockInfo.func = func.bind(serviceObject);
} else if (blockInfo.blockType !== BlockType.EVENT) {
throw new Error(`Could not find extension block function called ${blockInfo.func}`);
}
}
} else if (blockInfo.func) {
log.warn(`Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}`);
}
return blockInfo;
}
}
module.exports = ExtensionManager;