From 76134575879aaccc67a6541c474915c92acd7de6 Mon Sep 17 00:00:00 2001 From: FurryR Date: Sat, 20 Jul 2024 13:55:27 +0800 Subject: [PATCH 1/2] :sparkles: feat: add context mixin support Signed-off-by: FurryR --- src/index.ts | 3 +- src/injector/inject.ts | 138 +++++------------------------------------ src/loader/loader.ts | 13 ++-- src/loader/make-ctx.ts | 5 ++ src/util/hijack.ts | 134 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 130 deletions(-) create mode 100644 src/util/hijack.ts diff --git a/src/index.ts b/src/index.ts index 335525d..081ab9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -import { getVMInstance, getBlocklyInstance, injectVM, injectBlockly, initalizeEureka } from './injector/inject'; +import { getVMInstance, getBlocklyInstance } from './util/hijack'; +import { injectVM, injectBlockly, initalizeEureka } from './injector/inject'; import { log } from './util/log'; // @ts-expect-error defined in webpack define plugin log(`eureka-loader ${__EUREKA_VERSION__}`); diff --git a/src/injector/inject.ts b/src/injector/inject.ts index 6510b13..d03a756 100644 --- a/src/injector/inject.ts +++ b/src/injector/inject.ts @@ -4,36 +4,16 @@ import { log, warn, error } from '../util/log'; import { settings } from '../util/settings'; import { EurekaLoader } from '../loader/loader'; import openFrontend from '../frontend'; -import type VM from 'scratch-vm'; +import { EurekaCompatibleVM, getBlocklyInstance } from '../util/hijack'; import type Blockly from 'scratch-blocks'; import * as l10n from '../l10n/l10n.json'; import formatMessage from 'format-message'; +import type { Context } from '../loader/make-ctx'; interface EurekaCompatibleWorkspace extends Blockly.Workspace { registerButtonCallback(key: string, callback: () => void): void; } -interface EurekaCompatibleVM extends VM { - ccExtensionManager?: { - info: Record< - string, - { - api: number; - } - >; - getExtensionLoadOrder(extensions: string[]): unknown; - }; - setLocale?: (locale: string, ...args: unknown[]) => unknown; - getLocale?: () => string; - _loadExtensions?: ( - extensionIDs: Set, - extensionURLs: Map, - ...args: unknown[] - ) => Promise; -} - -const MAX_LISTENING_MS = 30 * 1000; - /** * Utility function to determine if a value is a Promise. * @param {*} value Value to check for a Promise. @@ -62,71 +42,6 @@ function getExtensionIdForOpcode (opcode: string) { if (prefix !== '') return prefix; } -/** - * Get Blockly instance. - * @param vm Virtual machine instance. For some reasons we cannot use VM here. - * @returns Blockly instance. - */ -export async function getBlocklyInstance (vm: EurekaCompatibleVM): Promise { - function getBlocklyInstanceInternal (): any | null { - // Hijack Function.prototype.apply to get React element instance. - function hijack (fn: (...args: unknown[]) => unknown) { - const _orig = Function.prototype.apply; - Function.prototype.apply = function (thisArg: any) { - return thisArg; - }; - const result = fn(); - Function.prototype.apply = _orig; - return result; - } - - // @ts-expect-error lazy to extend VM interface - const events = vm._events?.EXTENSION_ADDED; - if (events) { - if (events instanceof Function) { - // It is a function, just hijack it. - const result = hijack(events); - if (result && typeof result === 'object' && 'ScratchBlocks' in result) { - return result.ScratchBlocks; - } - } else { - // It is an array, hijack every listeners. - for (const value of events) { - const result = hijack(value); - if (result && typeof result === 'object' && 'ScratchBlocks' in result) { - return result.ScratchBlocks; - } - } - } - } - return null; - } - let res = getBlocklyInstanceInternal(); - return ( - res ?? - new Promise((resolve) => { - let state: any = undefined; - // @ts-expect-error lazy to extend VM interface - Reflect.defineProperty(vm._events, 'EXTENSION_ADDED', { - get: () => state, - set (v) { - state = v; - res = getBlocklyInstanceInternal(); - if (res) { - // @ts-expect-error lazy to extend VM interface - Reflect.defineProperty(vm._events, 'EXTENSION_ADDED', { - value: state, - writable: true - }); - resolve(res); - } - }, - configurable: true - }); - }) - ); -} - /** * Initalize eureka global object. */ @@ -140,43 +55,6 @@ export function initalizeEureka () { }; } -/** - * Trap to get Virtual Machine instance. - * @return Callback promise. After that you could use window.eureka.vm to get the virtual machine. - */ -export async function getVMInstance (): Promise { - log('Listening bind function...'); - const oldBind = Function.prototype.bind; - try { - const vm = await new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - log('Cannot find vm instance, stop listening.'); - Function.prototype.bind = oldBind; - reject(); - }, MAX_LISTENING_MS); - - Function.prototype.bind = function (...args) { - if (Function.prototype.bind === oldBind) { - return oldBind.apply(this, args); - } else if ( - args[0] && - Object.prototype.hasOwnProperty.call(args[0], 'editingTarget') && - Object.prototype.hasOwnProperty.call(args[0], 'runtime') - ) { - log('VM detected!'); - Function.prototype.bind = oldBind; - clearTimeout(timeoutId); - resolve(args[0]); - return oldBind.apply(this, args); - } - return oldBind.apply(this, args); - }; - }); - return vm; - } catch { - return null; - } -} function setupFormat (vm: EurekaCompatibleVM) { const getLocale = vm.getLocale; const format = formatMessage.namespace(); @@ -482,9 +360,21 @@ export function injectVM (vm: EurekaCompatibleVM) { return originalGetOrderFunc.call(this, extensions, ...args); }; } + + // @ts-expect-error lazy to extend VM interface + vm.on('CREATE_UNSANDBOXED_EXTENSION_API', (ctx: Context) => { + // allow Eureka to provide fallback Scratch.gui implementation + ctx.gui = Object.assign({ + getBlockly: getBlocklyInstance.bind(null, vm), + getBlocklyEagerly: () => { + throw new Error('Not implemented'); + } + }, ctx.gui); + }); } export function injectBlockly (blockly: any) { + getBlocklyInstance.cache = blockly; const format = window.eureka.format; if (!format) { return error('You should inject VM first'); diff --git a/src/loader/loader.ts b/src/loader/loader.ts index eb1417b..47ae9fc 100644 --- a/src/loader/loader.ts +++ b/src/loader/loader.ts @@ -50,6 +50,12 @@ class UnsandboxedLoader { }); const code = await resp.text(); return new Promise((resolve) => { + const register = ctx.extensions.register; + // now UnsandboxedLoader only resolves after Scratch.extensions.register called + ctx.extensions.register = function (ext) { + resolve(); + return register.call(this, ext); + } const elem = document.createElement('script') as EurekaCompatibleScript; const src = URL.createObjectURL( new Blob( @@ -72,11 +78,6 @@ ${code} elem.src = src; elem.id = 'eurekaExtension'; document.head.appendChild(elem); - elem.addEventListener('load', () => { - URL.revokeObjectURL(src); - document.head.removeChild(elem); - resolve(); - }); elem.addEventListener('error', (err) => { URL.revokeObjectURL(src); document.head.removeChild(elem); @@ -170,6 +171,8 @@ class EurekaLoader { const extensionInfo = extensionObj.getInfo(); this._registerExtensionInfo(extensionObj, extensionInfo, ext); }; + // @ts-expect-error lazy to extend VM interface + this.vm.emit('CREATE_UNSANDBOXED_EXTENSION_API', ctx); await unsandboxedLoader.load(ext, ctx); return; } diff --git a/src/loader/make-ctx.ts b/src/loader/make-ctx.ts index d00d425..c4fee9e 100644 --- a/src/loader/make-ctx.ts +++ b/src/loader/make-ctx.ts @@ -6,6 +6,7 @@ import { StandardScratchExtensionClass as ExtensionClass } from '../typings'; import { Cast } from '../util/cast'; +import type Blockly from 'scratch-blocks'; import formatMessage, { Message } from 'format-message'; import type VM from 'scratch-vm'; import type Renderer from 'scratch-render'; @@ -28,6 +29,10 @@ export interface Context { renderer?: Renderer; fetch: typeof fetch; canFetch (url: string): boolean; + gui?: { + getBlockly: () => Promise, + getBlocklyEagerly: () => never + } } function parseURL (url: string) { diff --git a/src/util/hijack.ts b/src/util/hijack.ts new file mode 100644 index 0000000..3eb0224 --- /dev/null +++ b/src/util/hijack.ts @@ -0,0 +1,134 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference + +import { log } from '../util/log'; +import type Blockly from 'scratch-blocks'; +import type VM from 'scratch-vm'; + +const MAX_LISTENING_MS = 30 * 1000; + +/** + * Get Blockly instance. + * @param vm Virtual machine instance. For some reasons we cannot use VM here. + * @returns Blockly instance. + */ +export async function getBlocklyInstance (vm: EurekaCompatibleVM): Promise { + function getBlocklyInstanceInternal (): any | null { + // Hijack Function.prototype.apply to get React element instance. + function hijack (fn: (...args: unknown[]) => unknown) { + const _orig = Function.prototype.apply; + Function.prototype.apply = function (thisArg: any) { + return thisArg; + }; + const result = fn(); + Function.prototype.apply = _orig; + return result; + } + + // @ts-expect-error lazy to extend VM interface + const events = vm._events?.EXTENSION_ADDED; + if (events) { + if (events instanceof Function) { + // It is a function, just hijack it. + const result = hijack(events); + if (result && typeof result === 'object' && 'ScratchBlocks' in result) { + return result.ScratchBlocks; + } + } else { + // It is an array, hijack every listeners. + for (const value of events) { + const result = hijack(value); + if (result && typeof result === 'object' && 'ScratchBlocks' in result) { + return result.ScratchBlocks; + } + } + } + } + return null; + } + if (getBlocklyInstance.cache) { + return getBlocklyInstance.cache; + } + let res = getBlocklyInstanceInternal(); + if (res) { + return (getBlocklyInstance.cache = res); + } + return new Promise((resolve) => { + let state: any = undefined; + // @ts-expect-error lazy to extend VM interface + Reflect.defineProperty(vm._events, 'EXTENSION_ADDED', { + get: () => state, + set (v) { + state = v; + res = getBlocklyInstanceInternal(); + if (res) { + // @ts-expect-error lazy to extend VM interface + Reflect.defineProperty(vm._events, 'EXTENSION_ADDED', { + value: state, + writable: true + }); + getBlocklyInstance.cache = res; + resolve(res); + } + }, + configurable: true + }); + }); +} +getBlocklyInstance.cache = null as typeof Blockly | null; + +/** + * Trap to get Virtual Machine instance. + * @return Callback promise. After that you could use window.eureka.vm to get the virtual machine. + */ +export async function getVMInstance (): Promise { + log('Listening bind function...'); + const oldBind = Function.prototype.bind; + try { + const vm = await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + log('Cannot find vm instance, stop listening.'); + Function.prototype.bind = oldBind; + reject(); + }, MAX_LISTENING_MS); + + Function.prototype.bind = function (...args) { + if (Function.prototype.bind === oldBind) { + return oldBind.apply(this, args); + } else if ( + args[0] && + Object.prototype.hasOwnProperty.call(args[0], 'editingTarget') && + Object.prototype.hasOwnProperty.call(args[0], 'runtime') + ) { + log('VM detected!'); + Function.prototype.bind = oldBind; + clearTimeout(timeoutId); + resolve(args[0]); + return oldBind.apply(this, args); + } + return oldBind.apply(this, args); + }; + }); + return vm; + } catch { + return null; + } +} + +export interface EurekaCompatibleVM extends VM { + ccExtensionManager?: { + info: Record< + string, + { + api: number; + } + >; + getExtensionLoadOrder(extensions: string[]): unknown; + }; + setLocale?: (locale: string, ...args: unknown[]) => unknown; + getLocale?: () => string; + _loadExtensions?: ( + extensionIDs: Set, + extensionURLs: Map, + ...args: unknown[] + ) => Promise; +} From ec5ed8ef874353fe319227b1d2b9831825f052b1 Mon Sep 17 00:00:00 2001 From: FurryR Date: Sat, 20 Jul 2024 14:00:36 +0800 Subject: [PATCH 2/2] :bug: fix: Scratch.extensions.register now recycles resources Signed-off-by: FurryR --- src/loader/loader.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/loader/loader.ts b/src/loader/loader.ts index 47ae9fc..48cc5ef 100644 --- a/src/loader/loader.ts +++ b/src/loader/loader.ts @@ -53,6 +53,8 @@ class UnsandboxedLoader { const register = ctx.extensions.register; // now UnsandboxedLoader only resolves after Scratch.extensions.register called ctx.extensions.register = function (ext) { + URL.revokeObjectURL(src); + document.head.removeChild(elem); resolve(); return register.call(this, ext); }