Spaces:
Build error
Build error
const dispatch = require('../dispatch/central-dispatch'); | |
const log = require('../util/log'); | |
const maybeFormatMessage = require('../util/maybe-format-message'); | |
const BlockType = require('./block-type'); | |
const SecurityManager = require('./tw-security-manager'); | |
const Cast = require('../util/cast'); | |
const AddonSwitches = require('./extension-addon-switchers'); | |
const urlParams = new URLSearchParams(location.search); | |
const IsLocal = String(window.location.href).startsWith(`http://localhost:`); | |
const IsLiveTests = urlParams.has('livetests'); | |
// thhank yoh random stack droverflwo person | |
async function sha256(source) { | |
const sourceBytes = new TextEncoder().encode(source); | |
const digest = await crypto.subtle.digest("SHA-256", sourceBytes); | |
const resultBytes = [...new Uint8Array(digest)]; | |
return resultBytes.map(x => x.toString(16).padStart(2, '0')).join(""); | |
} | |
// These extensions are currently built into the VM repository but should not be loaded at startup. | |
// TODO: move these out into a separate repository? | |
// TODO: change extension spec so that library info, including extension ID, can be collected through static methods | |
const defaultBuiltinExtensions = { | |
// This is an example that isn't loaded with the other core blocks, | |
// but serves as a reference for loading core blocks as extensions. | |
coreExample: () => require('../blocks/scratch3_core_example'), | |
// These are the non-core built-in extensions. | |
pen: () => require('../extensions/scratch3_pen'), | |
wedo2: () => require('../extensions/scratch3_wedo2'), | |
music: () => require('../extensions/scratch3_music'), | |
microbit: () => require('../extensions/scratch3_microbit'), | |
text2speech: () => require('../extensions/scratch3_text2speech'), | |
translate: () => require('../extensions/scratch3_translate'), | |
videoSensing: () => require('../extensions/scratch3_video_sensing'), | |
ev3: () => require('../extensions/scratch3_ev3'), | |
makeymakey: () => require('../extensions/scratch3_makeymakey'), | |
boost: () => require('../extensions/scratch3_boost'), | |
gdxfor: () => require('../extensions/scratch3_gdx_for'), | |
text: () => require('../extensions/scratchLab_animatedText'), | |
ml2scratch: () => require('../extensions/scratch3_ml2scratch'), | |
// garbomuffin: *silence* | |
// tw: core extension | |
tw: () => require('../extensions/tw'), | |
// twFiles: replaces jgFiles as it works better on other devices | |
twFiles: () => require('../extensions/tw_files'), | |
posenet2scratch: ()=> require('../extensions/scratch3_posenet2scratch'), | |
// pm: category expansions & seperations go here | |
// pmMotionExpansion: extra motion blocks that were in the category & new ones that werent | |
pmMotionExpansion: () => require("../extensions/pm_motionExpansion"), | |
// pmOperatorsExpansion: extra operators that were in the category & new ones that werent | |
pmOperatorsExpansion: () => require("../extensions/pm_operatorsExpansion"), | |
// pmSensingExpansion: extra sensing blocks that were in the category & new ones that werent | |
pmSensingExpansion: () => require("../extensions/pm_sensingExpansion"), | |
// pmControlsExpansion: extra control blocks that were in the category & new ones that werent | |
pmControlsExpansion: () => require("../extensions/pm_controlsExpansion"), | |
// pmEventsExpansion: extra event blocks that were in the category & new ones that werent | |
pmEventsExpansion: () => require("../extensions/pm_eventsExpansion"), | |
// pmInlineBlocks: seperates the inline function block to prevent confusled | |
pmInlineBlocks: () => require("../extensions/pm_inlineblocks"), | |
// jg: jeremyes esxsitenisonsnsn | |
// jgFiles: support for reading user files | |
jgFiles: () => require('../extensions/jg_files'), | |
// jgWebsiteRequests: fetch GET and POST requests to apis & websites | |
jgWebsiteRequests: () => require("../extensions/jg_websiteRequests"), | |
// jgJSON: handle JSON objects | |
jgJSON: () => require("../extensions/jg_json"), | |
// jgJSONParsed: handle JSON objects BETTER | |
// jgJSONParsed: () => require("../extensions/jg_jsonParsed"), | |
// jgRuntime: edit stage and other stuff | |
jgRuntime: () => require("../extensions/jg_runtime"), | |
// jgPrism: blocks for specific use cases or major convenience | |
jgPrism: () => require("../extensions/jg_prism"), | |
// jgIframe: my last call for help (for legal reasons this is a joke) | |
jgIframe: () => require("../extensions/jg_iframe"), | |
// jgExtendedAudio: ok this is my real last call for help (for legal reasons this is a joj) | |
jgExtendedAudio: () => require("../extensions/jg_audio"), | |
// jgScratchAuthenticate: easy to add its one block lol! | |
jgScratchAuthenticate: () => require("../extensions/jg_scratchAuth"), | |
// JgPermissionBlocks: someones gonna get mad at me for this one i bet | |
JgPermissionBlocks: () => require("../extensions/jg_permissions"), | |
// jgClones: funny clone manager | |
jgClones: () => require("../extensions/jg_clones"), | |
// jgTween: epic animation | |
jgTween: () => require("../extensions/jg_tween"), | |
// jgDebugging: epic animation | |
jgDebugging: () => require("../extensions/jg_debugging"), | |
// jgEasySave: easy save stuff | |
jgEasySave: () => require("../extensions/jg_easySave"), | |
// jgPackagerApplications: uuhhhhhhh packager | |
jgPackagerApplications: () => require("../extensions/jg_packagerApplications"), | |
// jgTailgating: follow sprites like in an RPG | |
jgTailgating: () => require("../extensions/jg_tailgating"), | |
// jgScripts: what you know about rollin down in the | |
jgScripts: () => require("../extensions/jg_scripts"), | |
// jg3d: damn daniel | |
jg3d: () => require("../extensions/jg_3d"), | |
// jg3dVr: epic | |
jg3dVr: () => require("../extensions/jg_3dVr"), | |
// jgVr: excuse to use vr headset lol! | |
jgVr: () => require("../extensions/jg_vr"), | |
// jgInterfaces: easier UI | |
jgInterfaces: () => require("../extensions/jg_interfaces"), | |
// jgCostumeDrawing: draw on costumes | |
// hiding so fir doesnt touch | |
// jgCostumeDrawing: () => require("../extensions/jg_costumeDrawing"), | |
// jgJavascript: this is like the 3rd time we have implemented JS blocks man | |
jgJavascript: () => require("../extensions/jg_javascript"), | |
// jgPathfinding: EZ pathfinding for beginners :D hopefully | |
jgPathfinding: () => require("../extensions/jg_pathfinding"), | |
// jgAnimation: animate idk | |
jgAnimation: () => require("../extensions/jg_animation"), | |
// jgStorage: event extension requested by Fir & silvxrcat | |
jgStorage: () => require("../extensions/jg_storage"), | |
// jgTimers: event extension requested by Arrow | |
jgTimers: () => require("../extensions/jg_timers"), | |
// jgAdvancedText: event extension requested by silvxrcat | |
// hiding so fir doesnt touch | |
// jgAdvancedText: () => require("../extensions/jg_advancedText"), | |
// jgDev: test extension used for making core blocks | |
jgDev: () => require("../extensions/jg_dev"), | |
// jgDooDoo: test extension used for making test extensions | |
jgDooDoo: () => require("../extensions/jg_doodoo"), | |
// jgBestExtension: great extension used for making great extensions | |
jgBestExtension: () => require("../extensions/jg_bestextensioin"), | |
// jgChristmas: Christmas extension used for making Christmas extensions | |
jgChristmas: () => require("../extensions/jg_christmas"), | |
// jw: hello it is i jwklong | |
// jwUnite: literal features that should of been added in the first place | |
jwUnite: () => require("../extensions/jw_unite"), | |
// jwProto: placeholders, labels, defenitons, we got em | |
jwProto: () => require("../extensions/jw_proto"), | |
// jwPostLit: postlit real???? | |
jwPostLit: () => require("../extensions/jw_postlit"), | |
// jwReflex: vector positioning (UNRELEASED, DO NOT ADD TO GUI) | |
jwReflex: () => require("../extensions/jw_reflex"), | |
// Blockly 2: a faithful recreation of the original blockly blocks | |
blockly2math: () => require("../extensions/blockly-2/math.js"), | |
// jwXml: hi im back haha have funny xml | |
jwXml: () => require("../extensions/jw_xml"), | |
// vector type blah blah blah | |
jwVector: () => require("../extensions/jwVector"), | |
// my own array system yipee | |
jwArray: () => require("../extensions/jwArray"), | |
// mid extension but i need it | |
jwTargets: () => require("../extensions/jwTargets"), | |
// cool new physics extension | |
jwPsychic: () => require("../extensions/jwPsychic"), | |
// test ext for lambda functions or something | |
jwLambda: () => require("../extensions/jwLambda"), | |
// omega num port for penguinmod | |
jwNum: () => require("../extensions/jwNum"), | |
// good color utilties | |
jwColor: () => require("../extensions/jwColor"), | |
// jw: They'll think its made by jwklong >:) | |
// (but it's not (yet (maybe (probably not (but its made by ianyourgod))))) | |
// this is the real jwklong speaking, one word shall be said about this: A N G E R Y | |
// Structs: hehe structs for oop (look at c) | |
jwStructs: () => require("../extensions/jw_structs"), | |
// mikedev: ghytfhygfvbl | |
// cl: () => require("../extensions/cl"), | |
Gamepad: () => require("../extensions/GamepadExtension"), | |
// theshovel: ... | |
// theshovelcanvaseffects: ... | |
theshovelcanvaseffects: () => require("../extensions/theshovel_canvasEffects"), | |
// shovellzcompresss: ... | |
shovellzcompresss: () => require("../extensions/theshovel_lzString"), | |
// shovelColorPicker: ... | |
shovelColorPicker: () => require("../extensions/theshovel_colorPicker"), | |
// shovelcss: ... | |
shovelcss: () => require("../extensions/theshovel_customStyles"), | |
// profanityAPI: ... | |
profanityAPI: () => require("../extensions/theshovel_profanity"), | |
// gsa: fill out your introduction stupet!!! | |
// no >:( | |
// canvas: kinda obvius if you know anything about html canvases | |
canvas: () => require('../extensions/gsa_canvas_old'), | |
// the replacment for the above extension | |
newCanvas: () => require('../extensions/gsa_canvas'), | |
// tempVars: fill out your introduction stupet!!! | |
tempVars: () => require('../extensions/gsa_tempVars'), | |
// colors: fill out your introduction stupet!!! | |
colors: () => require('../extensions/gsa_colorUtilBlocks'), | |
// Camera: camera | |
pmCamera: () => require('../extensions/pm_camera'), | |
// sharkpool: insert sharkpools epic introduction here | |
// sharkpoolPrinting: ... | |
sharkpoolPrinting: () => require("../extensions/sharkpool_printing"), | |
// silvxrcat: ... | |
// oddMessage: ... | |
oddMessage: () => require("../extensions/silvxrcat_oddmessages"), | |
// TW extensions | |
// lms: ... | |
// lmsutilsblocks: ... | |
lmsutilsblocks: () => require('../extensions/lmsutilsblocks'), | |
lmsTempVars2: () => require('../extensions/lily_tempVars2'), | |
// xeltalliv: ... | |
// xeltallivclipblend: ... | |
xeltallivclipblend: () => require('../extensions/xeltalliv_clippingblending'), | |
// DT: ... | |
// DTcameracontrols: ... | |
DTcameracontrols: () => require('../extensions/dt_cameracontrols'), | |
// griffpatch: ... | |
// griffpatch: () => require('../extensions/griffpatch_box2d') | |
// iyg: erm a crep, erm a werdohhhh | |
// iygPerlin: | |
iygPerlin: () => require('../extensions/iyg_perlin_noise'), | |
// fr: waw 3d physics!! | |
// fr3d: | |
fr3d: () => require('../extensions/fr_3d') | |
}; | |
const coreExtensionList = Object.getOwnPropertyNames(defaultBuiltinExtensions); | |
const preload = []; | |
if (IsLocal || IsLiveTests) { | |
preload.push("jgDev"); | |
} | |
/** | |
* @typedef {object} ArgumentInfo - Information about an extension block argument | |
* @property {ArgumentType} type - the type of value this argument can take | |
* @property {*|undefined} default - the default value of this argument (default: blank) | |
*/ | |
/** | |
* @typedef {object} ConvertedBlockInfo - Raw extension block data paired with processed data ready for scratch-blocks | |
* @property {ExtensionBlockMetadata} info - the raw block info | |
* @property {object} json - the scratch-blocks JSON definition for this block | |
* @property {string} xml - the scratch-blocks XML definition for this block | |
*/ | |
/** | |
* @typedef {object} CategoryInfo - Information about a block category | |
* @property {string} id - the unique ID of this category | |
* @property {string} name - the human-readable name of this category | |
* @property {string|undefined} blockIconURI - optional URI for the block icon image | |
* @property {string} color1 - the primary color for this category, in '#rrggbb' format | |
* @property {string} color2 - the secondary color for this category, in '#rrggbb' format | |
* @property {string} color3 - the tertiary color for this category, in '#rrggbb' format | |
* @property {Array.<ConvertedBlockInfo>} blocks - the blocks, separators, etc. in this category | |
* @property {Array.<object>} menus - the menus provided by this category | |
*/ | |
/** | |
* @typedef {object} PendingExtensionWorker - Information about an extension worker still initializing | |
* @property {string} extensionURL - the URL of the extension to be loaded by this worker | |
* @property {Function} resolve - function to call on successful worker startup | |
* @property {Function} reject - function to call on failed worker startup | |
*/ | |
const createExtensionService = extensionManager => { | |
const service = {}; | |
service.registerExtensionServiceSync = extensionManager.registerExtensionServiceSync.bind(extensionManager); | |
service.allocateWorker = extensionManager.allocateWorker.bind(extensionManager); | |
service.onWorkerInit = extensionManager.onWorkerInit.bind(extensionManager); | |
service.registerExtensionService = extensionManager.registerExtensionService.bind(extensionManager); | |
return service; | |
}; | |
class ExtensionManager { | |
constructor(vm) { | |
/** | |
* The ID number to provide to the next extension worker. | |
* @type {int} | |
*/ | |
this.nextExtensionWorker = 0; | |
/** | |
* FIFO queue of extensions which have been requested but not yet loaded in a worker, | |
* along with promise resolution functions to call once the worker is ready or failed. | |
* | |
* @type {Array.<PendingExtensionWorker>} | |
*/ | |
this.pendingExtensions = []; | |
/** | |
* Map of worker ID to workers which have been allocated but have not yet finished initialization. | |
* @type {Array.<PendingExtensionWorker>} | |
*/ | |
this.pendingWorkers = []; | |
/** | |
* Map of worker ID to the URL where it was loaded from. | |
* @type {Array<string>} | |
*/ | |
this.workerURLs = []; | |
/** | |
* Map of loaded extension URLs/IDs to service names. | |
* @type {Map.<string, string>} | |
* @private | |
*/ | |
this._loadedExtensions = new Map(); | |
/** | |
* Responsible for determining security policies related to custom extensions. | |
*/ | |
this.securityManager = new SecurityManager(); | |
/** | |
* @type {VirtualMachine} | |
*/ | |
this.vm = vm; | |
/** | |
* Keep a reference to the runtime so we can construct internal extension objects. | |
* TODO: remove this in favor of extensions accessing the runtime as a service. | |
* @type {Runtime} | |
*/ | |
this.runtime = vm.runtime; | |
this.loadingAsyncExtensions = 0; | |
this.asyncExtensionsLoadedCallbacks = []; | |
this.builtinExtensions = Object.assign({}, defaultBuiltinExtensions); | |
dispatch.setService('extensions', createExtensionService(this)).catch(e => { | |
log.error(`ExtensionManager was unable to register extension service: ${JSON.stringify(e)}`); | |
}); | |
preload.forEach(value => { | |
this.loadExtensionURL(value); | |
}); | |
this.extUrlCodes = {}; | |
// extensions that the user has stated (when they where loaded) that they do not wnat updated | |
this.keepOlder = []; | |
// map of all new shas so we know when a new code update has happened and so ask the user about it | |
this.extensionHashes = {}; | |
} | |
getCoreExtensionList() { | |
return coreExtensionList; | |
} | |
getBuiltInExtensionsList() { | |
return this.builtinExtensions; | |
} | |
getAddonBlockSwitches() { | |
return AddonSwitches(this.vm); | |
} | |
/** | |
* Check whether an extension is registered or is in the process of loading. This is intended to control loading or | |
* adding extensions so it may return `true` before the extension is ready to be used. Use the promise returned by | |
* `loadExtensionURL` if you need to wait until the extension is truly ready. | |
* @param {string} extensionID - the ID of the extension. | |
* @returns {boolean} - true if loaded, false otherwise. | |
*/ | |
isExtensionLoaded(extensionID) { | |
return this._loadedExtensions.has(extensionID); | |
} | |
/** | |
* Determine whether an extension with a given ID is built in to the VM, such as pen. | |
* Note that "core extensions" like motion will return false here. | |
* @param {string} extensionId | |
* @returns {boolean} | |
*/ | |
isBuiltinExtension(extensionId) { | |
return Object.prototype.hasOwnProperty.call(this.builtinExtensions, extensionId); | |
} | |
/** | |
* Synchronously load an internal extension (core or non-core) by ID. This call will | |
* fail if the provided id is not does not match an internal extension. | |
* @param {string} extensionId - the ID of an internal extension | |
*/ | |
loadExtensionIdSync(extensionId) { | |
if (!this.isBuiltinExtension(extensionId)) { | |
log.warn(`Could not find extension ${extensionId} in the built in extensions.`); | |
return; | |
} | |
/** @TODO dupe handling for non-builtin extensions. See commit 670e51d33580e8a2e852b3b038bb3afc282f81b9 */ | |
if (this.isExtensionLoaded(extensionId)) { | |
const message = `Rejecting attempt to load a second extension with ID ${extensionId}`; | |
log.warn(message); | |
return; | |
} | |
const extension = this.builtinExtensions[extensionId](); | |
const extensionInstance = new extension(this.runtime); | |
const serviceName = this._registerInternalExtension(extensionInstance); | |
// devs are stupid so uh | |
// get the ACTUAL id of the ext so that saving/loading doesnt error | |
const realId = extensionInstance.getInfo().id; | |
this._loadedExtensions.set(extensionId, serviceName); | |
this.runtime.compilerRegisterExtension(realId, extensionInstance); | |
} | |
addBuiltinExtension (extensionId, extensionClass) { | |
this.builtinExtensions[extensionId] = () => extensionClass; | |
} | |
_isValidExtensionURL(extensionURL) { | |
try { | |
const parsedURL = new URL(extensionURL); | |
return ( | |
parsedURL.protocol === 'https:' || | |
parsedURL.protocol === 'http:' || | |
parsedURL.protocol === 'data:' || | |
parsedURL.protocol === 'file:' | |
); | |
} catch (e) { | |
return false; | |
} | |
} | |
/** | |
* Load an extension by URL or internal extension ID | |
* @param {string} normalURL - the URL for the extension to load OR the ID of an internal extension | |
* @param {string|null} oldHash - included when loading, contains the known hash that is from the loaded file so it can be compared with the one gotten over the url | |
* @returns {Promise} resolved once the extension is loaded and initialized or rejected on failure | |
*/ | |
async loadExtensionURL(extensionURL, oldHash = '') { | |
if (this.isBuiltinExtension(extensionURL)) { | |
this.loadExtensionIdSync(extensionURL); | |
return [extensionURL]; | |
} | |
if (this.isExtensionURLLoaded(extensionURL)) { | |
// Extension is already loaded. | |
return []; | |
} | |
if (!this._isValidExtensionURL(extensionURL)) { | |
throw new Error(`Invalid extension URL: ${extensionURL}`); | |
} | |
if (extensionURL.includes("penguinmod.site")) { | |
alert("Extensions using penguinmod.site are deprecated, please swap them over to use penguinmod.com instead.") | |
} | |
const normalURL = extensionURL.replace("penguinmod.site", "penguinmod.com"); | |
this.runtime.setExternalCommunicationMethod('customExtensions', true); | |
this.loadingAsyncExtensions++; | |
const sandboxMode = await this.securityManager.getSandboxMode(normalURL); | |
const rewritten = await this.securityManager.rewriteExtensionURL(normalURL); | |
const blob = (await fetch(rewritten).then(req => req.blob())) | |
const blobUrl = URL.createObjectURL(blob) | |
const newHash = await new Promise(resolve => { | |
const reader = new FileReader() | |
reader.onload = async ({ target: { result } }) => { | |
console.log(result) | |
this.extUrlCodes[extensionURL] = result | |
resolve(await sha256(result)) | |
} | |
reader.onerror = err => { | |
console.error('couldnt read the contents of url', url, err) | |
} | |
reader.readAsText(blob) | |
}) | |
this.extensionHashes[extensionURL] = newHash | |
if (oldHash && oldHash !== newHash && this.securityManager.shouldUseLocal(extensionURL)) return Promise.reject('useLocal') | |
if (sandboxMode === 'unsandboxed') { | |
const { load } = require('./tw-unsandboxed-extension-runner'); | |
const extensionObjects = await load(blobUrl, this.vm) | |
.catch(error => this._failedLoadingExtensionScript(error)); | |
const fakeWorkerId = this.nextExtensionWorker++; | |
const returnedIDs = []; | |
this.workerURLs[fakeWorkerId] = normalURL; | |
for (const extensionObject of extensionObjects) { | |
const extensionInfo = extensionObject.getInfo(); | |
const serviceName = `unsandboxed.${fakeWorkerId}.${extensionInfo.id}`; | |
dispatch.setServiceSync(serviceName, extensionObject); | |
dispatch.callSync('extensions', 'registerExtensionServiceSync', serviceName); | |
this._loadedExtensions.set(extensionInfo.id, serviceName); | |
returnedIDs.push(extensionInfo.id); | |
this.runtime.compilerRegisterExtension(extensionInfo.id, extensionObject); | |
} | |
this._finishedLoadingExtensionScript(); | |
return returnedIDs; | |
} | |
/* eslint-disable max-len */ | |
let ExtensionWorker; | |
if (sandboxMode === 'worker') { | |
ExtensionWorker = require('worker-loader?name=js/extension-worker/extension-worker.[hash].js!./extension-worker'); | |
} else if (sandboxMode === 'iframe') { | |
ExtensionWorker = (await import(/* webpackChunkName: "iframe-extension-worker" */ './tw-iframe-extension-worker')).default; | |
} else { | |
throw new Error(`Invalid sandbox mode: ${sandboxMode}`); | |
} | |
/* eslint-enable max-len */ | |
return new Promise((resolve, reject) => { | |
this.pendingExtensions.push({ extensionURL: blobUrl, resolve, reject }); | |
dispatch.addWorker(new ExtensionWorker()); | |
}).catch(error => this._failedLoadingExtensionScript(error)); | |
} | |
/** | |
* Wait until all async extensions have loaded | |
* @returns {Promise} resolved when all async extensions have loaded | |
*/ | |
allAsyncExtensionsLoaded() { | |
if (this.loadingAsyncExtensions === 0) { | |
return; | |
} | |
return new Promise((resolve, reject) => { | |
this.asyncExtensionsLoadedCallbacks.push({ | |
resolve, | |
reject | |
}); | |
}); | |
} | |
/** | |
* Regenerate blockinfo for all loaded dynamic extensions | |
* @returns {Promise} resolved once all the extensions have been reinitialized | |
*/ | |
refreshDynamicCategorys() { | |
if (!this._loadedExtensions) return Promise.reject('_loadedExtensions is not readable yet'); | |
const allPromises = Array.from(this._loadedExtensions.values()).map(serviceName => | |
dispatch.call(serviceName, 'getInfo') | |
.then(info => { | |
info = this._prepareExtensionInfo(serviceName, info); | |
if (!info.isDynamic) return; | |
dispatch.call('runtime', '_refreshExtensionPrimitives', info); | |
}) | |
.catch(e => { | |
log.error(`Failed to refresh built-in extension primitives: ${e}`); | |
}) | |
); | |
return Promise.all(allPromises); | |
} | |
/** | |
* Regenerate blockinfo for any loaded extensions | |
* @returns {Promise} resolved once all the extensions have been reinitialized | |
*/ | |
refreshBlocks() { | |
const allPromises = Array.from(this._loadedExtensions.values()).map(serviceName => | |
dispatch.call(serviceName, 'getInfo') | |
.then(info => { | |
info = this._prepareExtensionInfo(serviceName, info); | |
dispatch.call('runtime', '_refreshExtensionPrimitives', info); | |
}) | |
.catch(e => { | |
log.error(`Failed to refresh built-in extension primitives: ${e}`); | |
}) | |
); | |
return Promise.all(allPromises); | |
} | |
prepareSwap(id) { | |
const serviceName = this._loadedExtensions.get(id); | |
dispatch.call(serviceName, 'dispose'); | |
delete dispatch.services[serviceName]; | |
delete this.runtime[`ext_${id}`]; | |
this._loadedExtensions.delete(id); | |
const workerId = +serviceName.split('.')[1]; | |
delete this.workerURLs[workerId]; | |
} | |
removeExtension(id) { | |
const serviceName = this._loadedExtensions.get(id); | |
dispatch.call(serviceName, 'dispose'); | |
delete dispatch.services[serviceName]; | |
delete this.runtime[`ext_${id}`]; | |
this._loadedExtensions.delete(id); | |
const workerId = +serviceName.split('.')[1]; | |
delete this.workerURLs[workerId]; | |
dispatch.call('runtime', '_removeExtensionPrimitive', id); | |
this.refreshBlocks(); | |
} | |
allocateWorker() { | |
const id = this.nextExtensionWorker++; | |
const workerInfo = this.pendingExtensions.shift(); | |
this.pendingWorkers[id] = workerInfo; | |
this.workerURLs[id] = workerInfo.extensionURL; | |
return [id, workerInfo.extensionURL]; | |
} | |
/** | |
* Synchronously collect extension metadata from the specified service and begin the extension registration process. | |
* @param {string} serviceName - the name of the service hosting the extension. | |
*/ | |
registerExtensionServiceSync(serviceName) { | |
const info = dispatch.callSync(serviceName, 'getInfo'); | |
this._registerExtensionInfo(serviceName, info); | |
} | |
/** | |
* Collect extension metadata from the specified service and begin the extension registration process. | |
* @param {string} serviceName - the name of the service hosting the extension. | |
*/ | |
registerExtensionService(serviceName) { | |
dispatch.call(serviceName, 'getInfo').then(info => { | |
this._loadedExtensions.set(info.id, serviceName); | |
this._registerExtensionInfo(serviceName, info); | |
this._finishedLoadingExtensionScript(); | |
}); | |
} | |
_finishedLoadingExtensionScript() { | |
this.loadingAsyncExtensions--; | |
if (this.loadingAsyncExtensions === 0) { | |
this.asyncExtensionsLoadedCallbacks.forEach(i => i.resolve()); | |
this.asyncExtensionsLoadedCallbacks = []; | |
} | |
} | |
_failedLoadingExtensionScript(error) { | |
// Don't set the current extension counter to 0, otherwise it will go negative if another | |
// extension finishes or fails to load. | |
this.loadingAsyncExtensions--; | |
this.asyncExtensionsLoadedCallbacks.forEach(i => i.reject(error)); | |
this.asyncExtensionsLoadedCallbacks = []; | |
// Re-throw error so the promise still rejects. | |
throw error; | |
} | |
/** | |
* Called by an extension worker to indicate that the worker has finished initialization. | |
* @param {int} id - the worker ID. | |
* @param {*?} e - the error encountered during initialization, if any. | |
*/ | |
onWorkerInit(id, e) { | |
const workerInfo = this.pendingWorkers[id]; | |
delete this.pendingWorkers[id]; | |
if (e) { | |
workerInfo.reject(e); | |
} else { | |
workerInfo.resolve(); | |
} | |
} | |
/** | |
* Register an internal (non-Worker) extension object | |
* @param {object} extensionObject - the extension object to register | |
* @returns {string} The name of the registered extension service | |
*/ | |
_registerInternalExtension(extensionObject) { | |
const extensionInfo = extensionObject.getInfo(); | |
const fakeWorkerId = this.nextExtensionWorker++; | |
const serviceName = `extension_${fakeWorkerId}_${extensionInfo.id}`; | |
dispatch.setServiceSync(serviceName, extensionObject); | |
dispatch.callSync('extensions', 'registerExtensionServiceSync', serviceName); | |
return serviceName; | |
} | |
/** | |
* Sanitize extension info then register its primitives with the VM. | |
* @param {string} serviceName - the name of the service hosting the extension | |
* @param {ExtensionInfo} extensionInfo - the extension's metadata | |
* @private | |
*/ | |
_registerExtensionInfo(serviceName, extensionInfo) { | |
extensionInfo = this._prepareExtensionInfo(serviceName, extensionInfo); | |
dispatch.call('runtime', '_registerExtensionPrimitives', extensionInfo).catch(e => { | |
log.error(`Failed to register primitives for extension on service ${serviceName}:`, e); | |
}); | |
} | |
/** | |
* Apply minor cleanup and defaults for optional extension fields. | |
* TODO: make the ID unique in cases where two copies of the same extension are loaded. | |
* @param {string} serviceName - the name of the service hosting this extension block | |
* @param {ExtensionInfo} extensionInfo - the extension info to be sanitized | |
* @returns {ExtensionInfo} - a new extension info object with cleaned-up values | |
* @private | |
*/ | |
_prepareExtensionInfo(serviceName, extensionInfo) { | |
extensionInfo = Object.assign({}, extensionInfo); | |
if (!/^[a-z0-9]+$/i.test(extensionInfo.id)) { | |
throw new Error('Invalid extension id'); | |
} | |
extensionInfo.name = extensionInfo.name || extensionInfo.id; | |
extensionInfo.blocks = extensionInfo.blocks || []; | |
extensionInfo.targetTypes = extensionInfo.targetTypes || []; | |
extensionInfo.menus = extensionInfo.menus || {}; | |
extensionInfo.menus = this._prepareMenuInfo(serviceName, extensionInfo.menus); | |
extensionInfo.blocks = extensionInfo.blocks.reduce((results, blockInfo) => { | |
try { | |
let result; | |
switch (blockInfo) { | |
case '---': // separator | |
result = '---'; | |
break; | |
default: // an ExtensionBlockMetadata object | |
result = this._prepareBlockInfo(serviceName, blockInfo, extensionInfo.menus); | |
break; | |
} | |
results.push(result); | |
} catch (e) { | |
// TODO: more meaningful error reporting | |
log.error(`Error processing block: ${e.message}, Block:\n${JSON.stringify(blockInfo)}`); | |
} | |
return results; | |
}, []); | |
return extensionInfo; | |
} | |
/** | |
* Prepare extension menus. e.g. setup binding for dynamic menu functions. | |
* @param {string} serviceName - the name of the service hosting this extension block | |
* @param {Array.<MenuInfo>} menus - the menu defined by the extension. | |
* @returns {Array.<MenuInfo>} - a menuInfo object with all preprocessing done. | |
* @private | |
*/ | |
_prepareMenuInfo(serviceName, menus) { | |
const menuNames = Object.getOwnPropertyNames(menus); | |
for (let i = 0; i < menuNames.length; i++) { | |
const menuName = menuNames[i]; | |
let menuInfo = menus[menuName]; | |
// If the menu description is in short form (items only) then normalize it to general form: an object with | |
// its items listed in an `items` property. | |
if (!menuInfo.items && (typeof menuInfo.variableType !== 'string')) { | |
menuInfo = { | |
items: menuInfo | |
}; | |
menus[menuName] = menuInfo; | |
} | |
// If `items` is a string, it should be the name of a function in the extension object. Calling the | |
// function should return an array of items to populate the menu when it is opened. | |
if (typeof menuInfo.items === 'string') { | |
const menuItemFunctionName = menuInfo.items; | |
const serviceObject = dispatch.services[serviceName]; | |
// Bind the function here so we can pass a simple item generation function to Scratch Blocks later. | |
menuInfo.items = this._getExtensionMenuItems.bind(this, serviceObject, menuItemFunctionName); | |
} | |
} | |
return menus; | |
} | |
/** | |
* Fetch the items for a particular extension menu, providing the target ID for context. | |
* @param {object} extensionObject - the extension object providing the menu. | |
* @param {string} menuItemFunctionName - the name of the menu function to call. | |
* @returns {Array} menu items ready for scratch-blocks. | |
* @private | |
*/ | |
_getExtensionMenuItems(extensionObject, menuItemFunctionName) { | |
// Fetch the items appropriate for the target currently being edited. This assumes that menus only | |
// collect items when opened by the user while editing a particular target. | |
const editingTarget = this.runtime.getEditingTarget() || this.runtime.getTargetForStage(); | |
const editingTargetID = editingTarget ? editingTarget.id : null; | |
const extensionMessageContext = this.runtime.makeMessageContextForTarget(editingTarget); | |
// TODO: Fix this to use dispatch.call when extensions are running in workers. | |
const menuFunc = extensionObject[menuItemFunctionName]; | |
const menuItems = menuFunc.call(extensionObject, editingTargetID).map( | |
item => { | |
item = maybeFormatMessage(item, extensionMessageContext); | |
switch (typeof item) { | |
case 'object': | |
if (Array.isArray(item)) return item.slice(0, 2); | |
return [ | |
maybeFormatMessage(item.text, extensionMessageContext), | |
item.value | |
]; | |
case 'string': | |
return [item, item]; | |
default: | |
return item; | |
} | |
}); | |
if (!menuItems || menuItems.length < 1) { | |
throw new Error(`Extension menu returned no items: ${menuItemFunctionName}`); | |
} | |
return menuItems; | |
} | |
_normalize(thing, to) { | |
switch (to) { | |
case 'string': return Cast.toString(thing); | |
case 'bigint': | |
case 'number': return Cast.toNumber(thing); | |
case 'boolean': return Cast.toBoolean(thing); | |
case 'function': return new Function(thing); | |
default: return Cast.toString(thing); | |
} | |
} | |
/** | |
* Apply defaults for optional block fields. | |
* @param {string} serviceName - the name of the service hosting this extension block | |
* @param {ExtensionBlockMetadata} blockInfo - the block info from the extension | |
* @returns {ExtensionBlockMetadata} - a new block info object which has values for all relevant optional fields. | |
* @private | |
*/ | |
_prepareBlockInfo(serviceName, blockInfo, menus) { | |
if (blockInfo.blockType === BlockType.XML) { | |
blockInfo = Object.assign({}, blockInfo); | |
blockInfo.xml = String(blockInfo.xml) || ''; | |
return blockInfo; | |
} | |
blockInfo = Object.assign({}, { | |
blockType: BlockType.COMMAND, | |
terminal: false, | |
blockAllThreads: false, | |
arguments: {} | |
}, blockInfo); | |
blockInfo.text = blockInfo.text || blockInfo.opcode; | |
switch (blockInfo.blockType) { | |
case BlockType.EVENT: | |
if (blockInfo.func) { | |
log.warn(`Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}`); | |
} | |
break; | |
case BlockType.BUTTON: | |
if (!blockInfo.opcode && !blockInfo.func) { | |
throw new Error(`Missing opcode or func for button: ${blockInfo.text}`); | |
} | |
if (blockInfo.func && !blockInfo.opcode) { | |
blockInfo.opcode = blockInfo.func; | |
} | |
const funcName = blockInfo.opcode; | |
const callBlockFunc = (...args) => dispatch.call(serviceName, funcName, ...args); | |
blockInfo.func = callBlockFunc; | |
break; | |
case BlockType.LABEL: | |
break; | |
default: { | |
if (!blockInfo.opcode) { | |
throw new Error('Missing opcode for block'); | |
} | |
const funcName = blockInfo.func || blockInfo.opcode; | |
const getBlockInfo = blockInfo.isDynamic ? | |
args => args && args.mutation && args.mutation.blockInfo : | |
() => blockInfo; | |
const callBlockFunc = (() => { | |
if (dispatch._isRemoteService(serviceName)) { | |
return (args, util, realBlockInfo) => | |
dispatch.call(serviceName, funcName, args, util, realBlockInfo) | |
.then(result => { | |
// Scratch is only designed to handle these types. | |
// If any other value comes in such as undefined, null, an object, etc. | |
// we'll convert it to a string to avoid undefined behavior. | |
if ( | |
typeof result === 'number' || | |
typeof result === 'string' || | |
typeof result === 'boolean' | |
) { | |
return result; | |
} | |
return `${result}`; | |
}) | |
// When an error happens, instead of returning undefined, we'll return a stringified | |
// version of the error so that it can be debugged. | |
.catch(err => { | |
// We want the full error including stack to be printed but the log helper | |
// messes with that. | |
// eslint-disable-next-line no-console | |
console.error('Custom extension block error', err); | |
return `${err}`; | |
}); | |
} | |
// avoid promise latency if we can call direct | |
const serviceObject = dispatch.services[serviceName]; | |
if (!serviceObject[funcName]) { | |
// The function might show up later as a dynamic property of the service object | |
log.warn(`Could not find extension block function called ${funcName}`); | |
} | |
return (args, util, realBlockInfo) => | |
serviceObject[funcName](args, util, realBlockInfo); | |
})(); | |
blockInfo.func = (args, util, visualReport) => { | |
const normal = { | |
'angle': "number", | |
'Boolean': "boolean", | |
'color': "string", | |
'number': "number", | |
'string': "string", | |
'matrix': "string", | |
'note': "number", | |
'image': "string", | |
'polygon': "object", | |
// normalization exceptions | |
'list': "exception", | |
'broadcast': "exception" | |
}; | |
const realBlockInfo = getBlockInfo(args); | |
for (const arg in realBlockInfo.arguments) { | |
const expected = normal[realBlockInfo.arguments[arg].type]; | |
if (realBlockInfo.arguments[arg].exemptFromNormalization === true) continue; | |
if (expected === 'exception') continue; | |
if (!expected) continue; | |
// stupidly long check but :Trollhands | |
// if this argument is for a variable dropdown, do not type cast it | |
// as variable dropdowns report an object and not something we can or should cast | |
if (typeof menus[realBlockInfo.arguments[arg].menu]?.variableType === 'string') continue; | |
if (!(typeof args[arg] === expected)) args[arg] = this._normalize(args[arg], expected); | |
} | |
// TODO: filter args using the keys of realBlockInfo.arguments? maybe only if sandboxed? | |
const returnValue = callBlockFunc(args, util, realBlockInfo); | |
if (!visualReport && (returnValue?.value ?? false)) return returnValue.value; | |
return returnValue; | |
}; | |
break; | |
} | |
} | |
return blockInfo; | |
} | |
extensionUrlFromId(extId) { | |
for (const [extensionId, serviceName] of this._loadedExtensions.entries()) { | |
if (extensionId !== extId) continue; | |
// Service names for extension workers are in the format "extension.WORKER_ID.EXTENSION_ID" | |
const workerId = +serviceName.split('.')[1]; | |
return this.workerURLs[workerId]; | |
} | |
} | |
getExtensionURLs() { | |
const extensionURLs = {}; | |
for (const [extensionId, serviceName] of this._loadedExtensions.entries()) { | |
// Service names for extension workers are in the format "extension.WORKER_ID.EXTENSION_ID" | |
const workerId = +serviceName.split('.')[1]; | |
const extensionURL = this.workerURLs[workerId]; | |
if (typeof extensionURL === 'string') { | |
extensionURLs[extensionId] = extensionURL; | |
} | |
} | |
return extensionURLs; | |
} | |
isExtensionURLLoaded (url) { | |
return this.workerURLs.includes(url); | |
} | |
} | |
module.exports = ExtensionManager; | |