diff --git a/src/extension-support/extension-load-helper.js b/src/extension-support/extension-load-helper.js index ee3cc03a..ccc1805c 100644 --- a/src/extension-support/extension-load-helper.js +++ b/src/extension-support/extension-load-helper.js @@ -1,69 +1,119 @@ // output a Scratch Object contains APIs all extension needed -const BlockType = require('./block-type'); -const ArgumentType = require('./argument-type'); -const TargetType = require('./target-type'); -const Cast = require('../util/cast'); -const Color = require('../util/color'); -const createTranslate = require('./tw-l10n'); -const log = require('../util/log'); +const BlockType = require("./block-type"); +const ArgumentType = require("./argument-type"); +const TargetType = require("./target-type"); +const Cast = require("../util/cast"); +const Color = require("../util/color"); +const createTranslate = require("./tw-l10n"); +const log = require("../util/log"); + +/** + * @typedef {{ info: unknown, Extension?: Function, extensionInstance?: unknown }} RegisteredExtension + */ + +/** + * @typedef {{ result: RegisteredExtension[], source: 'iife' | 'tempExt' | 'ExtensionLib' | 'scratchExtensions'}} RegisterResult + */ let openVM = null; -let translate = null; -let needSetup = true; -const pending = new Set(); - -const clearScratchAPI = id => { - pending.delete(id); - if (global.IIFEExtensionInfoList && id) { - global.IIFEExtensionInfoList = global.IIFEExtensionInfoList.filter(({extensionObject}) => extensionObject.info.extensionId !== id); - } - if (global.Scratch && pending.size === 0) { - global.Scratch.extensions = { - register: extensionInstance => { - const info = extensionInstance.getInfo(); - throw new Error(`ScratchAPI: ${info.id} call extensions.register too late`); - } - }; - global.Scratch.vm = null; - global.Scratch.runtime = null; - global.Scratch.renderer = null; - needSetup = true; +/** @type {{ resolve: (res: RegisterResult | undefined) => void, reject: (reason: unknown) => void, promise: Promise}=} */ +let loadingPromise; +let globalScratch; + +function initalizePromise() { + const pm = {}; + pm.promise = new Promise((resolve, reject) => { + [pm.resolve, pm.reject] = [resolve, reject]; + }); + return pm; +} + +const clearScratchAPI = () => { + if (globalScratch) { + delete global.tempExt; + delete global.ExtensionLib; + delete global.scratchExt; + if (global.Scratch) { + global.Scratch = globalScratch; + globalScratch = undefined; + } + if (loadingPromise) loadingPromise.resolve(); + loadingPromise = undefined; } }; -const setupScratchAPI = (vm, id) => { - pending.add(id); - if (!needSetup) { - return; - } - const registerExt = extensionInstance => { +const setupScratchAPI = async (vm) => { + if (loadingPromise) await loadingPromise.promise; + loadingPromise = initalizePromise(); + + const registerExt = (extensionInstance, optMetadata) => { const info = extensionInstance.getInfo(); const extensionId = info.id; const extensionObject = { - info: { - name: info.name, - extensionId - }, - Extension: () => extensionInstance.constructor + info: Object.assign( + { + name: info.name, + extensionId, + }, + optMetadata + ), + Extension: () => new Proxy(extensionInstance.constructor, { + construct() { + return extensionInstance + } + }), + extensionInstance: extensionInstance, }; - global.IIFEExtensionInfoList = global.IIFEExtensionInfoList || []; - global.IIFEExtensionInfoList.push({extensionObject, extensionInstance}); - return; + loadingPromise.resolve({ source: "iife", result: [extensionObject] }); + clearScratchAPI(); }; + Object.defineProperty(global, "tempExt", { + get() {}, + set(v) { + loadingPromise.resolve(v); + clearScratchAPI(); + }, + configurable: true, + }); + Object.defineProperty(global, "ExtensionLib", { + get() {}, + set(v) { + v.then((lib) => { + loadingPromise.resolve({ + source: "ExtensionLib", + result: Object.values(lib), + }); + clearScratchAPI(); + }); + }, + }); + Object.defineProperty(global, "scratchExtensions", { + get() {}, + set(v) { + const added = []; + v.default().then(({ default: lib }) => { + Object.entries(lib).forEach(([key, obj]) => { + if (!(obj.info && obj.info.extensionId)) { + // compatible with some legacy gandi extension service + obj.info = obj.info || {}; + obj.info.extensionId = key; + } + if (obj.info) added.push(obj); + }); + loadingPromise.resolve({ + source: "scratchExtensions", + result: added, + }); + clearScratchAPI(); + }); + }, + }); if (!openVM) { - const {runtime} = vm; + const { runtime } = vm; if (runtime.ccwAPI && runtime.ccwAPI.getOpenVM) { openVM = runtime.ccwAPI.getOpenVM(); - } - openVM = { - runtime: vm.runtime, - exports: vm.exports, - ...openVM - }; - } - if (!translate) { - translate = createTranslate(vm); + } else openVM = vm; } const scratch = { @@ -72,74 +122,85 @@ const setupScratchAPI = (vm, id) => { TargetType, Cast, Color, - translate, + translate: createTranslate(vm), extensions: { - register: registerExt + register: registerExt, }, vm: openVM, runtime: openVM.runtime, - renderer: openVM.runtime.renderer + renderer: openVM.runtime.renderer, }; - global.Scratch = Object.assign(global.Scratch || {}, scratch); - needSetup = false; + globalScratch = global.Scratch; + global.Scratch = scratch; }; -const createdScriptLoader = ({url, onSuccess, onError}) => { +/** + * + * @param {*} vm + * @param {*} url + * @returns {Promise} + */ +const loadExtension = async (vm, url) => { if (!url) { - return onError('remote extension url is null'); + return onError("remote extension url is null"); } - const exist = document.getElementById(url); - if (exist) { - log.warn(`${url} remote extension script already loaded before`); - exist.successCallBack.push(onSuccess); - exist.failedCallBack.push(onError); - return exist; + await setupScratchAPI(vm); + const pm = loadingPromise; + + if (!url) { + return onError("remote extension url is null"); } - const script = document.createElement('script'); - script.src = `${url + (url.includes('?') ? '&' : '?')}t=${Date.now()}`; + const script = document.createElement("script"); + const parsedURL = new URL(url); + script.src = + parsedURL.protocol === "data:" + ? url + : `${url + (url.includes("?") ? "&" : "?")}t=${Date.now()}`; script.id = url; script.defer = true; - script.type = 'module'; - - script.successCallBack = [onSuccess]; - script.failedCallBack = [onError]; + script.type = "module"; let scriptError = null; - const logError = e => { + const logError = (e) => { scriptError = e; }; - global.addEventListener('error', logError); + + global.addEventListener("error", logError); const removeScript = () => { - global.removeEventListener('error', logError); + global.removeEventListener("error", logError); document.body.removeChild(script); }; - script.onload = () => { - if (scriptError) { - script.failedCallBack.forEach(cb => cb?.(scriptError, url)); - script.failedCallBack = []; - } else { - script.successCallBack.forEach(cb => cb(url)); - script.successCallBack = []; - } - removeScript(); - }; - - script.onerror = e => { - script.failedCallBack.forEach(cb => cb?.(e, url)); - script.failedCallBack = []; - removeScript(); - }; + script.addEventListener("error", (e) => { + pm.reject(e); + loadingPromise = undefined; + }); try { - document.body.append(script); + document.body.appendChild(script); } catch (error) { - removeScript(); - log.error('load custom extension error:', error); + pm.reject(e); + loadingPromise = undefined; + log.error("load custom extension error:", error); } - return script; + + return pm.promise + .then((v) => { + if (scriptError) { + throw scriptError; + } + return v; + }) + .finally(() => { + loadingPromise = undefined; + removeScript(); + }); }; -module.exports = {setupScratchAPI, clearScratchAPI, createdScriptLoader}; +module.exports = { + setupScratchAPI, + clearScratchAPI, + loadExtension, +}; diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index 68c91112..a6216c7c 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -3,7 +3,7 @@ const log = require('../util/log'); const maybeFormatMessage = require('../util/maybe-format-message'); const formatMessage = require('format-message'); const BlockType = require('./block-type'); -const {setupScratchAPI, clearScratchAPI, createdScriptLoader} = require('./extension-load-helper'); +const {setupScratchAPI, clearScratchAPI, loadExtension} = require('./extension-load-helper'); const SecurityManager = require('./tw-security-manager'); // These extensions are currently built into the VM repository but should not be loaded at startup. @@ -99,17 +99,18 @@ const createExtensionService = extensionManager => { // check if func is a class const isConstructor = value => { - try { - // eslint-disable-next-line no-new - new new Proxy(value, { - construct () { - return {}; - } - })(); - return true; - } catch (err) { - return false; - } + // try { + // // eslint-disable-next-line no-new + // new new Proxy(value, { + // construct () { + // return {}; + // } + // })(); + // return true; + // } catch (err) { + // return false; + // } + return !!value.prototype; }; class ExtensionManager { @@ -884,16 +885,16 @@ class ExtensionManager { } } - loadExternalExtensionById (extensionId, shouldReplace = false) { + async loadExternalExtensionById (extensionId, shouldReplace = false) { if (this.isExtensionLoaded(extensionId) && !shouldReplace) { // avoid init extension twice if it already loaded return; } - setupScratchAPI(this.vm, extensionId); - return this.getExternalExtensionConstructor(extensionId) + return setupScratchAPI(this.vm) + .then(() => this.getExternalExtensionConstructor(extensionId)) .then(extension => this.registerExtension(extensionId, extension, shouldReplace)) .finally(() => { - clearScratchAPI(extensionId); + clearScratchAPI(); }); } @@ -1014,74 +1015,25 @@ class ExtensionManager { const onlyAdded = []; const addedAndLoaded = []; // exts use Scratch.extensions.register const rewritten = await this.securityManager.rewriteExtensionURL(url); - return new Promise((resolve, reject) => { - setupScratchAPI(this.vm, rewritten); - createdScriptLoader({ - url: rewritten, - onSuccess: async () => { - try { - if (global.IIFEExtensionInfoList) { - // for those extension which registered by scratch.extensions.register in IIFE - global.IIFEExtensionInfoList.forEach(({extensionObject, extensionInstance}) => { - this.addCustomExtensionInfo(extensionObject, url); - if (disallowIIFERegister) { - onlyAdded.push(extensionObject.info.extensionId); - } else { - this.registerExtension(extensionObject.info.extensionId, extensionInstance, shouldReplace); - addedAndLoaded.push(extensionObject.info.extensionId); - } - }); - } - if (global.ExtensionLib) { - // for those extension which developed by user using ccw-customExt-tool - const lib = await global.ExtensionLib; - Object.keys(lib).forEach(key => { - const obj = lib[key]; - this.addCustomExtensionInfo(obj, url); - onlyAdded.push(obj.info.extensionId); - }); - delete global.ExtensionLib; - } - if (global.tempExt) { - // for user developing custom extension - const obj = global.tempExt; - this.addCustomExtensionInfo(obj, url); - onlyAdded.push(obj.info.extensionId); - delete global.tempExt; - } - if (global.scratchExtensions) { - // for Gandi extension service - const {default: lib} = - await global.scratchExtensions.default(); - Object.entries(lib).forEach(([key, obj]) => { - if (!(obj.info && obj.info.extensionId)) { - // compatible with some legacy gandi extension service - obj.info = obj.info || {}; - obj.info.extensionId = key; - } - this.addOfficialExtensionInfo(obj); - onlyAdded.push(obj.info && obj.info.extensionId); - }); - } - resolve({onlyAdded, addedAndLoaded}); - } catch (error) { - reject(error); - } - }, - onError: reject - }); - }) - // .catch(e => log.error('LoadRemoteExtensionError: ', e)) - .finally(() => { - clearScratchAPI(url); - if (onlyAdded.length > 0 || addedAndLoaded.length > 0) { - this.runtime.emit('EXTENSION_LIBRARY_UPDATED'); + try { + await setupScratchAPI(this.vm); + const result = await loadExtension(this.vm, rewritten); + for (const extensionObject of result.result) { + if (result.source === 'scratchExtensions') this.addOfficialExtensionInfo(extensionObject); + else this.addCustomExtensionInfo(extensionObject, url); + if (result.source !== 'iife' || disallowIIFERegister) { + onlyAdded.push(extensionObject.info.extensionId); + } else { + this.registerExtension(extensionObject.info.extensionId, extensionObject.extensionInstance, shouldReplace); + addedAndLoaded.push(extensionObject.info.extensionId); } - delete global.scratchExtensions; - delete global.tempExt; - delete global.ExtensionLib; - delete global.IIFEExtensionInfoList; - }); + } + return {onlyAdded, addedAndLoaded}; + } finally { + if (onlyAdded.length > 0 || addedAndLoaded.length > 0) { + this.runtime.emit('EXTENSION_LIBRARY_UPDATED'); + } + } }