Skip to content

Commit

Permalink
Extension storage (#179)
Browse files Browse the repository at this point in the history
  • Loading branch information
GarboMuffin authored Jan 3, 2024
1 parent 7f9c8f7 commit dd848db
Show file tree
Hide file tree
Showing 6 changed files with 223 additions and 1 deletion.
6 changes: 6 additions & 0 deletions src/engine/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.<string, object>}
*/
this.extensionStorage = {};
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/engine/target.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ class Target extends EventEmitter {
* @type {Object.<string, *>}
*/
this._edgeActivatedHatValues = {};

/**
* Maps extension ID to a JSON-serializable value.
* @type {Object.<string, object>}
*/
this.extensionStorage = {};
}

/**
Expand Down
43 changes: 42 additions & 1 deletion src/serialization/sb3.js
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,26 @@ const serializeTarget = function (target, extensions) {
return obj;
};

/**
* @param {Record<string, unknown>} extensionStorage extensionStorage object
* @param {Set<string>} extensions extension IDs
* @returns {Record<string, unknown>|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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
});
Expand Down Expand Up @@ -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 => ({
Expand Down
Binary file added test/fixtures/tw-extension-storage-no-data.sb3
Binary file not shown.
Binary file added test/fixtures/tw-extension-storage.sb3
Binary file not shown.
169 changes: 169 additions & 0 deletions test/integration/tw_extension_storage.js
Original file line number Diff line number Diff line change
@@ -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();
});
});

0 comments on commit dd848db

Please sign in to comment.