diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 7e475861..e1e518f0 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -512,6 +512,12 @@ class Runtime extends EventEmitter { * Responsible for managing custom fonts. */ this.fontManager = new FontManager(this); + + /** + * Maps extension ID to a JSON-serializable value. + * @type {Object.} + */ + this.extensionStorage = {}; } /** diff --git a/src/engine/target.js b/src/engine/target.js index 166e4436..e9cecfe0 100644 --- a/src/engine/target.js +++ b/src/engine/target.js @@ -70,6 +70,12 @@ class Target extends EventEmitter { * @type {Object.} */ this._edgeActivatedHatValues = {}; + + /** + * Maps extension ID to a JSON-serializable value. + * @type {Object.} + */ + this.extensionStorage = {}; } /** diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index cc221920..3520ecb6 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -634,6 +634,26 @@ const serializeTarget = function (target, extensions) { return obj; }; +/** + * @param {Record} extensionStorage extensionStorage object + * @param {Set} extensions extension IDs + * @returns {Record|null} + */ +const serializeExtensionStorage = (extensionStorage, extensions) => { + const result = {}; + let isEmpty = true; + for (const [key, value] of Object.entries(extensionStorage)) { + if (extensions.has(key) && value !== null && typeof value !== 'undefined') { + isEmpty = false; + result[key] = extensionStorage[key]; + } + } + if (isEmpty) { + return null; + } + return result; +}; + const getSimplifiedLayerOrdering = function (targets) { const layerOrders = targets.map(t => t.getLayerOrder()); return MathUtil.reducedSortOrdering(layerOrders); @@ -712,7 +732,17 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {}) }); } - const serializedTargets = flattenedOriginalTargets.map(t => serializeTarget(t, extensions)); + const serializedTargets = flattenedOriginalTargets.map(t => serializeTarget(t, extensions)) + .map((serialized, index) => { + // can't serialize extensionStorage until the list of used extensions is fully known + const target = originalTargetsToSerialize[index]; + const targetExtensionStorage = serializeExtensionStorage(target.extensionStorage, extensions); + if (targetExtensionStorage) { + serialized.extensionStorage = targetExtensionStorage; + } + return serialized; + }); + const fonts = runtime.fontManager.serializeJSON(); if (targetId) { @@ -731,6 +761,11 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {}) return serializedTargets[0]; } + const globalExtensionStorage = serializeExtensionStorage(runtime.extensionStorage, extensions); + if (globalExtensionStorage) { + obj.extensionStorage = globalExtensionStorage; + } + obj.targets = serializedTargets; obj.monitors = serializeMonitors(runtime.getMonitorState(), runtime, extensions); @@ -1274,6 +1309,9 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) { if (Object.prototype.hasOwnProperty.call(object, 'draggable')) { target.draggable = object.draggable; } + if (Object.prototype.hasOwnProperty.call(object, 'extensionStorage')) { + target.extensionStorage = object.extensionStorage; + } Promise.all(costumePromises).then(costumes => { sprite.costumes = costumes; }); @@ -1501,6 +1539,9 @@ const deserialize = function (json, runtime, zip, isSingleSprite) { .then(targets => replaceUnsafeCharsInVariableIds(targets)) .then(targets => { monitorObjects.map(monitorDesc => deserializeMonitor(monitorDesc, runtime, targets, extensions)); + if (Object.prototype.hasOwnProperty.call(json, 'extensionStorage')) { + runtime.extensionStorage = json.extensionStorage; + } return targets; }) .then(targets => ({ diff --git a/test/fixtures/tw-extension-storage-no-data.sb3 b/test/fixtures/tw-extension-storage-no-data.sb3 new file mode 100644 index 00000000..8350def3 Binary files /dev/null and b/test/fixtures/tw-extension-storage-no-data.sb3 differ diff --git a/test/fixtures/tw-extension-storage.sb3 b/test/fixtures/tw-extension-storage.sb3 new file mode 100644 index 00000000..9e333ec1 Binary files /dev/null and b/test/fixtures/tw-extension-storage.sb3 differ diff --git a/test/integration/tw_extension_storage.js b/test/integration/tw_extension_storage.js new file mode 100644 index 00000000..dbaeb972 --- /dev/null +++ b/test/integration/tw_extension_storage.js @@ -0,0 +1,169 @@ +const {test} = require('tap'); +const fs = require('fs'); +const path = require('path'); +const VirtualMachine = require('../../src/virtual-machine'); +const Sprite = require('../../src/sprites/sprite'); +const RenderedTarget = require('../../src/sprites/rendered-target'); +const sb3 = require('../../src/serialization/sb3'); + +test('serialize data', t => { + const vm = new VirtualMachine(); + const rt = vm.runtime; + + const target1 = new RenderedTarget(new Sprite(null, rt), rt); + const target2 = new RenderedTarget(new Sprite(null, rt), rt); + rt.addTarget(target1); + rt.addTarget(target2); + + t.same(sb3.serialize(rt).extensionStorage, undefined, 'global - nothing when no extensions'); + t.same( + sb3.serialize(rt).targets.map(i => i.extensionStorage), + [undefined, undefined], + 'sprites - nothing when no extensions' + ); + + vm.extensionManager._registerInternalExtension({ + getInfo: () => ({ + id: 'test1', + blocks: [] + }) + }); + vm.extensionManager._registerInternalExtension({ + getInfo: () => ({ + id: 'test2', + blocks: [] + }) + }); + vm.extensionManager._registerInternalExtension({ + getInfo: () => ({ + id: 'test3', + blocks: [] + }) + }); + + t.same(sb3.serialize(rt).extensionStorage, undefined, 'global - nothing when no storage'); + t.same( + sb3.serialize(rt).targets.map(i => i.extensionStorage), + [undefined, undefined], + 'sprites - nothing when no storage' + ); + + const topLevelBlockBase = { + // this is not interesting for this test + inputs: {}, + fields: {}, + topLevel: true, + next: null, + parent: null + }; + + target1.blocks.createBlock({ + ...topLevelBlockBase, + id: 'block1', + opcode: 'test1_whatever' + }); + target2.blocks.createBlock({ + ...topLevelBlockBase, + id: 'block2', + opcode: 'test2_whatever' + }); + + target1.extensionStorage.test1 = 1234321; + t.same(sb3.serialize(rt, target1.id).extensionStorage, { + test1: 1234321 + }, 'target1 alone has test1'); + t.same(sb3.serialize(rt, target2.id).extensionStorage, undefined, 'target2 alone does not have test1'); + + target1.extensionStorage.test1 = null; + t.same(sb3.serialize(rt, target1.id).extensionStorage, undefined, 'null is not serialized'); + + target1.extensionStorage.test1 = undefined; + t.same(sb3.serialize(rt, target1.id).extensionStorage, undefined, 'undefined is not serialized'); + + target1.extensionStorage.test1 = {it: 'works'}; + target1.extensionStorage.test2 = true; + target1.extensionStorage.test3 = {should_not: 'be_saved'}; + + target2.extensionStorage.test1 = ['ok']; + delete target2.extensionStorage.test2; + delete target2.extensionStorage.test3; + + rt.extensionStorage.test1 = 'global ok'; + delete rt.extensionStorage.test2; + rt.extensionStorage.test3 = ['dont save this']; + + const json = sb3.serialize(rt); + t.same(json.extensionStorage, { + test1: 'global ok' + }, 'final - global has test1'); + t.same(json.targets.map(i => i.extensionStorage), [ + { + test1: { + it: 'works' + }, + test2: true + }, + { + test1: ['ok'] + } + ], 'final - targets ok'); + + t.end(); +}); + +test('deserialize project with data', t => { + const vm = new VirtualMachine(); + + vm.extensionManager._registerInternalExtension({ + getInfo: () => ({ + id: 'test1', + blocks: [] + }) + }); + vm.extensionManager._registerInternalExtension({ + getInfo: () => ({ + id: 'test2', + blocks: [] + }) + }); + vm.extensionManager._registerInternalExtension({ + getInfo: () => ({ + id: 'test3', + blocks: [] + }) + }); + + // trick it into thinking the extensions are real and loaded... + vm.extensionManager._loadedExtensions.set('test1', 'invalid'); + vm.extensionManager._loadedExtensions.set('test2', 'invalid'); + vm.extensionManager._loadedExtensions.set('test3', 'invalid'); + + const fixture = fs.readFileSync(path.resolve(__dirname, '../fixtures/tw-extension-storage.sb3')); + vm.loadProject(fixture).then(() => { + t.same(vm.runtime.extensionStorage, { + test1: 'global ok' + }, 'deserialized global'); + t.same(vm.runtime.targets[0].extensionStorage, { + test1: { + it: 'works' + }, + test2: true + }, 'deserialized target 0'); + t.same(vm.runtime.targets[1].extensionStorage, { + test1: ['ok'] + }, 'deserialized target 1'); + t.same(vm.runtime.targets[2].extensionStorage, {}, 'deserialized target 2'); + + t.end(); + }); +}); + +test('deserialize project with no data', t => { + const vm = new VirtualMachine(); + const fixture = fs.readFileSync(path.resolve(__dirname, '../fixtures/tw-extension-storage-no-data.sb3')); + vm.loadProject(fixture).then(() => { + t.same(vm.runtime.extensionStorage, {}, 'deserialized global'); + t.same(vm.runtime.targets[0].extensionStorage, {}, 'deserialized target 0'); + t.end(); + }); +});