Skip to content

Commit

Permalink
Merge pull request #154 from TurboWarp/serialize-block-fixes
Browse files Browse the repository at this point in the history
Add API to export blocks with extension metadata
  • Loading branch information
GarboMuffin authored Jul 29, 2023
2 parents a56307f + 2158aef commit edae502
Show file tree
Hide file tree
Showing 6 changed files with 283 additions and 73 deletions.
12 changes: 12 additions & 0 deletions src/extension-support/tw-default-extension-urls.js
Original file line number Diff line number Diff line change
@@ -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;
144 changes: 101 additions & 43 deletions src/serialization/sb3.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -290,6 +289,39 @@ const getExtensionIdForOpcode = function (opcode) {
}
};

/**
* @param {Set<string>|string[]} extensionIDs Project extension IDs
* @param {Runtime} runtime
* @returns {Record<string, string>|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.)
Expand Down Expand Up @@ -339,6 +371,58 @@ const serializeBlocks = function (blocks) {
return [obj, Array.from(extensionIDs)];
};

/**
* @param {unknown} blocks Output of serializeStandaloneBlocks
* @returns {{blocks: Block[], extensionURLs: Map<string, string>}}
*/
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.
Expand Down Expand Up @@ -580,45 +664,6 @@ const serializeMonitors = function (monitors, runtime, extensions) {
.toArray();
};

/**
* @param {any} obj Project or target JSON. Modified in place.
* @param {Set<string>} 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
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;
}
}
};

/**
* Serializes the specified VM runtime.
* @param {!Runtime} runtime VM runtime instance to be serialized.
Expand Down Expand Up @@ -651,15 +696,26 @@ 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];
}

obj.targets = serializedTargets;

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);
Expand Down Expand Up @@ -1354,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.
Expand Down Expand Up @@ -1422,5 +1478,7 @@ module.exports = {
deserialize: deserialize,
deserializeBlocks: deserializeBlocks,
serializeBlocks: serializeBlocks,
deserializeStandaloneBlocks: deserializeStandaloneBlocks,
serializeStandaloneBlocks: serializeStandaloneBlocks,
getExtensionIdForOpcode: getExtensionIdForOpcode
};
11 changes: 0 additions & 11 deletions src/serialization/tw-default-extension-urls.js

This file was deleted.

18 changes: 14 additions & 4 deletions src/virtual-machine.js
Original file line number Diff line number Diff line change
Expand Up @@ -681,17 +681,17 @@ class VirtualMachine extends EventEmitter {
* @param {Map<string, string>} 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)) {
// Already loaded
} 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}`);
}
Expand Down Expand Up @@ -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.
Expand All @@ -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);

Expand All @@ -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);
});
Expand Down
55 changes: 40 additions & 15 deletions test/integration/tw_default_extension_url.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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();
});
});
Loading

0 comments on commit edae502

Please sign in to comment.