From dd848dbbf60894d37a723918e51143cf2d2eba61 Mon Sep 17 00:00:00 2001 From: GarboMuffin Date: Tue, 2 Jan 2024 23:19:51 -0600 Subject: [PATCH] Extension storage (#179) --- src/engine/runtime.js | 6 + src/engine/target.js | 6 + src/serialization/sb3.js | 43 ++++- .../fixtures/tw-extension-storage-no-data.sb3 | Bin 0 -> 1186 bytes test/fixtures/tw-extension-storage.sb3 | Bin 0 -> 1468 bytes test/integration/tw_extension_storage.js | 169 ++++++++++++++++++ 6 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/tw-extension-storage-no-data.sb3 create mode 100644 test/fixtures/tw-extension-storage.sb3 create mode 100644 test/integration/tw_extension_storage.js diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 7e475861ddd..e1e518f0dc0 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 166e443697b..e9cecfe0b0d 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 cc2219201b2..3520ecb6279 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 0000000000000000000000000000000000000000..8350def303b5ad730b9c1796d335e9ced5234278 GIT binary patch literal 1186 zcmWIWW@h1HU|`^2XxlhHe66)x+&xAH1`!qp1|FbjK~a8IYI2EQR&jpb*2($ZR}2J> z?LQ@WU*n4E-|g?d^`3gzsK9Yg@l@dhlbP3c_y$cXxmkMmecj}3-LDtk+57RX&1BDC zXZ~2Riyhc1VY51q-{g*i#fFIfyfovptp^+zdz=EUiM>Y-u&3y zIQ#gwaeMymsaW}4e7RCp|J>6p_hp5zY0BG|F4#~ewxr?yuFXlt#}8%oJ}XgTd+re% zCEu3OalFZJUd(y3Yys()IrArlax$)4b6McUf&6H%#_tmMye~PWWzH0vG`;fHiDfG{ z>pW&H5>Y)B${4aHwR87}mRm=^?AXlMwCUNTnnk}4ty=Sde{+1~)O9~T>LuReb8~wy zlAmjCHnnKig@bZ;-PJGl#4^Ff#)!j@=Em;W zc-?l=SGJhZ9kgA9Ux4dsHFMuw(_ zCMgD{#)fGrX=zDDMn(oEsRpUZrl~0wCW(5*W$C%T-h9mpB6q60u5V{Qvd-ktB2WJd z!dtvrLXI9-ec|@?sOi3GhBNb}uQP@TFZ#UM>7=Stk!@tFXp?E)hAo$`&Mf&DsW^GV z!Y1F-mo3~|r)6ZecxZksd1lA6Nb7V^&SCc@Cl}A2|G#O|y6la+S8z9cJ0cPD`4RI> zIl25Dr4Rk)va-3G|6{SUe6*~dGr>pv?RW2b22@Weh6mm42YTiN)KjL3DQO1A7Kv#l z$th;WhL(n=CP}7d28Kq)<_4BVX>d<98Hn5|?s|Uvd-FPl15HPSE0~t@=CXL*N{`Oh zSMoNVeX?e@fP2R+_Se(IyyKKqR-f3SA=tBR@xr-otApQvI+n=pG;g`;mW+;TQ5{o+ z{Ken>(Kr|6wehUnL@WQY%75I`LPI<69$m+f;n#D}bp5%>VIL|E=YA-c;xVw;^-%r8 zO`+?rR2w#~%h(rxmp#Cnkx7IBcYX!>3<)6TUUZ%4F^tg0!9-kqqnn5xya*HT0!<_) VumikV*+9COf$$!XUdIgL0RYU;*^2-G literal 0 HcmV?d00001 diff --git a/test/fixtures/tw-extension-storage.sb3 b/test/fixtures/tw-extension-storage.sb3 new file mode 100644 index 0000000000000000000000000000000000000000..9e333ec1b2225ae3814cf483c6796622fa9d86f0 GIT binary patch literal 1468 zcmWIWW@h1HU|`^2c)ekMxMFzF-F_hN1Q4qLv1wvTnt`!JVwy>EikY#YrJ<=wlBt=2 zp^>q9Q#jCcM7V-!DQ_-|*RAyEe0?Qv$W=h{ikDz>`wERt8U5YxE9qhMaWkhs2Tj+Xn;iC`;&ASVaw#4Ii(L=ZKim|${z|oB zd zfqSS~LF7(#*Y)k}N7k7fTIA_}L3oQ-OUTg!t1sNX9yQ%J&2VPE^mWEC;YFV}JDpT@ zDzc4i6>T!j+py*G)tMz9BNZoaSlHxy`m%+4>$Hr_77xvjCC}`57HORh$~o-5_X_X_TYZ$~6zK0jifDJPe|qx7NQTvj%B^M5RMmXDUzb0+wRzy0oA&j9k2 z2s0>_J~Ye^Z#Mbuc7=(7!B&8Qfd}ZZf};Ga)Z`Mqtm6E+YBhTCK5 zsr1ZBMiWEMOd+AU0vWR&sFfK7tM}!oXE**UpTocH>EYb=V=Xs+Rh<9$_N=A(cPo3h zY^3h9&o0DA?Hl^3a0Io1ACi0Iw#F4EGvEStHO0* z(aKkQyq=_7Vhi^0V!1knFQQ?AfPvEbtgM6a`}fA~{Z@Brv-i)>GfVY;G=`hpi<)F&XU7(Y|j!Jzaf?!kTZ_k3?nPDC14I`DOD@$F(9dS{ptK zuzoA&$kD%JoBUyZWa8P#$86jyycB*rsH9g~zCOKt_u`Nu zhL1HDnOTn*vaH#w^Q%es>as@)2{}BAL>T}6Tx1d-{FCcz!{`0G80P+tZRi(%T$psH zEXm^k=_3+bMYF#+aQd%&^vG-RHItQ#?ymprY5ib9ncboVe|_~mgF=Fo%Cmge&VJs# zgeN+9xoq2xediQRR&n3^#}wer$RxsmyOaWE0VF_7nT2j5dNF}8?jn#$OrZfX(ViJs z9tD-9AfV8&rEwV}YL4Y%Pyov@GDt8mtjaoIYIBPboeuD3WdkW<20|vF{v|9R9sr~{ BQIh}w literal 0 HcmV?d00001 diff --git a/test/integration/tw_extension_storage.js b/test/integration/tw_extension_storage.js new file mode 100644 index 00000000000..dbaeb972c5b --- /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(); + }); +});