Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Platform info in serialized SB3 files #205

Merged
merged 5 commits into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/engine/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
*/
Expand Down
7 changes: 7 additions & 0 deletions src/engine/tw-platform.js
Original file line number Diff line number Diff line change
@@ -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/'
};
40 changes: 39 additions & 1 deletion src/serialization/sb3.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -1468,6 +1472,36 @@ const replaceUnsafeCharsInVariableIds = function (targets) {
return targets;
};

/**
* @param {object} json
* @param {Runtime} runtime
* @returns {void|Promise<void>} 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.
Expand All @@ -1476,16 +1510,20 @@ const replaceUnsafeCharsInVariableIds = function (targets) {
* @param {boolean} isSingleSprite - If true treat as single sprite, else treat as whole project
* @returns {Promise.<ImportedProject>} 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()
};

// 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;
}

Expand Down
161 changes: 161 additions & 0 deletions test/integration/tw_platform.js
Original file line number Diff line number Diff line change
@@ -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);
});