diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 6fc794268f7..92e368923cd 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -22,6 +22,7 @@ const xmlEscape = require('../util/xml-escape'); const ScratchLinkWebSocket = require('../util/scratch-link-websocket'); const FontManager = require('./tw-font-manager'); const fetchWithTimeout = require('../util/fetch-with-timeout'); +const platform = require('./tw-platform.js'); // Virtual I/O devices. const Clock = require('../io/clock'); @@ -439,6 +440,11 @@ class Runtime extends EventEmitter { */ this.origin = null; + /** + * Metadata about the platform this VM is part of. + */ + this.platform = Object.assign({}, platform); + this._initScratchLink(); this.resetRunId(); @@ -912,6 +918,13 @@ class Runtime extends EventEmitter { return 'BLOCKS_NEED_UPDATE'; } + /** + * Event name when platform name inside a project does not match the runtime. + */ + static get PLATFORM_MISMATCH () { + return 'PLATFORM_MISMATCH'; + } + /** * How rapidly we try to step threads by default, in ms. */ diff --git a/src/engine/tw-platform.js b/src/engine/tw-platform.js new file mode 100644 index 00000000000..ee6a2e2d627 --- /dev/null +++ b/src/engine/tw-platform.js @@ -0,0 +1,7 @@ +// Forks should change this. +// This can be accessed externally on `vm.runtime.platform` + +module.exports = { + name: 'TurboWarp', + url: 'https://turbowarp.org/' +}; diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index b760e05bb08..bad68d84114 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -4,6 +4,7 @@ * JSON and then generates all needed scratch-vm runtime structures. */ +const Runtime = require('../engine/runtime'); const Blocks = require('../engine/blocks'); const Sprite = require('../sprites/sprite'); const Variable = require('../engine/variable'); @@ -794,6 +795,9 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {}) // TW: Never include full user agent to slightly improve user privacy // if (typeof navigator !== 'undefined') meta.agent = navigator.userAgent; + // TW: Attach copy of platform information + meta.platform = Object.assign({}, runtime.platform); + // Assemble payload and return obj.meta = meta; @@ -1468,6 +1472,36 @@ const replaceUnsafeCharsInVariableIds = function (targets) { return targets; }; +/** + * @param {object} json + * @param {Runtime} runtime + * @returns {void|Promise} Resolves when the user has acknowledged any compatibilities, if any exist. + */ +const checkPlatformCompatibility = (json, runtime) => { + if (!json.meta || !json.meta.platform) { + return; + } + + const projectPlatform = json.meta.platform.name; + if (projectPlatform === runtime.platform.name) { + return; + } + + let pending = runtime.listenerCount(Runtime.PLATFORM_MISMATCH); + if (pending === 0) { + return; + } + + return new Promise(resolve => { + runtime.emit(Runtime.PLATFORM_MISMATCH, json.meta.platform, () => { + pending--; + if (pending === 0) { + resolve(); + } + }); + }); +}; + /** * Deserialize the specified representation of a VM runtime and loads it into the provided runtime instance. * @param {object} json - JSON representation of a VM runtime. @@ -1476,7 +1510,9 @@ const replaceUnsafeCharsInVariableIds = function (targets) { * @param {boolean} isSingleSprite - If true treat as single sprite, else treat as whole project * @returns {Promise.} Promise that resolves to the list of targets after the project is deserialized */ -const deserialize = function (json, runtime, zip, isSingleSprite) { +const deserialize = async function (json, runtime, zip, isSingleSprite) { + await checkPlatformCompatibility(json, runtime); + const extensions = { extensionIDs: new Set(), extensionURLs: new Map() @@ -1484,8 +1520,10 @@ const deserialize = function (json, runtime, zip, isSingleSprite) { // Store the origin field (e.g. project originated at CSFirst) so that we can save it again. if (json.meta && json.meta.origin) { + // eslint-disable-next-line require-atomic-updates runtime.origin = json.meta.origin; } else { + // eslint-disable-next-line require-atomic-updates runtime.origin = null; } diff --git a/test/integration/tw_platform.js b/test/integration/tw_platform.js new file mode 100644 index 00000000000..d487cbe0b54 --- /dev/null +++ b/test/integration/tw_platform.js @@ -0,0 +1,161 @@ +const {test} = require('tap'); +const VM = require('../../src/virtual-machine'); +const platform = require('../../src/engine/tw-platform'); +const Clone = require('../../src/util/clone'); + +test('the internal object', t => { + // the idea with this test is to make it harder for forks to screw up modifying the file + t.type(platform.name, 'string'); + t.type(platform.url, 'string'); + t.end(); +}); + +test('vm property', t => { + const vm = new VM(); + t.same(vm.runtime.platform, platform, 'copy of tw-platform.js'); + t.not(vm.runtime.platform, platform, 'not the same object as tw-platform.js'); + t.end(); +}); + +test('sanitize', t => { + const vm = new VM(); + vm.runtime.platform.name += ' - test'; + const json = JSON.parse(vm.toJSON()); + t.same(json.meta.platform, vm.runtime.platform, 'copy of runtime.platform'); + t.not(json.meta.platform, vm.runtime.platform, 'not the same object as runtime.platform'); + t.end(); +}); + +const vanillaProject = { + targets: [ + { + isStage: true, + name: 'Stage', + variables: {}, + lists: {}, + broadcasts: {}, + blocks: {}, + comments: {}, + currentCostume: 0, + costumes: [ + { + name: 'backdrop1', + dataFormat: 'svg', + assetId: 'cd21514d0531fdffb22204e0ec5ed84a', + md5ext: 'cd21514d0531fdffb22204e0ec5ed84a.svg', + rotationCenterX: 240, + rotationCenterY: 180 + } + ], + sounds: [], + volume: 100, + layerOrder: 0, + tempo: 60, + videoTransparency: 50, + videoState: 'on', + textToSpeechLanguage: null + } + ], + monitors: [], + extensions: [], + meta: { + semver: '3.0.0', + vm: '0.2.0', + agent: '' + } +}; + +test('deserialize no platform', t => { + const vm = new VM(); + vm.runtime.on('PLATFORM_MISMATCH', () => { + t.fail('Called PLATFORM_MISMATCH'); + }); + vm.loadProject(vanillaProject).then(() => { + t.end(); + }); +}); + +test('deserialize matching platform', t => { + const vm = new VM(); + vm.runtime.on('PLATFORM_MISMATCH', () => { + t.fail('Called PLATFORM_MISMATCH'); + }); + const project = Clone.simple(vanillaProject); + project.meta.platform = Object.assign({}, platform); + vm.loadProject(project).then(() => { + t.end(); + }); +}); + +test('deserialize mismatching platform with no listener', t => { + const vm = new VM(); + const project = Clone.simple(vanillaProject); + project.meta.platform = { + name: '3tw4ergo980uitegr5hoijuk;' + }; + vm.loadProject(project).then(() => { + t.end(); + }); +}); + +test('deserialize mismatching platform with 1 listener', t => { + t.plan(2); + const vm = new VM(); + vm.runtime.on('PLATFORM_MISMATCH', (pl, callback) => { + t.same(pl, { + name: 'aa', + url: '...' + }); + t.ok('called PLATFORM_MISMATCH'); + callback(); + }); + const project = Clone.simple(vanillaProject); + project.meta.platform = { + name: 'aa', + url: '...' + }; + vm.loadProject(project).then(() => { + t.end(); + }); +}); + +test('deserialize mismatching platform with 3 listeners', t => { + t.plan(2); + + const calls = []; + let expectedToLoad = false; + const vm = new VM(); + vm.runtime.on('PLATFORM_MISMATCH', (_, callback) => { + calls.push([1, callback]); + }); + vm.runtime.on('PLATFORM_MISMATCH', (_, callback) => { + calls.push([2, callback]); + }); + vm.runtime.on('PLATFORM_MISMATCH', (_, callback) => { + calls.push([3, callback]); + }); + + const project = Clone.simple(vanillaProject); + project.meta.platform = { + name: '' + }; + vm.loadProject(project).then(() => { + t.ok(expectedToLoad); + t.end(); + }); + + // loadProject is async, may need to wait a bit + setTimeout(async () => { + t.same(calls.map(i => i[0]), [1, 2, 3], 'listeners called in correct order'); + + // loadProject should not finish until we call all of the listeners' callbacks + calls[0][1](); + await new Promise(resolve => setTimeout(resolve, 100)); + + calls[1][1](); + await new Promise(resolve => setTimeout(resolve, 100)); + + expectedToLoad = true; + calls[2][1](); + }, 0); +});