From 7fb7f0dc7bdae472f4e627611f9f36d3bb29b0fc Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford Date: Fri, 14 Jul 2017 15:37:59 -0400 Subject: [PATCH] WIP Extension Manager When asked to load an extension, the Extension Manager starts up a new Worker. That worker runs the message dispatch system as well as an instance of the new `ExtensionWorker` class, which will load the desired extension and register it with the extension system. Extensions, placed in `./src/extensions/*.js`, are now processed by Webpack as separate entry points and packed into an `extensions` subdirectory in the output. Still to do: query an extension's information, including the blocks it provides, and register that information with the VM, GUI, etc. --- package.json | 3 +- src/extension-support/extension-manager.js | 45 +++++ src/extension-support/extension-worker.js | 38 +++++ src/extensions/.eslintrc.js | 8 + src/extensions/example-extension.js | 183 +++++++++++++++++++++ webpack.config.js | 19 ++- 6 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 src/extension-support/extension-manager.js create mode 100644 src/extension-support/extension-worker.js create mode 100644 src/extensions/.eslintrc.js create mode 100644 src/extensions/example-extension.js diff --git a/package.json b/package.json index 10535ef70..2dac26a58 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "tap": "^10.2.0", "tiny-worker": "^2.1.1", "webpack": "^2.4.1", - "webpack-dev-server": "^2.4.1" + "webpack-dev-server": "^2.4.1", + "worker-loader": "0.8.1" } } diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js new file mode 100644 index 000000000..a5b28147e --- /dev/null +++ b/src/extension-support/extension-manager.js @@ -0,0 +1,45 @@ +const centralDispatch = require('../dispatch/central-dispatch'); + +class ExtensionManager { + constructor () { + /** + * The list of current active extension workers. + * @type {Array.} + */ + this.workers = []; + + /** + * The ID number to provide to the next extension worker. + * @type {int} + */ + this.nextExtensionWorker = 0; + + /** + * The list of extension URLs which have been requested but not yet loaded in a worker. + * @type {Array} + */ + this.pendingExtensionURLs = []; + + centralDispatch.setService('extensions', this); + } + + foo () { + this.loadExtensionURL('extensions/example-extension.js'); + } + + loadExtensionURL (extensionURL) { + // If we `require` this at the global level it breaks non-webpack targets, including tests + const ExtensionWorker = require('worker-loader!./extension-worker'); + + this.pendingExtensionURLs.push(extensionURL); + centralDispatch.addWorker(new ExtensionWorker()); + } + + allocateWorker () { + const id = this.nextExtensionWorker++; + const extFile = this.pendingExtensionURLs.shift(); + return [id, extFile]; + } +} + +module.exports = ExtensionManager; diff --git a/src/extension-support/extension-worker.js b/src/extension-support/extension-worker.js new file mode 100644 index 000000000..aec133baf --- /dev/null +++ b/src/extension-support/extension-worker.js @@ -0,0 +1,38 @@ +/* eslint-env worker */ + +const dispatch = require('../dispatch/worker-dispatch'); + +class ExtensionWorker { + constructor () { + this.nextExtensionId = 0; + + dispatch.waitForConnection.then(() => { + dispatch.call('extensions', 'allocateWorker').then(x => { + const [id, extension] = x; + this.workerId = id; + + // TODO: catch and report any exceptions here + importScripts(extension); + }); + }); + + this.extensions = []; + } + + register (extensionObject) { + const extensionId = this.nextExtensionId++; + this.extensions.push(extensionObject); + dispatch.setService(`extension.${this.workerId}.${extensionId}`, extensionObject); + } +} + +const extensionWorker = new ExtensionWorker(); + +global.Scratch = global.Scratch || {}; + +/** + * Expose only specific parts of the worker to extensions. + */ +global.Scratch.extensions = { + register: extensionWorker.register.bind(extensionWorker) +}; diff --git a/src/extensions/.eslintrc.js b/src/extensions/.eslintrc.js new file mode 100644 index 000000000..f84dadeb6 --- /dev/null +++ b/src/extensions/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + env: { + worker: true + }, + globals: { + Scratch: true + } +}; diff --git a/src/extensions/example-extension.js b/src/extensions/example-extension.js new file mode 100644 index 000000000..cc9312ab1 --- /dev/null +++ b/src/extensions/example-extension.js @@ -0,0 +1,183 @@ +class ExampleExtension { + /** + * @return {object} This extension's metadata. + */ + getInfo () { + return { + // Required: the machine-readable name of this extension. + // Will be used as the extension's namespace. + id: 'someBlocks', + + // Optional: the human-readable name of this extension as string. + // This and any other string to be displayed in the Scratch UI may either be + // a string or a call to `intlDefineMessage`; a plain string will not be + // translated whereas a call to `intlDefineMessage` will connect the string + // to the translation map (see below). The `intlDefineMessage` call is + // similar to `defineMessages` from `react-intl` in form, but will actually + // call some extension support code to do its magic. For example, we will + // internally namespace the messages such that two extensions could have + // messages with the same ID without colliding. + // See also: https://github.com/yahoo/react-intl/wiki/API#definemessages + name: 'Some Blocks', + + // Optional: URI for an icon for this extension. Data URI OK. + // If not present, use a generic icon. + // TODO: what file types are OK? All web images? Just PNG? + iconURI: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAFCAAAAACyOJm3AAAAFklEQVQYV2P4DwMMEMgAI/+DE' + + 'UIMBgAEWB7i7uidhAAAAABJRU5ErkJggg==', + + // Optional: Link to documentation content for this extension. + // If not present, offer no link. + docsURI: 'https://....', + + // Required: the list of blocks implemented by this extension, + // in the order intended for display. + blocks: [ + { + // Required: the machine-readable name of this operation. + // This will appear in project JSON. + opcode: 'myReporter', // becomes 'someBlocks.myReporter' + + // Required: the kind of block we're defining, from a predefined list: + // 'command' - a normal command block, like "move {} steps" + // 'reporter' - returns a value, like "direction" + // 'Boolean' - same as 'reporter' but returns a Boolean value + // 'hat' - starts a stack if its value is truthy + // 'conditional' - control flow, like "if {}" or "repeat {}" + // A 'conditional' block may return the one-based index of a branch + // to run, or it may return zero/falsy to run no branch. Each time a + // child branch finishes, the block is called again. This is only a + // slight change to the current model for control flow blocks, and is + // also compatible with returning true/false for an "if" or "repeat" + // block. + // TODO: Consider Blockly-like nextStatement, previousStatement, and + // output attributes as an alternative. Those are more flexible, but + // allow bad combinations. + blockType: 'reporter', + + // Required for conditional blocks, ignored for others: the number of + // child branches this block controls. An "if" or "repeat" block would + // specify a branch count of 1; an "if-else" block would specify a + // branch count of 2. + // TODO: should we support dynamic branch count for "switch"-likes? + branchCount: 0, + + // Optional, default false: whether or not this block ends a stack. + // The "forever" and "stop all" blocks would specify true here. + terminal: true, + + // Optional, default false: whether or not to block all threads while + // this block is busy. This is for things like the "touching color" + // block in compatibility mode, and is only needed if the VM runs in a + // worker. We might even consider omitting it from extension docs... + blockAllThreads: false, + + // Required: the human-readable text on this block, including argument + // placeholders. Argument placeholders should be in [MACRO_CASE] and + // must be [ENCLOSED_WITHIN_SQUARE_BRACKETS]. + text: 'letter [LETTER_NUM] of [TEXT]', + + // Required: describe each argument. + // Note that this is an array: the order of arguments will be used + arguments: { + // Required: the ID of the argument, which will be the name in the + // args object passed to the implementation function. + LETTER_NUM: { + // Required: type of the argument / shape of the block input + type: 'number', + + // Optional: the default value of the argument + default: 1 + }, + + // Required: the ID of the argument, which will be the name in the + // args object passed to the implementation function. + TEXT: { + // Required: type of the argument / shape of the block input + type: 'string', + + // Optional: the default value of the argument + default: 'text' + } + }, + + // Required: the function implementing this block. + func: this.myReporter, + + // Optional: list of target types for which this block should appear. + // If absent, assume it applies to all builtin targets -- that is: + // ['sprite', 'stage'] + filter: ['someBlocks.wedo2', 'sprite', 'stage'] + }, + { + // Another block... + } + ], + + // Optional: define extension-specific menus here. + menus: { + // Required: an identifier for this menu, unique within this extension. + menuA: [ + // Static menu: list items which should appear in the menu. + { + // Required: the value of the menu item when it is chosen. + value: 'itemId1', + + // Optional: the human-readable label for this item. + // Use `value` as the text if this is absent. + text: 'Item One' + }, + + // The simplest form of a list item is a string which will be used as + // both value and text. + 'itemId2' + ], + + // Dynamic menu: returns an array as above. + // Called each time the menu is opened. + menuB: this.getItemsForMenuB + }, + + // Optional: translations + translation_map: { + de: { + 'extensionName': 'Einige Blöcke', + 'myReporter': 'Buchstabe [LETTER_NUM] von [TEXT]', + 'myReporter.TEXT_default': 'Text', + 'menuA_item1': 'Artikel eins', + + // Dynamic menus can be translated too + 'menuB_example': 'Beispiel', + + // This message contains ICU placeholders (see `myReporter()` below) + 'myReporter.result': 'Buchstabe {LETTER_NUM} von {TEXT} ist {LETTER}.' + }, + it: { + // ... + } + }, + + // Optional: list new target type(s) provided by this extension. + targetTypes: [ + 'wedo2', // automatically transformed to 'someBlocks.wedo2' + 'speech' // automatically transformed to 'someBlocks.speech' + ] + }; + } + + /** + * Implement myReporter. + * @param {object} args - the block's arguments. + * @property {number} LETTER_NUM - the string value of the argument. + * @property {string} TEXT - the string value of the argument. + * @returns {string} a string which includes the block argument value. + */ + myReporter (args) { + // Note: this implementation is not Unicode-clean; it's just here as an example. + const result = args.TEXT.charAt(args.LETTER_NUM); + + return ['Letter ', args.LETTER_NUM, ' of ', args.TEXT, ' is ', result, '.'].join(''); + } +} + +Scratch.extensions.register(new ExampleExtension()); diff --git a/webpack.config.js b/webpack.config.js index 571256800..d741b787c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,8 +1,15 @@ const CopyWebpackPlugin = require('copy-webpack-plugin'); const defaultsDeep = require('lodash.defaultsdeep'); +const glob = require('glob'); const path = require('path'); const webpack = require('webpack'); +const extensionEntries = glob.sync('./src/extensions/*.js').reduce((bag, extensionPath) => { + const nameWithoutExtension = path.basename(extensionPath).replace(/\.[^/.]+$/, ''); + bag[`extensions/${nameWithoutExtension}`] = extensionPath; + return bag; +}, {}); + const base = { devServer: { contentBase: false, @@ -32,10 +39,10 @@ module.exports = [ // Web-compatible defaultsDeep({}, base, { target: 'web', - entry: { + entry: defaultsDeep({}, extensionEntries, { 'scratch-vm': './src/index.js', 'scratch-vm.min': './src/index.js' - }, + }), output: { path: path.resolve(__dirname, 'dist/web'), filename: '[name].js' @@ -52,9 +59,9 @@ module.exports = [ // Node-compatible defaultsDeep({}, base, { target: 'node', - entry: { + entry: defaultsDeep({}, extensionEntries, { 'scratch-vm': './src/index.js' - }, + }), output: { library: 'VirtualMachine', libraryTarget: 'commonjs2', @@ -65,7 +72,7 @@ module.exports = [ // Playground defaultsDeep({}, base, { target: 'web', - entry: { + entry: defaultsDeep({}, extensionEntries, { 'scratch-vm': './src/index.js', 'vendor': [ // FPS counter @@ -81,7 +88,7 @@ module.exports = [ // Storage 'scratch-storage' ] - }, + }), output: { path: path.resolve(__dirname, 'playground'), filename: '[name].js'