From 67afd7e8e3d889e5dd125ce465242275bf5b8e8b Mon Sep 17 00:00:00 2001 From: Muffin Date: Fri, 28 Jul 2023 22:59:06 -0500 Subject: [PATCH 1/3] Add API to export blocks with extension metadata Will be used to make backpack work properly with custom extensions --- src/serialization/sb3.js | 111 ++++++++++++++++++---- src/virtual-machine.js | 14 ++- test/integration/tw_standalone_blocks.js | 116 +++++++++++++++++++++++ 3 files changed, 218 insertions(+), 23 deletions(-) create mode 100644 test/integration/tw_standalone_blocks.js diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index cfd568bb1e..1e659cbdf2 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -290,6 +290,39 @@ const getExtensionIdForOpcode = function (opcode) { } }; +/** + * @param {Set|string[]} extensionIDs Project extension IDs + * @param {Runtime} runtime + * @returns {Record|null} extension ID -> URL map, or null if no custom extensions. + */ +const getExtensionURLsToSave = (extensionIDs, runtime) => { + // Extension manager only exists when runtime is wrapped by VirtualMachine + if (!runtime.extensionManager) { + return null; + } + + // We'll save the extensions in the format: + // { + // "extensionid": "https://...", + // "otherid": "https://..." + // } + // Which lets the VM know which URLs correspond to which IDs, which is useful when the project + // is being loaded. For example, if the extension is eventually converted to a builtin extension + // or if it is already loaded, then it doesn't need to fetch the script again. + const extensionURLs = runtime.extensionManager.getExtensionURLs(); + const toSave = {}; + for (const extension of extensionIDs) { + const url = extensionURLs[extension]; + if (typeof url === 'string') { + toSave[extension] = url; + } + } + if (Object.keys(toSave).length === 0) { + return null; + } + return toSave; +}; + /** * Serialize the given blocks object (representing all the blocks for the target * currently being serialized.) @@ -339,6 +372,58 @@ const serializeBlocks = function (blocks) { return [obj, Array.from(extensionIDs)]; }; +/** + * @param {unknown} blocks Output of serializeStandaloneBlocks + * @returns {{blocks: Block[], extensionURLs: Map}} + */ +const deserializeStandaloneBlocks = blocks => { + // deep clone to ensure it's safe to modify later + blocks = JSON.parse(JSON.stringify(blocks)); + + if (blocks.extensionURLs) { + const extensionURLs = new Map(); + for (const [id, url] of Object.entries(blocks.extensionURLs)) { + extensionURLs.set(id, url); + } + return { + blocks: blocks.blocks, + extensionURLs + }; + } + + // Vanilla Scratch format is just a list of block objects + return { + blocks, + extensionURLs: new Map() + }; +}; + +/** + * @param {Block[]} blocks List of block objects. + * @param {Runtime} runtime Runtime + * @returns {object} Something that can be understood by deserializeStandaloneBlocks + */ +const serializeStandaloneBlocks = (blocks, runtime) => { + const extensionIDs = new Set(); + for (const block of blocks) { + const extensionID = getExtensionIdForOpcode(block.opcode); + if (extensionID) { + extensionIDs.add(extensionID); + } + } + const extensionURLs = getExtensionURLsToSave(extensionIDs, runtime); + if (extensionURLs) { + return { + blocks, + // same format as project.json + extensionURLs: extensionURLs + }; + } + // Vanilla Scratch always just uses the block array as-is. To reduce compatibility concerns + // we too will use that when possible. + return blocks; +}; + /** * Serialize the given costume. * @param {object} costume The costume to be serialized. @@ -595,27 +680,9 @@ const serializeExtensionMetadata = (obj, extensionIds, runtime, isSprite) => { // Save list of URLs to load the current extensions // Extension manager only exists when runtime is wrapped by VirtualMachine - if (runtime.extensionManager) { - // We'll save the extensions in the format: - // { - // "extensionid": "https://...", - // "otherid": "https://..." - // } - // Which lets the VM know which URLs correspond to which IDs, which is useful when the project - // is being loaded. For example, if the extension is eventually converted to a builtin extension - // or if it is already loaded, then it doesn't need to fetch the script again. - const extensionURLs = runtime.extensionManager.getExtensionURLs(); - const urlsToSave = {}; - for (const extension of extensionIds) { - const url = extensionURLs[extension]; - if (typeof url === 'string') { - urlsToSave[extension] = url; - } - } - // Only save this object if any URLs would actually be saved. - if (Object.keys(urlsToSave).length !== 0) { - obj.extensionURLs = urlsToSave; - } + const extensionURLs = getExtensionURLsToSave(extensionIds, runtime); + if (extensionURLs) { + obj.extensionURLs = extensionURLs; } }; @@ -1422,5 +1489,7 @@ module.exports = { deserialize: deserialize, deserializeBlocks: deserializeBlocks, serializeBlocks: serializeBlocks, + deserializeStandaloneBlocks: deserializeStandaloneBlocks, + serializeStandaloneBlocks: serializeStandaloneBlocks, getExtensionIdForOpcode: getExtensionIdForOpcode }; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 522d740456..74bb3c6cf1 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -1447,6 +1447,16 @@ class VirtualMachine extends EventEmitter { } } + /** + * @param {Block[]} blockObjects + * @returns {object} + */ + exportStandaloneBlocks (blockObjects) { + const sb3 = require('./serialization/sb3'); + const serialized = sb3.serializeStandaloneBlocks(blockObjects, this.runtime); + return serialized; + } + /** * Called when blocks are dragged from one sprite to another. Adds the blocks to the * workspace of the given target. @@ -1459,7 +1469,7 @@ class VirtualMachine extends EventEmitter { shareBlocksToTarget (blocks, targetId, optFromTargetId) { const sb3 = require('./serialization/sb3'); - const copiedBlocks = JSON.parse(JSON.stringify(blocks)); + const {blocks: copiedBlocks, extensionURLs} = sb3.deserializeStandaloneBlocks(blocks); newBlockIds(copiedBlocks); const target = this.runtime.getTargetById(targetId); @@ -1477,7 +1487,7 @@ class VirtualMachine extends EventEmitter { .filter(id => !this.extensionManager.isExtensionLoaded(id)) // and remove loaded extensions ); - return this._loadExtensions(extensionIDs).then(() => { + return this._loadExtensions(extensionIDs, extensionURLs).then(() => { copiedBlocks.forEach(block => { target.blocks.createBlock(block); }); diff --git a/test/integration/tw_standalone_blocks.js b/test/integration/tw_standalone_blocks.js new file mode 100644 index 0000000000..392086d221 --- /dev/null +++ b/test/integration/tw_standalone_blocks.js @@ -0,0 +1,116 @@ +const {test} = require('tap'); +const VirtualMachine = require('../../src/virtual-machine'); +const RenderedTarget = require('../../src/sprites/rendered-target'); +const Sprite = require('../../src/sprites/sprite'); + +test('Serializes standalone blocks', t => { + t.plan(4); + + const vm = new VirtualMachine(); + + vm.extensionManager.workerURLs[0] = 'https://example.com/test1.js'; + vm.extensionManager.workerURLs[1] = 'https://example.com/test2.js'; + vm.extensionManager._loadedExtensions.set('test1', 'test.0.0'); + vm.extensionManager._loadedExtensions.set('test2', 'test.1.0'); + + const primitiveBlock = { + id: 'donotcompress1', + opcode: 'control_if' + }; + const extensionBlock1 = { + id: 'donotcompress2', + opcode: 'test1_something' + }; + const extensionBlock2 = { + id: 'donotcompress3', + opcode: 'test2_something' + }; + + t.same(vm.exportStandaloneBlocks([]), []); + t.same(vm.exportStandaloneBlocks([primitiveBlock]), [primitiveBlock]); + t.same(vm.exportStandaloneBlocks([extensionBlock1, extensionBlock2]), { + blocks: [extensionBlock1, extensionBlock2], + extensionURLs: { + test1: 'https://example.com/test1.js', + test2: 'https://example.com/test2.js' + } + }); + t.same(vm.exportStandaloneBlocks([primitiveBlock, extensionBlock2]), { + blocks: [primitiveBlock, extensionBlock2], + extensionURLs: { + test2: 'https://example.com/test2.js' + } + }); + + t.end(); +}); + +test('Deserializes vanilla standalone blocks', t => { + t.plan(2); + + const vm = new VirtualMachine(); + const target = new RenderedTarget(new Sprite(null, vm.runtime), vm.runtime); + vm.runtime.addTarget(target); + + vm.shareBlocksToTarget([ + { + id: 'abcdef', + opcode: 'control_if' + } + ], target.id).then(() => { + const createdBlock = Object.values(target.sprite.blocks._blocks)[0]; + t.equal(createdBlock.opcode, 'control_if'); + t.not(createdBlock.id, 'abcdef', 'opcode changed'); + + t.end(); + }); +}); + +test('Deserializes standalone blocks with extensions', t => { + t.plan(3); + + const vm = new VirtualMachine(); + const target = new RenderedTarget(new Sprite(null, vm.runtime), vm.runtime); + vm.runtime.addTarget(target); + + const events = []; + vm.securityManager.canLoadExtensionFromProject = url => { + events.push(`canLoadExtensionFromProject ${url}`); + return true; + }; + vm.extensionManager.loadExtensionURL = url => { + events.push(`loadExtensionURL ${url}`); + return Promise.resolve(); + }; + + vm.shareBlocksToTarget({ + blocks: [ + { + id: 'fruit', + opcode: 'pen_clear' + }, + { + id: 'vegetable', + opcode: 'test1_something' + } + ], + extensionURLs: { + test1: 'https://example.com/test1.js', + test2: 'https://example.com/should.be.discarded.js', + pen: 'https://example.com/should.also.be.discarded.js' + } + }, target.id).then(() => { + t.same(events, [ + 'canLoadExtensionFromProject https://example.com/test1.js', + 'loadExtensionURL https://example.com/test1.js' + ]); + + const penBlock = Object.values(target.sprite.blocks._blocks).find(i => i.opcode === 'pen_clear'); + t.not(penBlock.id, 'fruit', 'changed pen block id'); + + const extensionBlock = Object.values(target.sprite.blocks._blocks).find(i => i.opcode === 'test1_something'); + t.not(extensionBlock.id, 'vegetable', 'changed extension block id'); + + t.end(); + }); +}); From 1d314182a7e239d291769e8a398433faec68351d Mon Sep 17 00:00:00 2001 From: Muffin Date: Fri, 28 Jul 2023 23:02:07 -0500 Subject: [PATCH 2/3] Remove unnecessary helper function --- src/serialization/sb3.js | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 1e659cbdf2..b9eda1dd38 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -665,27 +665,6 @@ const serializeMonitors = function (monitors, runtime, extensions) { .toArray(); }; -/** - * @param {any} obj Project or target JSON. Modified in place. - * @param {Set} extensionIds - * @param {Runtime} runtime - * @param {boolean} isSprite - * @returns {void} nothing, operates in-place - */ -const serializeExtensionMetadata = (obj, extensionIds, runtime, isSprite) => { - const serializedExtensions = Array.from(extensionIds); - if (serializedExtensions.length || !isSprite) { - obj.extensions = serializedExtensions; - } - - // Save list of URLs to load the current extensions - // Extension manager only exists when runtime is wrapped by VirtualMachine - const extensionURLs = getExtensionURLsToSave(extensionIds, runtime); - if (extensionURLs) { - obj.extensionURLs = extensionURLs; - } -}; - /** * Serializes the specified VM runtime. * @param {!Runtime} runtime VM runtime instance to be serialized. @@ -718,7 +697,14 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {}) if (targetId) { const target = serializedTargets[0]; - serializeExtensionMetadata(target, extensions, runtime, true); + if (extensions.size) { + // Vanilla Scratch doesn't include extensions in sprites, so don't add this if it's not needed + target.extensions = Array.from(extensions); + } + const extensionURLs = getExtensionURLsToSave(extensions, runtime); + if (extensionURLs) { + target.extensionURLs = extensionURLs; + } return serializedTargets[0]; } @@ -726,7 +712,11 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {}) obj.monitors = serializeMonitors(runtime.getMonitorState(), runtime, extensions); - serializeExtensionMetadata(obj, extensions, runtime, false); + obj.extensions = Array.from(extensions); + const extensionURLs = getExtensionURLsToSave(extensions, runtime); + if (extensionURLs) { + obj.extensionURLs = extensionURLs; + } // Assemble metadata const meta = Object.create(null); From 2158aef7637e1c53c09815dfe0b4ff3377095b42 Mon Sep 17 00:00:00 2001 From: Muffin Date: Fri, 28 Jul 2023 23:27:14 -0500 Subject: [PATCH 3/3] Refactor default extension URL handling --- .../tw-default-extension-urls.js | 12 ++++ src/serialization/sb3.js | 3 +- .../tw-default-extension-urls.js | 11 ---- src/virtual-machine.js | 4 +- test/integration/tw_default_extension_url.js | 55 ++++++++++++++----- 5 files changed, 55 insertions(+), 30 deletions(-) create mode 100644 src/extension-support/tw-default-extension-urls.js delete mode 100644 src/serialization/tw-default-extension-urls.js diff --git a/src/extension-support/tw-default-extension-urls.js b/src/extension-support/tw-default-extension-urls.js new file mode 100644 index 0000000000..3550ba082c --- /dev/null +++ b/src/extension-support/tw-default-extension-urls.js @@ -0,0 +1,12 @@ +// If a project uses an extension but does not specify a URL, it will default to +// the URLs given here, if it exists. This is useful for compatibility with other mods. + +const defaults = new Map(); + +// Box2D (`griffpatch`) is not listed here because our extension is not actually +// compatible with the original version due to fields vs inputs. + +// Scratch Lab Animated Text - https://lab.scratch.mit.edu/text/ +defaults.set('text', 'https://extensions.turbowarp.org/lab/text.js'); + +module.exports = defaults; diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index b9eda1dd38..00e38e6849 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -16,7 +16,6 @@ const MathUtil = require('../util/math-util'); const StringUtil = require('../util/string-util'); const VariableUtil = require('../util/variable-util'); const compress = require('./tw-compress-sb3'); -const defaultExtensionURLs = require('./tw-default-extension-urls'); const {loadCostume} = require('../import/load-costume.js'); const {loadSound} = require('../import/load-sound.js'); @@ -1411,7 +1410,7 @@ const replaceUnsafeCharsInVariableIds = function (targets) { const deserialize = function (json, runtime, zip, isSingleSprite) { const extensions = { extensionIDs: new Set(), - extensionURLs: new Map(Object.entries(defaultExtensionURLs)) + extensionURLs: new Map() }; // Store the origin field (e.g. project originated at CSFirst) so that we can save it again. diff --git a/src/serialization/tw-default-extension-urls.js b/src/serialization/tw-default-extension-urls.js deleted file mode 100644 index 22f248e827..0000000000 --- a/src/serialization/tw-default-extension-urls.js +++ /dev/null @@ -1,11 +0,0 @@ -// If a project uses an extension but does not specify a URL, it will default to -// the URL given here, if any. This is useful for compatibility with other mods. - -const defaults = { - // Box2D (`griffpatch`) is not listed here because our extension is not actually - // compatible with the original version due to fields vs inputs. - - text: 'https://extensions.turbowarp.org/lab/text.js' -}; - -module.exports = defaults; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 74bb3c6cf1..f292ad6bd5 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -681,6 +681,7 @@ class VirtualMachine extends EventEmitter { * @param {Map} extensionURLs A map of extension ID to URL */ async _loadExtensions (extensionIDs, extensionURLs = new Map()) { + const defaultExtensionURLs = require('./extension-support/tw-default-extension-urls'); const extensionPromises = []; for (const extensionID of extensionIDs) { if (this.extensionManager.isExtensionLoaded(extensionID)) { @@ -688,10 +689,9 @@ class VirtualMachine extends EventEmitter { } else if (this.extensionManager.isBuiltinExtension(extensionID)) { // Builtin extension this.extensionManager.loadExtensionIdSync(extensionID); - continue; } else { // Custom extension - const url = extensionURLs.get(extensionID); + const url = extensionURLs.get(extensionID) || defaultExtensionURLs.get(extensionID); if (!url) { throw new Error(`Unknown extension: ${extensionID}`); } diff --git a/test/integration/tw_default_extension_url.js b/test/integration/tw_default_extension_url.js index 77b42ea92e..2dd6573bfc 100644 --- a/test/integration/tw_default_extension_url.js +++ b/test/integration/tw_default_extension_url.js @@ -1,12 +1,21 @@ const {test} = require('tap'); -const {deserialize} = require('../../src/serialization/sb3'); -const Runtime = require('../../src/engine/runtime'); +const VirtualMachine = require('../../src/virtual-machine'); -// Note that at some point it is likely that this extension will break -test('text extension defaults to URL', async t => { - t.plan(2); - const rt = new Runtime(); - const deserialized = await deserialize({ +test('Loading project uses default extension URLs', t => { + t.plan(1); + + const vm = new VirtualMachine(); + const events = []; + vm.securityManager.canLoadExtensionFromProject = url => { + events.push(`canLoadExtensionFromProject ${url}`); + return true; + }; + vm.extensionManager.loadExtensionURL = url => { + events.push(`loadExtensionURL ${url}`); + return Promise.resolve(); + }; + + vm.loadProject({ targets: [ { isStage: true, @@ -51,7 +60,16 @@ test('text extension defaults to URL', async t => { }, comments: {}, currentCostume: 0, - costumes: [], + costumes: [ + { + assetId: 'cd21514d0531fdffb22204e0ec5ed84a', + dataFormat: 'svg', + md5ext: 'cd21514d0531fdffb22204e0ec5ed84a.svg', + name: 'backdrop1', + rotationCenterX: 240, + rotationCenterY: 180 + } + ], sounds: [], volume: 100, layerOrder: 0, @@ -62,18 +80,25 @@ test('text extension defaults to URL', async t => { } ], monitors: [], - // this list intentionally wrong to make sure we don't rely on its contents - extensions: [], + extensions: [ + // this list intentionally wrong to make sure we don't rely on its contents + ], extensionURLs: { - griffpatch: 'https://extensions.turbowarp.org/fake-box2d-url.js' + griffpatch: 'https://example.com/box2d.js' }, meta: { semver: '3.0.0', vm: '0.2.0', agent: '' } - }, rt); - t.equal(deserialized.extensions.extensionURLs.get('text'), 'https://extensions.turbowarp.org/lab/text.js'); - t.equal(deserialized.extensions.extensionURLs.get('griffpatch'), 'https://extensions.turbowarp.org/fake-box2d-url.js'); - t.end(); + }).then(() => { + t.same(events, [ + 'canLoadExtensionFromProject https://extensions.turbowarp.org/lab/text.js', + 'loadExtensionURL https://extensions.turbowarp.org/lab/text.js', + 'canLoadExtensionFromProject https://example.com/box2d.js', + 'loadExtensionURL https://example.com/box2d.js' + ]); + + t.end(); + }); });