Skip to content

Commit

Permalink
Merge pull request #157 from TurboWarp/modernize-loading
Browse files Browse the repository at this point in the history
Use fetch() and AsyncFunction instead of `<script>` for unsandboxed extensions
  • Loading branch information
GarboMuffin authored Aug 22, 2023
2 parents c787bcc + 6836209 commit 9caca1c
Show file tree
Hide file tree
Showing 9 changed files with 467 additions and 542 deletions.
3 changes: 2 additions & 1 deletion src/extension-support/extension-worker.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-env worker */

const ScratchCommon = require('./tw-extension-api-common');
const createScratchX = require('./tw-scratchx-compatibility-layer');
const dispatch = require('../dispatch/worker-dispatch');
const log = require('../util/log');
const {isWorker} = require('./tw-extension-worker-context');
Expand Down Expand Up @@ -95,4 +96,4 @@ global.Scratch.extensions = {
register: extensionWorker.register.bind(extensionWorker)
};

global.ScratchExtensions = require('./tw-scratchx-compatibility-layer');
global.ScratchExtensions = createScratchX(global.Scratch);
52 changes: 27 additions & 25 deletions src/extension-support/tw-scratchx-compatibility-layer.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
// ScratchX API Documentation: https://github.com/LLK/scratchx/wiki/

// Global Scratch API from extension-worker.js
/* globals Scratch */

const ArgumentType = require('./argument-type');
const BlockType = require('./block-type');

Expand Down Expand Up @@ -191,31 +188,36 @@ const convert = (name, descriptor, functions) => {

const extensionNameToExtension = new Map();

const register = (name, descriptor, functions) => {
const scratch3Extension = convert(name, descriptor, functions);
extensionNameToExtension.set(name, scratch3Extension);
Scratch.extensions.register(scratch3Extension);
};

/**
* @param {string} extensionName
* @returns {ScratchXStatus}
* @param {*} Scratch Scratch 3.0 extension API object
* @returns {*} ScratchX-compatible API object
*/
const getStatus = extensionName => {
const extension = extensionNameToExtension.get(extensionName);
if (extension) {
return extension._getStatus();
}
return {
status: 0,
msg: 'does not exist'
const createScratchX = Scratch => {
const register = (name, descriptor, functions) => {
const scratch3Extension = convert(name, descriptor, functions);
extensionNameToExtension.set(name, scratch3Extension);
Scratch.extensions.register(scratch3Extension);
};
};

module.exports = {
register,
getStatus,
/**
* @param {string} extensionName
* @returns {ScratchXStatus}
*/
const getStatus = extensionName => {
const extension = extensionNameToExtension.get(extensionName);
if (extension) {
return extension._getStatus();
}
return {
status: 0,
msg: 'does not exist'
};
};

// For tests
convert
return {
register,
getStatus
};
};

module.exports = createScratchX;
76 changes: 39 additions & 37 deletions src/extension-support/tw-unsandboxed-extension-runner.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const ScratchCommon = require('./tw-extension-api-common');
const createScratchX = require('./tw-scratchx-compatibility-layer');
const AsyncLimiter = require('../util/async-limiter');
const createTranslate = require('./tw-l10n');

Expand All @@ -16,19 +17,18 @@ const parseURL = url => {
};

/**
* Sets up the global.Scratch API for an unsandboxed extension.
* Create the unsandboxed extension API objects.
* @param {VirtualMachine} vm
* @returns {Promise<object[]>} Resolves with a list of extension objects when Scratch.extensions.register is called.
* @returns {*} The objects
*/
const setupUnsandboxedExtensionAPI = vm => new Promise(resolve => {
const extensionObjects = [];
const register = extensionObject => {
extensionObjects.push(extensionObject);
resolve(extensionObjects);
const createAPI = vm => {
const extensions = [];
const register = extension => {
extensions.push(extension);
};

// Create a new copy of global.Scratch for each extension
const Scratch = Object.assign({}, global.Scratch || {}, ScratchCommon);
// Each extension should get its own copy of Scratch so they don't break things badly
const Scratch = Object.assign({}, global.Scratch, ScratchCommon);
Scratch.extensions = {
unsandboxed: true,
register
Expand Down Expand Up @@ -114,18 +114,12 @@ const setupUnsandboxedExtensionAPI = vm => new Promise(resolve => {

Scratch.translate = createTranslate(vm);

global.Scratch = Scratch;
global.ScratchExtensions = require('./tw-scratchx-compatibility-layer');
});
const ScratchExtensions = createScratchX(Scratch);

/**
* Disable the existing global.Scratch unsandboxed extension APIs.
* This helps debug poorly designed extensions.
*/
const teardownUnsandboxedExtensionAPI = () => {
// We can assume global.Scratch already exists.
global.Scratch.extensions.register = () => {
throw new Error('Too late to register new extensions.');
return {
Scratch,
ScratchExtensions,
extensions
};
};

Expand All @@ -135,26 +129,34 @@ const teardownUnsandboxedExtensionAPI = () => {
* @param {Virtualmachine} vm
* @returns {Promise<object[]>} Resolves with a list of extension objects if the extension was loaded successfully.
*/
const loadUnsandboxedExtension = (extensionURL, vm) => new Promise((resolve, reject) => {
setupUnsandboxedExtensionAPI(vm).then(resolve);
const loadUnsandboxedExtension = async (extensionURL, vm) => {
const res = await fetch(extensionURL);
if (!res.ok) {
throw new Error(`HTTP status ${extensionURL}`);
}

const script = document.createElement('script');
script.onerror = () => {
reject(new Error(`Error in unsandboxed script ${extensionURL}. Check the console for more information.`));
};
script.src = extensionURL;
document.body.appendChild(script);
}).then(objects => {
teardownUnsandboxedExtensionAPI();
return objects;
});

// Because loading unsandboxed extensions requires messing with global state (global.Scratch),
// only let one extension load at a time.
const text = await res.text();

// AsyncFunction isn't a global like Function, but we can still access it indirectly like this
const AsyncFunction = (async () => {}).constructor;

const api = createAPI(vm);
const fn = new AsyncFunction('Scratch', 'ScratchExtensions', text);
await fn(api.Scratch, api.ScratchExtensions);

if (api.extensions.length === 0) {
throw new Error('Extension called register() 0 times');
}
return api.extensions;
};

// For now we force them to load one at a time to ensure consistent order
const limiter = new AsyncLimiter(loadUnsandboxedExtension, 1);
const load = (extensionURL, vm) => limiter.do(extensionURL, vm);

module.exports = {
setupUnsandboxedExtensionAPI,
load
load,

// For tests:
createAPI
};
20 changes: 5 additions & 15 deletions test/integration/tw_privacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,23 +94,13 @@ test('custom extensions', async t => {
vm.attachRenderer(mockRenderer());

vm.extensionManager.securityManager.getSandboxMode = () => 'unsandboxed';
global.document = {
createElement: () => {
const element = {};
setTimeout(() => {
global.Scratch.extensions.register({
getInfo: () => ({})
});
});
return element;
},
body: {
appendChild: () => {}
}
};
global.fetch = () => Promise.resolve({
ok: true,
text: () => Promise.resolve('Scratch.extensions.register({getInfo: () => ({id: "test", blocks: []})})')
});

t.equal(vm.renderer.privateSkinAccess, true);
await vm.extensionManager.loadExtensionURL('data:application/javascript;,');
await vm.extensionManager.loadExtensionURL('data:application/javascript;,fake URL see fetch() mock');
t.equal(vm.renderer.privateSkinAccess, false);
t.end();
});
Loading

0 comments on commit 9caca1c

Please sign in to comment.