mirror of
https://github.com/scratchfoundation/scratch-vm.git
synced 2024-12-23 14:32:59 -05:00
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.
This commit is contained in:
parent
bf97383b42
commit
7fb7f0dc7b
6 changed files with 289 additions and 7 deletions
|
@ -54,6 +54,7 @@
|
||||||
"tap": "^10.2.0",
|
"tap": "^10.2.0",
|
||||||
"tiny-worker": "^2.1.1",
|
"tiny-worker": "^2.1.1",
|
||||||
"webpack": "^2.4.1",
|
"webpack": "^2.4.1",
|
||||||
"webpack-dev-server": "^2.4.1"
|
"webpack-dev-server": "^2.4.1",
|
||||||
|
"worker-loader": "0.8.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
45
src/extension-support/extension-manager.js
Normal file
45
src/extension-support/extension-manager.js
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
const centralDispatch = require('../dispatch/central-dispatch');
|
||||||
|
|
||||||
|
class ExtensionManager {
|
||||||
|
constructor () {
|
||||||
|
/**
|
||||||
|
* The list of current active extension workers.
|
||||||
|
* @type {Array.<ExtensionWorker>}
|
||||||
|
*/
|
||||||
|
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;
|
38
src/extension-support/extension-worker.js
Normal file
38
src/extension-support/extension-worker.js
Normal file
|
@ -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)
|
||||||
|
};
|
8
src/extensions/.eslintrc.js
Normal file
8
src/extensions/.eslintrc.js
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
worker: true
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
Scratch: true
|
||||||
|
}
|
||||||
|
};
|
183
src/extensions/example-extension.js
Normal file
183
src/extensions/example-extension.js
Normal file
|
@ -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());
|
|
@ -1,8 +1,15 @@
|
||||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||||
const defaultsDeep = require('lodash.defaultsdeep');
|
const defaultsDeep = require('lodash.defaultsdeep');
|
||||||
|
const glob = require('glob');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const webpack = require('webpack');
|
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 = {
|
const base = {
|
||||||
devServer: {
|
devServer: {
|
||||||
contentBase: false,
|
contentBase: false,
|
||||||
|
@ -32,10 +39,10 @@ module.exports = [
|
||||||
// Web-compatible
|
// Web-compatible
|
||||||
defaultsDeep({}, base, {
|
defaultsDeep({}, base, {
|
||||||
target: 'web',
|
target: 'web',
|
||||||
entry: {
|
entry: defaultsDeep({}, extensionEntries, {
|
||||||
'scratch-vm': './src/index.js',
|
'scratch-vm': './src/index.js',
|
||||||
'scratch-vm.min': './src/index.js'
|
'scratch-vm.min': './src/index.js'
|
||||||
},
|
}),
|
||||||
output: {
|
output: {
|
||||||
path: path.resolve(__dirname, 'dist/web'),
|
path: path.resolve(__dirname, 'dist/web'),
|
||||||
filename: '[name].js'
|
filename: '[name].js'
|
||||||
|
@ -52,9 +59,9 @@ module.exports = [
|
||||||
// Node-compatible
|
// Node-compatible
|
||||||
defaultsDeep({}, base, {
|
defaultsDeep({}, base, {
|
||||||
target: 'node',
|
target: 'node',
|
||||||
entry: {
|
entry: defaultsDeep({}, extensionEntries, {
|
||||||
'scratch-vm': './src/index.js'
|
'scratch-vm': './src/index.js'
|
||||||
},
|
}),
|
||||||
output: {
|
output: {
|
||||||
library: 'VirtualMachine',
|
library: 'VirtualMachine',
|
||||||
libraryTarget: 'commonjs2',
|
libraryTarget: 'commonjs2',
|
||||||
|
@ -65,7 +72,7 @@ module.exports = [
|
||||||
// Playground
|
// Playground
|
||||||
defaultsDeep({}, base, {
|
defaultsDeep({}, base, {
|
||||||
target: 'web',
|
target: 'web',
|
||||||
entry: {
|
entry: defaultsDeep({}, extensionEntries, {
|
||||||
'scratch-vm': './src/index.js',
|
'scratch-vm': './src/index.js',
|
||||||
'vendor': [
|
'vendor': [
|
||||||
// FPS counter
|
// FPS counter
|
||||||
|
@ -81,7 +88,7 @@ module.exports = [
|
||||||
// Storage
|
// Storage
|
||||||
'scratch-storage'
|
'scratch-storage'
|
||||||
]
|
]
|
||||||
},
|
}),
|
||||||
output: {
|
output: {
|
||||||
path: path.resolve(__dirname, 'playground'),
|
path: path.resolve(__dirname, 'playground'),
|
||||||
filename: '[name].js'
|
filename: '[name].js'
|
||||||
|
|
Loading…
Reference in a new issue