diff --git a/package.json b/package.json index e309a103..7475105c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xigua/scratch-vm", - "version": "1.25.3", + "version": "1.26.1", "description": "Virtual Machine for Scratch 3.0 merged tw-vm", "author": "Massachusetts Institute of Technology", "license": "LGPL-3.0-only", @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "https://github.com/Gandi-IDE/scratch-vm.git", - "sha": "7429d67e03f0dacdf629dd5befd704126c8e4c0d" + "sha": "72fc9a2eabb363d61e0a00adddf971406eb21132" }, "main": "./src/index.js", "browser": "./src/index.js", diff --git a/src/engine/blocks.js b/src/engine/blocks.js index 8633752b..2c0d4399 100644 --- a/src/engine/blocks.js +++ b/src/engine/blocks.js @@ -813,7 +813,35 @@ class Blocks { /** * Reset all runtime caches. */ - resetCache () { + resetCache (checkGlobalProcedures = true) { + // CCW: 检查要重置的缓存是否有全局积木 + if (checkGlobalProcedures) { + /** + * Check if a block is a global procedure. + * @param {*} id Block ID + * @returns {boolean} Whether the block is a global procedure + */ + const isGlobalProcedure = (id) => { + if (!id) return false; + if (!this._blocks.hasOwnProperty(id)) return false; + const block = this._blocks[id]; + if (block.opcode !== 'procedures_definition') { + return false; + } + const internal = this._getCustomBlockInternal(block); + return internal && internal.mutation && internal.mutation.isglobal === 'true'; + } + const procedureIds = Object.values(this._cache.procedureDefinitions); + // 有全局积木,需要同时清空所有角色的缓存 + if (procedureIds.some(isGlobalProcedure)) { + // TODO: 记录全局积木依赖关系,只清空受影响的角色的缓存 + for (const target of this.runtime.targets) { + if (target.isOriginal) { + target.blocks.resetCache(false); // 避免递归调用 + } + } + } + } this._cache.inputs = {}; this._cache.procedureParamNames = {}; this._cache.procedureDefinitions = {}; diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 959a6781..7efdfab2 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -39,6 +39,7 @@ const StringUtil = require('../util/string-util'); const LogSystem = require('../util/log-system'); const Gandi = require('../util/gandi'); const uid = require('../util/uid'); +const Color = require('../util/color'); const defaultBlockPackages = { scratch3_control: require('../blocks/scratch3_control'), @@ -1211,6 +1212,13 @@ class Runtime extends EventEmitter { static get PLATFORM_MISMATCH () { return 'PLATFORM_MISMATCH'; } + /** + * Event name for reporting that the locale has changed. + * @const {string} + */ + static get LOCALE_CHANGED () { + return 'LOCALE_CHANGED'; + } /** * How rapidly we try to step threads by default, in ms. @@ -2087,7 +2095,6 @@ class Runtime extends EventEmitter { return `%${argNum}`; } - /** * @returns {Array.} scratch-blocks XML for each category of extension blocks, in category order. * @param {?Target} [target] - the active editing target (optional) @@ -2097,7 +2104,7 @@ class Runtime extends EventEmitter { getBlocksXML (target) { // eslint-disable-next-line max-len return this._blockInfo/* powered by xigua start */.filter(({onlyVisibleOnShortcut}) => global.__XIGUA_SHORTCUT || Boolean(!onlyVisibleOnShortcut))/* powered by xigua end */.map(categoryInfo => { - const {name, color1, color2} = categoryInfo; + const {name, color1, color2=Color.darkenHex(color1, 0.1)} = categoryInfo; // Filter out blocks that aren't supposed to be shown on this target, as determined by the block info's // `hideFromPalette` and `filter` properties. const paletteBlocks = categoryInfo.blocks.filter(block => { @@ -4067,10 +4074,19 @@ class Runtime extends EventEmitter { getFormatMessage (message) { const globalFormatMessage = require('format-message'); const formatMessage = globalFormatMessage.namespace(); + let lastLocale = null; return (...args) => { - formatMessage.setup({locale: globalFormatMessage.setup().locale, translations: message}); + const currentLocale = globalFormatMessage.setup().locale; + const needSetup = lastLocale !== currentLocale; + if (needSetup) { + lastLocale = currentLocale; + formatMessage.setup({ + locale: currentLocale, + translations: message + }); + } return formatMessage(...args); - }; + } } getOriginalFormatMessage () { diff --git a/src/extension-support/extension-load-helper.js b/src/extension-support/extension-load-helper.js index 16a52fab..6e300663 100644 --- a/src/extension-support/extension-load-helper.js +++ b/src/extension-support/extension-load-helper.js @@ -6,18 +6,15 @@ const Cast = require('../util/cast'); const Color = require('../util/color'); const createTranslate = require('./tw-l10n'); const log = require('../util/log'); +const Patcher = require('./patcher'); +const AsyncLimiter = require('../util/async-limiter'); 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) { +const clearScratchAPI = () => { + delete global.IIFEExtensionInfoList; + if (global.Scratch) { global.Scratch.extensions = { unsandboxed: true, register: extensionInstance => { @@ -25,18 +22,33 @@ const clearScratchAPI = id => { throw new Error(`ScratchAPI: ${info.id} call extensions.register too late`); } }; + // After an extension is loaded, we need to remove vm/runtime/renderer/etc. + // from the global Scratch object. But the extension might still hold a reference + // to the original object and access those properties later. To avoid breakage, + // we clone the global Scratch object first, then only clear the global's APIs. + global.Scratch = {...global.Scratch}; global.Scratch.vm = null; global.Scratch.runtime = null; global.Scratch.renderer = null; - needSetup = true; + // In theory, translate should also be nulled out, since each extension needs its own translate. + // But we keep it for now to avoid errors from extensions that accidentally rely on it. + // global.Scratch.translate = null; + + // NOTE: The extension should either: + // - keep a reference to the original Scratch object + // (e.g., IIFE style in TurboWarp: `((Scratch)=>{...})(window.Scratch)`, + // or simply `const Scratch = window.Scratch;` at the top) + // → the extension can still access vm/runtime/translate through the saved reference + // or: + // - not keep a reference, and always access via global.Scratch + // → the extension must NOT access vm/runtime/translate through global.Scratch + // (global.Scratch only provides basic APIs like Cast, ArgumentType, etc.) + // vm/runtime are nulled out, and `global.Scratch.translate` should also not be used, + // since it will be overwritten by the next extension } }; -const setupScratchAPI = (vm, id) => { - pending.add(id); - if (!needSetup) { - return; - } +const setupScratchAPI = (vm) => { const registerExt = extensionInstance => { const info = extensionInstance.getInfo(); const extensionId = info.id; @@ -63,16 +75,18 @@ const setupScratchAPI = (vm, id) => { ...openVM }; } - if (!translate) { - translate = createTranslate(vm); - } + // 需要重复创建,因为每个 extension 都需要一个独立的 translate + translate = createTranslate(vm); - const scratch = { + // 需要创建新的 Scratch Object + // 否则所有 extension 都共享一个 Scratch 对象 → 共享同一个 translate + global.Scratch = { ArgumentType, BlockType, TargetType, Cast, Color, + Patcher, translate, extensions: { unsandboxed: true, @@ -82,8 +96,6 @@ const setupScratchAPI = (vm, id) => { runtime: openVM.runtime, renderer: openVM.runtime.renderer }; - global.Scratch = Object.assign(global.Scratch || {}, scratch); - needSetup = false; }; const createdScriptLoader = ({url, onSuccess, onError}) => { @@ -144,4 +156,24 @@ const createdScriptLoader = ({url, onSuccess, onError}) => { return script; }; -module.exports = {setupScratchAPI, clearScratchAPI, createdScriptLoader}; +// Because setupScratchAPI requires messing with global state (global.Scratch), +// only let one extension load at a time. +const limiter = new AsyncLimiter(async (vm, callback) => { + setupScratchAPI(vm); + try { + const res = await callback(); + return res; + } finally { + clearScratchAPI(); + } +}, 1); +/** + * Sets up the Scratch API and ensures that only one is executing at a time to prevent race conditions. + * @async + * @param {Object} vm - The virtual machine to use. + * @param {() => Promise} callback - Async callback to execute with Scratch API. + * @returns {Promise} - The promise that resolves when the callback completes. + */ +const withScratchAPI = async (vm, callback) => limiter.do(vm, callback); + +module.exports = {withScratchAPI, createdScriptLoader}; diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index c1a0d6d2..cec4eec8 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 {withScratchAPI, createdScriptLoader} = 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. @@ -890,12 +890,10 @@ class ExtensionManager { // avoid init extension twice if it already loaded return; } - setupScratchAPI(this.vm, extensionId); - return this.getExternalExtensionConstructor(extensionId) - .then(extension => this.registerExtension(extensionId, extension, shouldReplace)) - .finally(() => { - clearScratchAPI(extensionId); - }); + return withScratchAPI(this.vm, async () => { + return this.getExternalExtensionConstructor(extensionId) + .then(extension => this.registerExtension(extensionId, extension, shouldReplace)); + }); } isValidExtensionURL (extensionURL) { @@ -1015,8 +1013,7 @@ 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); + return withScratchAPI(this.vm, ()=> new Promise((resolve, reject) => { createdScriptLoader({ url: rewritten, onSuccess: async () => { @@ -1071,10 +1068,9 @@ class ExtensionManager { }, onError: reject }); - }) + })) // .catch(e => log.error('LoadRemoteExtensionError: ', e)) .finally(() => { - clearScratchAPI(url); if (onlyAdded.length > 0 || addedAndLoaded.length > 0) { this.runtime.emit('EXTENSION_LIBRARY_UPDATED'); } diff --git a/src/extension-support/patcher-doc-zh.md b/src/extension-support/patcher-doc-zh.md new file mode 100644 index 00000000..a8aa2c0b --- /dev/null +++ b/src/extension-support/patcher-doc-zh.md @@ -0,0 +1,381 @@ +# Patcher 工具使用文档 + +[English](./patcher-doc.md) + +## 简介 + +Patcher 是一个用于解决 Scratch 扩展对函数 patch 冲突和性能问题的工具。它提供了一种更优雅、更高效的方式来对函数进行 patch,支持注册 before/after 钩子,避免传统 wrapper 方式的层层套娃问题,同时支持 unpatch 清理操作。 + +### 传统 patch 方法的缺点 + +我们经常遇到扩展 Scratch 原有功能的需求。例如,假设我们希望在 Scratch 每一帧的开始、结束时添加一些自定义行为,这需要 patch `runtime._step` 方法(即 Scratch 引擎的主循环)。传统的 patch 方法通常是这样的: + +```javascript +const runtimeProto = Object.getPrototypeOf(runtime); +// 记录原函数 +const orig = runtimeProto._step; +// 定义新函数,添加自定义逻辑 +runtimeProto._step = function(a, b) { + console.log('before _step:', a, b); + const result = orig.call(this, a, b); + console.log('after _step:', result, a, b); + return result; +}; +``` + +这种方法存在以下缺点: +- 当多个扩展对同一个方法 patch 时,会导致层层套娃,影响性能; +- 而很多时候只是需要在函数前/后执行自定义动作,没必要使用 wrapper 方式 +- 无法 unpatch,若直接恢复原函数,会导致其他扩展的 patch 失效 + +### Patcher 的优点 + +使用 Patcher 可以解决这些问题: +- 通过 Scratch.Patcher 获取和使用该工具,统一多扩展的 patch 管理 +- 支持注册 before、after 钩子,避免 wrapper 层层套娃,提高性能 +- 支持 unpatch 清理,且 unpatch 时不会直接替换原函数导致传统 patch 失效 + +### 原理 + +- 首次 patch 时,将原函数替换为统一的 patched 代理函数;该 patched 函数一经创建便不再被替换/移除,后续仅通过修改闭包内的变量(before 钩子数组、wrapped 核心函数、after 钩子数组)来更新 patch 逻辑 +- unpatch 操作**不会直接恢复原函数**,而是仍然保留空壳的 patched 代理函数,以避免直接恢复原函数导致其他传统 patch 丢失。 + +## 基本用法 + +### 创建 Patcher 实例 + +```javascript +const patcher = new Patcher('myExtensionId'); // 可用扩展 ID 作为 patch id +``` + +### before 钩子示例 + +```javascript +patcher.patch(runtime, 'exampleMethod', { + before: function (a, b) { + console.log('before:', a, b); + if (a > 10) return a; // 提前返回 + } +}); +``` + +### after 钩子示例 + +```javascript +patcher.patch(runtime, 'exampleMethod', { + after: function (result, a, b) { + console.log('after:', result, a, b); + return result * 2; // 可修改返回值,无返回值则默认不修改 + } +}); +``` + +### wrapper 模式示例 + +```javascript +patcher.patch(runtime, 'exampleMethod', function (next, a, b) { + console.log('do something mysterious'); + return next.call(this, a, b); +}); +``` + +### 在扩展中使用 Patcher + +```javascript +(function (Scratch) { + // 从全局 Scratch 对象中引入 Patcher + const { Patcher, runtime } = Scratch; + // 你的扩展类 + class MyExtension { + constructor() { + // 1. 创建 Patcher 实例 + this.patcher = new Patcher('myExtensionId'); // 可用扩展 ID 作为 patch id + + // 2. patch 特定方法 + // 注:Patcher会自动查找原型链上的方法(即 runtime.__proto__._step) + this.patcher.patch(runtime, '_step', { + // 注册 before 钩子,会在原函数执行前调用 + before: function (a, b) { + console.log('before:', a, b); + if (a > 10) { + // 支持提前返回值(跳过原函数执行) + return a; + } + }, + // 注册 after 钩子,会在原函数执行后调用 + after: function (result, a, b) { + console.log('after:', result, a, b); + // 可以修改返回值。 + return result * 2; + }, + // 也支持 wrapper 模式(但建议优先使用before/after钩子,避免层层套娃) + wrapper: function (orig, a, b) { + console.log('before:', a, b); + const result = orig.call(this, a, b); + console.log('after:', result, a, b); + return result; + } + }); + + // 可以在必要的时候 unpatch + // this.patcher.unpatch(runtime, '_step'); + + // 清理所有 patch + // this.patcher.unpatchAll(); + + + // 临时暂停特定 patch + this.patcher.pause(runtime, '_step'); + // 稍后恢复特定 patch + this.patcher.resume(runtime, '_step'); + } + ... + + } +})(Scratch); +``` +## 高级用法 + + +### 多次 patch + +**同一个 patcher**对同一个方法**多次 patch**,默认会覆盖之前的 patch 。 + +> 这么设计是出于以下考虑: +> - 通常一个扩展对一个方法只会 patch 一次(多次 patch 也可以合并为一个) +> - 避免意外重复应用相同的 patch (例如扩展重复加载) + +```javascript +patcher.patch(runtime, '_step', { + before: function () { + console.log('1'); + } +}); +// 重复 patch 时,会覆盖之前的 patch +patcher.patch(runtime, '_step', { + before: function () { + console.log('2'); + } +}); +runtime._step(); +// 输出: +// 2 +``` + +若要保留多个 patch,需指定不同的 **name** 来区分。 +> 注:默认使用 'default'作为 name;同名 patch 会发生覆盖 +```javascript +patcher.patch(runtime, '_step', { + name: '功能1', + before: function (a, b) { + console.log('fun1'); + } +}); +patcher.patch(runtime, '_step', { + name: '功能2', + before: function (a, b) { + console.log('fun2'); + } +}); +// 同名则覆盖之前的 patch +patcher.patch(runtime, '_step', { + name: '功能2', + before: function (a, b) { + console.log('覆盖!'); + } +}); +// 移除指定 name 的 patch +patcher.unpatch(runtime, '_step', '功能2'); +``` + +### patch 顺序 + +支持指定 patch 顺序,顺序越小,执行越早。 + +可使用预设值: +- `Patcher.ORDER_EARLY(-1):比其他 patch 先执行 +- `Patcher.ORDER_NORMAL(0):默认值 +- `Patcher.ORDER_LATE(1):比其他 patch 后执行 +例如 +```javascript +const patcher1 = new Patcher('ext1'); +const patcher2 = new Patcher('ext2'); +const patcher3 = new Patcher('ext3'); +let obj = { + test: function () { + console.log('original'); + } +} +patcher1.patch(obj, 'test', { + order: Patcher.ORDER_LATE, // 比其他 patch 先执行 + before: function () { + console.log('ext1'); + } +}); +patcher2.patch(obj, 'test', { + order: Patcher.ORDER_EARLY, // 比其他 patch 后执行 + before: function () { + console.log('ext2'); + } +}); +patcher3.patch(obj, 'test', { + order: Patcher.ORDER_NORMAL, // 默认值 + before: function () { + console.log('ext3'); + } +}); +obj.test(); +// 输出: +// ext2 +// ext3 +// ext1 +// original +``` + +### Patcher.UNDEFINED +用于在 before / after 钩子中显式返回 undefined: +- before 钩子返回 `Patcher.UNDEFINED` 以提前结束原函数执行(返回普通的 undefined 视为无返回值,继续执行原函数) +- after 钩子返回 `Patcher.UNDEFINED` 以修改原函数返回值为 undefined + +## API 参考 + +### 构造函数 + +```javascript +new Patcher(id, options) +``` + +**参数:** +- `id` (string):patch 的唯一标识符,例如扩展 ID +- `options` (object):可选配置 + - `patchOwner` (boolean):是否在拥有该方法的原型上打 patch,默认值为 true + +### patch 方法 + +```javascript +patcher.patch(target, methodName, spec) +``` + +**作用:** 对目标对象的方法进行 patch。 + +**参数:** +- `target` (object):目标对象 +- `methodName` (string):要 patch 的方法名 +- `spec` (object):补丁信息 + - `name` (string):可选,patch 的名称,默认值为 'default'。同名 patch 会发生覆盖 + - `before` (Function):before 钩子函数 + - 参数:原函数参数 + - 返回值: + - 无返回值/返回 undefined 则继续执行原函数; + - 有返回值则作为原函数的返回值返回,跳过原函数执行; + - 返回 `Patcher.UNDEFINED` 则跳过原函数执行且返回 undefined + - `after` (Function):after 钩子函数 + - 参数:(原函数返回值, ...原函数参数) + - 返回值: + - 无返回值/返回 undefined 则不做任何额外处理; + - 有返回值则修改原函数的返回值,且传递给后续 after 钩子; + - 返回 `Patcher.UNDEFINED` 则显式修改返回值为 undefined + - `wrapper` (Function):包装函数,用于包装新函数 + - 参数:(原函数, ...原函数参数) + - 返回值:原函数返回值 + - `replace` (Function):替换函数,将直接替换原函数(不建议使用),其他扩展的 patch 仍有效 + - `patchOnce` (boolean):是否只 patch 一次 + - 默认值为 false,同 id 的 patcher 多次 patch 同一个函数时,后续 patch 会覆盖之前的 patch + - 若设置为 true,则后续 patch 会被忽略 + +**返回值:** boolean,是否成功 patch + +### unpatch 方法 + +```javascript +patcher.unpatch(target, methodName, name='default') +``` + +**作用:** 对目标对象的方法进行 unpatch + +**参数:** +- `target` (object):目标对象 +- `methodName` (string):要 unpatch 的方法名 +- `name` (string):可选,要 unpatch 的 patch 名称,默认值为 'default' + +**返回值:** boolean,是否成功 unpatch + +### unpatchAll 方法 + +```javascript +patcher.unpatchAll() +``` + +**作用:** 卸载当前 id 的所有 patch + +**返回值:** number,成功 unpatch 的方法数量 + +### listPatches 方法 + +```javascript +patcher.listPatches() +``` + +**作用:** 获取当前 id 对哪些 owner 上的方法进行了 patch + +**返回值:** Array<{owner: object, methodName: string, name: string}>,patch 信息列表 + +### getCustomInfo 方法 + +```javascript +patcher.getCustomInfo() +``` + +**作用:** 可以存放一些当前 id 的 patcher 的一些额外自定义信息 + +**返回值:** object,自定义信息对象 + +### pause 方法 + +```javascript +patcher.pause(target, methodName, name='default') +``` + +**作用:** 暂停对目标对象的方法的 patch + +**参数:** +- `target` (object):目标对象 +- `methodName` (string):要暂停 patch 的方法名 +- `name` (string):可选,要暂停 patch 的名称,默认值为 'default' + +**返回值:** boolean,是否成功暂停 patch + +### resume 方法 + +```javascript +patcher.resume(target, methodName, name='default') +``` + +**作用:** 恢复对目标对象的方法的 patch + +**参数:** +- `target` (object):目标对象 +- `methodName` (string):要恢复 patch 的方法名 +- `name` (string):可选,要恢复 patch 的名称,默认值为 'default' + +**返回值:** boolean,是否成功恢复 patch + +### pauseAll 方法 + +```javascript +patcher.pauseAll() +``` + +**作用:** 暂停当前 id 对所有方法的 patch + +**返回值:** boolean,是否成功暂停所有 patch + +### resumeAll 方法 + +```javascript +patcher.resumeAll() +``` + +**作用:** 恢复当前 id 对所有方法的 patch + +**返回值:** boolean,是否成功恢复所有 patch \ No newline at end of file diff --git a/src/extension-support/patcher-doc.md b/src/extension-support/patcher-doc.md new file mode 100644 index 00000000..5ea4f370 --- /dev/null +++ b/src/extension-support/patcher-doc.md @@ -0,0 +1,358 @@ +# Patcher Documentation + +[中文文档](./patcher-doc-zh.md) + +## Introduction + +Patcher is a tool designed to solve function patching conflicts and performance issues in Scratch extensions. It provides a more elegant and efficient way to patch functions, supporting before/after hook registration to avoid the nested wrapper problem of traditional approaches, while also supporting unpatch cleanup operations. + +### Drawbacks of Traditional Patching Methods + +We often encounter the need to extend Scratch's existing functionality. For example, suppose we want to add custom behavior at the beginning and end of each Scratch frame. This requires patching the `runtime._step` method (the main loop of the Scratch engine). Traditional patching methods typically look like this: + +```javascript +const runtimeProto = Object.getPrototypeOf(runtime); +// Record the original function +const orig = runtimeProto._step; +// Define a new function with custom logic +runtimeProto._step = function(a, b) { + console.log('before _step:', a, b); + const result = orig.call(this, a, b); + console.log('after _step:', result, a, b); + return result; +}; +``` + +This approach has several drawbacks: +- When multiple extensions patch the same method, it creates nested wrappers that impact performance +- Many times we only need to execute custom actions before/after the function, making the wrapper approach unnecessary +- No unpatch support: directly restoring the original function would invalidate patches from other extensions + +### Advantages of Patcher + +Using Patcher solves these problems: +- Access and use the tool through `Scratch.Patcher` for unified patch management across multiple extensions +- Supports before/after hook registration to avoid nested wrappers and improve performance +- Supports unpatch cleanup without directly replacing the original function (which would break traditional patches) + +### Principles of Patcher + +- On first patch, replaces the original function with a patched function. Once created, this patched function is never replaced/removed. Subsequent updates modify closure variables (the before hook array, the wrapped core function, the after hook array) to update patch logic +- Unpatch operations **do not directly restore the original function**, but keep the patched function shell to avoid breaking traditional patches from other extensions + +## Examples + +### Before Hook Example + +```javascript +patcher.patch(runtime, 'exampleMethod', { + before: function (a, b) { + console.log('before:', a, b); + if (a > 10) return a; // Early return + } +}); +``` + +### After Hook Example + +```javascript +patcher.patch(runtime, 'exampleMethod', { + after: function (result, a, b) { + console.log('after:', result, a, b); + return result * 2; // Modify return value + } +}); +``` + +### Wrapper Pattern Example + +```javascript +patcher.patch(runtime, 'exampleMethod', function (next, a, b) { + console.log('before:', a, b); + const result = next.call(this, a, b); + console.log('after:', result, a, b); + return result; +}); +``` + +### Using Patcher in Extensions + +```javascript +(function (Scratch) { + // Import Patcher from the global Scratch object + const { Patcher, runtime } = Scratch; + // Your extension class + class MyExtension { + constructor() { + // 1. Create a Patcher instance + this.patcher = new Patcher('myExtensionId'); // Use extension ID as patch identifier + + // 2. Patch specific methods + // Note: Patcher automatically finds the method on the prototype chain (runtime.__proto__._step) + patcher.patch(runtime, '_step', { + // Register before hook, called before original function executes + before: function (a, b) { + console.log('before:', a, b); + if (a > 10) { + // Support early return (skips original function execution) + return a; + } + }, + // Register after hook, called after original function executes + after: function (result, a, b) { + console.log('after:', result, a, b); + // Modify return value + return result * 2; + }, + // Still supports wrapper pattern (but prefer before/after hooks to avoid nesting) + wrapper: function (orig, a, b) { + console.log('before:', a, b); + const result = orig.call(this, a, b); + console.log('after:', result, a, b); + return result; + } + }); + + // Unpatch when necessary + // patcher.unpatch(runtime, '_step'); + } + + // 3. Clean up all patches when extension is destroyed + // Note: Extensions don't have a dispose method yet, this is just a future consideration + dispose() { + this.patcher.unpatchAll(); + } + + ... + + } +})(Scratch); + +``` + +## Advanced Features + +### Multiple Patches + +**The same patcher** can patch the same method **multiple times** by specifying different **names** to distinguish them. + +- If no name is specified, 'default' is used as the default name. +- Patches with the same name will overwrite each other. + +Example: +```javascript +patcher.patch(runtime, '_step', { + name: 'feature1', + before: function (a, b) { + console.log('fun1'); + } +}); +patcher.patch(runtime, '_step', { + name: 'feature2', + before: function (a, b) { + console.log('fun2'); + } +}); +// Same name overwrites previous patch +patcher.patch(runtime, '_step', { + name: 'feature2', + before: function (a, b) { + console.log('overwrite!'); + } +}); +// Remove patch with specified name +patcher.unpatch(runtime, '_step', 'feature2'); +``` + +### patch Order + +Supports specifying patch execution order: the smaller the order value, the earlier the patch will be executed. + +You can use the following preset values: +- `Patcher.ORDER_EARLY (-1)`: Executes before other patches +- `Patcher.ORDER_NORMAL (0)`: Default value +- `Patcher.ORDER_LATE (1)`: Executes after other patches + +For example: +```javascript +const patcher1 = new Patcher('ext1'); +const patcher2 = new Patcher('ext2'); +const patcher3 = new Patcher('ext3'); +let obj = { + test: function () { + console.log('original'); + } +} +patcher1.patch(obj, 'test', { + order: Patcher.ORDER_LATE, // Executes after other patches + before: function () { + console.log('ext1'); + } +}); +patcher2.patch(obj, 'test', { + order: Patcher.ORDER_EARLY, // Executes before other patches + before: function () { + console.log('ext2'); + } +}); +patcher3.patch(obj, 'test', { + order: Patcher.ORDER_NORMAL, // Default value + before: function () { + console.log('ext3'); + } +}); +obj.test(); +// Output: +// ext2 +// ext3 +// ext1 +// original +``` +### Patcher.UNDEFINED + +Used to explicitly return undefined in before/after hooks: +- Before hook returns `Patcher.UNDEFINED` to skip original function execution (returning regular undefined means no return value, continuing original function execution) +- After hook returns `Patcher.UNDEFINED` to explicitly set return value to undefined + +## API Reference + +### Constructor + +```javascript +new Patcher(id, options) +``` + +**Parameters:** +- `id` (string): Unique identifier for the patch, e.g., extension ID +- `options` (object): Optional configuration + - `patchOwner` (boolean): Whether to patch the method on the prototype that owns it, default value is true + +### patch Method + +```javascript +patcher.patch(target, methodName, spec) +``` + +**Purpose:** Patch a method on the target object. Note: Each patcher instance can only patch a method once. Subsequent patches with the same ID will overwrite previous ones. + +**Parameters:** +- `target` (object): Target object +- `methodName` (string): Name of the method to patch +- `spec` (object): Patch information + - `name` (string): Optional, name of the patch, default value is 'default' + - `before` (Function): Before hook function + - Parameters: Original function parameters + - Return value: + - No return value/undefined: Continue executing original function + - Any other value: Use as return value instead of executing original function + - `Patcher.UNDEFINED`: Skip original function execution and return undefined + - `after` (Function): After hook function + - Parameters: (Original function return value, ...original function parameters) + - Return value: + - No return value/undefined: No additional processing + - Any other value: Modify return value and pass to subsequent after hooks + - `Patcher.UNDEFINED`: Explicitly set return value to undefined + - `wrapper` (Function): Wrapper function for wrapping the new function + - Parameters: (Original function, ...original function parameters) + - Return value: Original function return value + - `replace` (Function): Replacement function to directly replace the original function (not recommended), patches from other extensions still work + - `patchOnce` (boolean): Whether to patch only once + - Default: false, subsequent patches with the same ID overwrite previous ones + - If true: Only patch once, subsequent patches are ignored + +**Return value:** boolean indicating whether patching succeeded + +### unpatch Method + +```javascript +patcher.unpatch(target, methodName, name='default') +``` + +**Purpose:** Unpatch a method on the target object + +**Parameters:** +- `target` (object): Target object +- `methodName` (string): Name of the method to unpatch +- `name` (string): Optional, name of the patch to unpatch, default value is 'default' + +**Return value:** boolean indicating whether unpatching succeeded + +### unpatchAll Method + +```javascript +patcher.unpatchAll() +``` + +**Purpose:** Uninstall all patches for the current ID + +**Return value:** Number of successfully unpatch methods + +### listPatches Method + +```javascript +patcher.listPatches() +``` + +**Purpose:** Get information about which methods on which owners have been patched by the current ID + +**Return value:** Array<{owner: object, methodName: string, name: string}> containing patch information + +### getCustomInfo Method + +```javascript +patcher.getCustomInfo() +``` + +**Purpose:** Store additional custom information for the current ID's patcher + +**Return value:** Object containing custom information + +### pause Method + +```javascript +patcher.pause(target, methodName, name='default') +``` + +**Purpose:** Pause patching a method on the target object + +**Parameters:** +- `target` (object): Target object +- `methodName` (string): Name of the method to pause patching +- `name` (string): Optional, name of the patch to pause, default value is 'default' + +**Return value:** boolean indicating whether pausing succeeded + +### resume Method + +```javascript +patcher.resume(target, methodName, name='default') +``` + +**Purpose:** Resume patching a method on the target object + +**Parameters:** +- `target` (object): Target object +- `methodName` (string): Name of the method to resume patching +- `name` (string): Optional, name of the patch to resume, default value is 'default' + +**Return value:** boolean indicating whether resuming succeeded + +### pauseAll Method + +```javascript +patcher.pauseAll() +``` + +**Purpose:** Pause all patches for the current ID + +**Return value:** boolean indicating whether all patches were successfully paused + +### resumeAll Method + +```javascript +patcher.resumeAll() +``` + +**Purpose:** Resume all patches for the current ID + +**Return value:** boolean indicating whether all patches were successfully resumed diff --git a/src/extension-support/patcher.js b/src/extension-support/patcher.js new file mode 100644 index 00000000..eeb23a71 --- /dev/null +++ b/src/extension-support/patcher.js @@ -0,0 +1,472 @@ +/** + * patcher.js + * by Arkos & GPT5 + * doc: see ./patcher-doc.md + */ + +const hasOwn = Object.prototype.hasOwnProperty; +/** + * @typedef SinglePatch + * @property {string} patcherId - patcher id + * @property {string} name - patch name + * @property {number} order - patch order + * @property {(orig)=>Function} [factory] + * @property {(orig, ...args)=>Function} [wrapper] + * @property {Function} [before] + * @property {Function} [after] + * @property {Function} [replace] + * @property {boolean} [paused=false] 是否暂停应用 + */ +/** + * @typedef FunctionRecord + * @property {Array} patches 应用在该函数的所有 patch 信息 + * + * @property {Function} patched patched function + * @property {Array} befores before 钩子函数列表 + * @property {Function} wrapped 中间函数 + * @property {Array} afters after 钩子函数列表 + * @property {Function} original 原始函数 + * @property {object} customInfo 自定义信息 + */ + +const namespace = '__Arkos_Patcher'; + +/** + * patchedObject→ + * @type {WeakMap>} + */ +const patchedObjectMap = new WeakMap(); +/** + * patcherId → PatcherInfo + * @type {Map>, customInfo: object}>} + */ +const patcherInfoMap = new Map(); + +function firstLetterToLower(str) { + if (typeof str !== 'string' || str.length === 0) { + return ''; + } + return str[0].toLowerCase() + str.slice(1); +} + +function getObjectName(object) { + if (!object) return String(object); + const className = object.constructor?.name; + if (!className || className === 'Object') return 'unnamed object'; + return firstLetterToLower(className); +} + +class Patcher { + /** + * Used to explicitly return undefined in before/after hooks: + * - Before hook returns Patcher.UNDEFINED to skip original function execution (returning regular undefined means no return value, continuing original function execution) + * - After hook returns Patcher.UNDEFINED to explicitly set return value to undefined + * @public + * @constant + * @type {Symbol} + */ + static UNDEFINED = Symbol.for(`${namespace}_UNDEFINED`); + + static ORDER_EARLY = -1; + static ORDER_NORMAL = 0; + static ORDER_LATE = 1; + + static get DEFAULT_PATCH_NAME() { + return 'default'; + } + + /** + * new Patcher(id, options) + * options: + * @param {string} id - patcher id + * @param {Options} options + * @param {boolean} [options.patchOwner=true] (Default: true) Whether to patch on the prototype that owns the method + */ + constructor(id, options = {}) { + if (!id) throw new Error('Patcher requires an id'); + this.id = String(id); + this.options = { + patchOwner: options.patchOwner !== undefined ? !!options.patchOwner : true, // Default: true + }; + let patcherInfo = patcherInfoMap.get(id); + if (!patcherInfo) { + patcherInfo = { + ownerMap: new Map(), + customInfo: {}, + }; + patcherInfoMap.set(id, patcherInfo); + } + this.patcherInfo = patcherInfo; + } + + /** + * Find the first object that owns the property along the prototype chain + * @private + * @param {object} target Initial object + * @param {string} methodName Method name + * @returns {object|null} The object that owns the property, or null if not found + */ + static _findOwner(target, methodName) { + if (!target) return null; + let obj = target; + while (obj) { + if (hasOwn.call(obj, methodName)) return obj; + obj = Object.getPrototypeOf(obj); + } + return null; + } + + /** + * Create a patched function + * @private + * @param {FunctionRecord} rec Patch information + * @returns {Function} Patched function + */ + static _createPatchedFunction(rec) { + const { befores, afters } = rec; + return function patched(...args) { + for (let i = 0; i < befores.length; i++) { + const res = befores[i].apply(this, args); + // Early return if there's a result + // Note: Returning undefined directly means no return value. If you want to explicitly return undefined, use Patcher.UNDEFINED + if (res !== undefined) return res === Patcher.UNDEFINED ? undefined : res; + } + let res = rec.wrapped.apply(this, args); + for (let i = 0; i < afters.length; i++) { + const r = afters[i].call(this, res, ...args); + if (r !== undefined) res = r === Patcher.UNDEFINED ? undefined : r; + } + return res; + }; + } + + /** + * Get patch information from owner + * @private + * @param {object} owner + * @param {string} methodName + * @param {boolean} createIfMissing + * @returns {FunctionRecord|null} Patch information + */ + static _getRecord(owner, methodName, createIfMissing = false) { + if (!owner || !owner[methodName]) { + console.warn(`Patcher: failed to find method '${methodName}' in owner`); + return null; + } + let map = patchedObjectMap.get(owner); + if (!map) { + if (!createIfMissing) return null; + map = new Map(); + patchedObjectMap.set(owner, map); + } + let rec = map.get(methodName); + if (!rec && createIfMissing) { + const original = owner[methodName]; + rec = { + // Original function when first patched + original, + befores: [], + afters: [], + patches: [], + wrapped: original, + customInfo: {}, + }; + const patched = Patcher._createPatchedFunction(rec); + try { + owner[methodName] = patched; + } catch (e) { + console.warn(`Patcher: failed to install patched method ${methodName}`, e); + } + map.set(methodName, rec); + } + return rec; + } + + /** + * Recompose and install the final function (updates before/after hooks and wrapped function) + * @private + * @param {FunctionRecord} rec + */ + static _recomposeAndInstall(rec) { + // Sort patches by order + rec.patches.sort((a, b) => a.order - b.order); + // Apply before/after hooks + const befores = rec.patches.filter((p) => !p.paused && typeof p.before === 'function').map((p) => p.before); + const afters = rec.patches.filter((p) => !p.paused && typeof p.after === 'function').map((p) => p.after); + // Clear original hooks + rec.afters.length = 0; + rec.befores.length = 0; + rec.afters.push(...afters); + rec.befores.push(...befores); + // Check for replace function + const replace = rec.patches.filter((p) => typeof p.replace === 'function' && !p.paused); + let fn = rec.original; + if (replace.length > 0) { + fn = replace[0].replace; + if (replace.length > 1) { + console.warn(`Patcher: multiple replace patches found, only the first one will be applied`); + } + } + // Apply wrappers + const useWrapper = (wrapper, orig) => { + return function (...args) { + return wrapper.call(this, orig, ...args); + }; + }; + rec.patches.forEach(({ wrapper, factory, paused }) => { + if (paused) return; + if (typeof factory === 'function') { + fn = factory(fn); + } else if (typeof wrapper === 'function') { + fn = useWrapper(wrapper, fn); + } + }); + rec.wrapped = fn; + } + + /** + * Record that current patcher has patched methodName on owner + * @private + * @param {object} owner + * @param {string} methodName + */ + _recordPatch(owner, methodName) { + const { ownerMap } = this.patcherInfo; + let methodSet = ownerMap.get(owner); + if (!methodSet) { + methodSet = new Set(); + ownerMap.set(owner, methodSet); + } + methodSet.add(methodName); + } + + /** + * Store additional custom information for current patcher + * @returns {object} Custom information object + */ + getCustomInfo() { + return this.patcherInfo.customInfo; + } + + /** + * Patch object method + * @param {object} target Target object (if Patcher's `patchOwner` is true, automatically finds instance prototype) + * @param {string} methodName + * @param {object} spec Patch information + * @param {string} [spec.name] (Optional) Patch name. By default, a patcher can only patch the same method once. + * @param {number} [spec.order] (default: 0) Patch order. Lower order patches are applied first. + * To patch multiple times, you must specify different names (otherwise it will overwrite previous patch) + * @param {Function} [spec.before] + * Before hook function + * - Parameters: Original function parameters + * - Return value: + * - No return value/returns undefined: Continue executing original function + * - Has return value (non-undefined): Use as return value instead of executing original function + * - To skip original function execution with no return value, return `Patcher.UNDEFINED` + * @param {Function} [spec.after] + * After hook function + * - Parameters: (Original function return value, ...original function parameters) + * - Return value: + * - No return value/returns undefined: No additional processing + * - Has return value (non-undefined): Modify original function's return value and pass to subsequent after hooks + * - To explicitly set return value to undefined, return `Patcher.UNDEFINED` + * @param {(orig, ...args)=>Function} [spec.wrapper] + * Wrapper function for wrapping new function + * - Parameters: (Original function, ...original function parameters) + * @param {(orig)=>Function} [spec.factory] Factory function for creating new function + * @param {Function} [spec.replace] Replacement function to directly replace original function + * @param {boolean} [spec.patchOnce] Whether to keep first patch when patching multiple times (default: overwrite) + * @returns {boolean} Whether patch was successful + */ + patch(target, methodName, spec) { + const owner = this.options.patchOwner ? Patcher._findOwner(target, methodName) || target : target; + const ownerName = getObjectName(owner); + const rec = Patcher._getRecord(owner, methodName, true); + if (!rec) return false; + + /** @type {SinglePatch} */ + let patch = { + name: spec?.name ?? Patcher.DEFAULT_PATCH_NAME, + patcherId: this.id, + paused: false, + order: spec?.order ?? Patcher.ORDER_NORMAL, + }; + if (typeof spec === 'function') { + // If spec is a function, treat it as a wrapper by default + patch.wrapper = spec; + } else if (spec) { + patch = { + ...patch, + ...spec, + }; + } + + const existingIndex = rec.patches.findIndex((p) => p.name === patch.name && p.patcherId === this.id); + if (existingIndex >= 0) { + // Already patched + // If new patch has patchOnce: true, do not overwrite + if (spec && spec.patchOnce) { + console.warn(`Patcher '${this.id}' has already patched '${methodName}'(patch name '${patch.name}') on the owner '${ownerName}'.`); + return false; + } + // Overwrite patch with the same name + rec.patches[existingIndex] = patch; + console.warn(`Patcher '${this.id}' has already patched '${methodName}'(patch name '${patch.name}') on the owner '${ownerName}'. The new patch will overwrite the old one.`); + } else { + rec.patches.push(patch); + } + this._recordPatch(owner, methodName); + Patcher._recomposeAndInstall(rec); + + return true; + } + + /** + * Unpatch object method + * @param {object} target + * @param {string} methodName + * @param {string} [name] (Optional) Patch name + * @returns {boolean} Whether unpatch was successful + */ + unpatch(target, methodName, name = Patcher.DEFAULT_PATCH_NAME) { + if (!target || typeof methodName !== 'string') return false; + const owner = this.options.patchOwner ? Patcher._findOwner(target, methodName) || target : target; + const rec = Patcher._getRecord(owner, methodName, false); + if (!rec) return false; + const idx = rec.patches.findIndex((p) => p.patcherId === this.id && p.name === name); + if (idx === -1) return false; + rec.patches.splice(idx, 1); + Patcher._recomposeAndInstall(rec); + + // Update ownerMap + const { ownerMap } = this.patcherInfo; + if (ownerMap) { + const methodSet = ownerMap.get(owner); + const noOtherPatch = rec.patches.every((p) => p.patcherId !== this.id); + if (methodSet && noOtherPatch) { + methodSet.delete(methodName); + if (methodSet.size === 0) ownerMap.delete(owner); + } + } + + return true; + } + + /** + * Uninstall all patches for current patcher + * @returns {number} Number of successfully uninstalled patches + */ + unpatchAll() { + const { ownerMap } = this.patcherInfo; + if (!ownerMap) return 0; + let count = 0; + this.listPatches().forEach(({ owner, methodName, name }) => { + if (this.unpatch(owner, methodName, name)) { + count++; + } + }); + ownerMap.clear(); + return count; + } + + /** + * Get list of methods patched by current patcher on various owners + * @returns {Array<{owner: object, methodName: string, name: string}>} Patch information list + */ + listPatches() { + const { ownerMap } = this.patcherInfo; + if (!ownerMap) return []; + const out = []; + ownerMap.forEach((methodSet, owner) => { + methodSet.forEach((methodName) => { + const rec = Patcher._getRecord(owner, methodName, false); + if (!rec) return; + rec.patches.forEach((p) => { + if (p.patcherId === this.id) { + out.push({ owner, methodName, name: p.name }); + } + }); + }); + }); + return out; + } + + /** + * Pause/resume a patch + * @private + * @param {object} target + * @param {string} methodName + * @param {boolean} paused Whether to pause + * @param {string} [name] (Optional) Patch name + * @returns {boolean} Whether pause/resume was successful + */ + _setPause(target, methodName, paused, name = Patcher.DEFAULT_PATCH_NAME) { + if (!target || typeof methodName !== 'string') return false; + const owner = this.options.patchOwner ? Patcher._findOwner(target, methodName) || target : target; + const rec = Patcher._getRecord(owner, methodName, false); + if (!rec) return false; + const patch = rec.patches.find((p) => p.patcherId === this.id && p.name === name); + if (!patch) return false; + patch.paused = paused; + Patcher._recomposeAndInstall(rec); + return true; + } + + /** + * Pause a patch + * @param {object} target + * @param {string} methodName + * @param {string} [name] (Optional) Patch name + * @returns {boolean} Whether pause was successful + */ + pause(target, methodName, name = Patcher.DEFAULT_PATCH_NAME) { + return this._setPause(target, methodName, true, name); + } + + /** + * Resume a patch + * @param {object} target + * @param {string} methodName + * @param {string} [name] (Optional) Patch name + * @returns {boolean} Whether resume was successful + */ + resume(target, methodName, name = Patcher.DEFAULT_PATCH_NAME) { + return this._setPause(target, methodName, false, name); + } + + /** + * Pause/resume all patches for current patcher + * @private + * @param {boolean} paused Whether to pause + * @returns {number} Number of successfully paused/resumed patches + */ + _setPauseAll(paused) { + const { ownerMap } = this.patcherInfo; + if (!ownerMap) return 0; + let count = 0; + this.listPatches().forEach(({ owner, methodName, name }) => { + if (this._setPause(owner, methodName, paused, name)) { + count++; + } + }); + return count; + } + + /** + * Pause all patches for current patcher + * @returns {number} Number of successfully paused patches + */ + pauseAll() { + return this._setPauseAll(true); + } + + /** + * Resume all patches for current patcher + * @returns {number} Number of successfully resumed patches + */ + resumeAll() { + return this._setPauseAll(false); + } +} + +module.exports = Patcher; diff --git a/src/util/color.js b/src/util/color.js index 8617a123..af3d3c53 100644 --- a/src/util/color.js +++ b/src/util/color.js @@ -213,6 +213,22 @@ class Color { b: (fraction0 * rgb0.b) + (fraction1 * rgb1.b) }; } + + /** + * Darken an hex color by a given amount. + * @param {!string} hex Hex representation of the color. + * @param {number} amount - Amount to darken the color by. default 0.1 (10%) + * @returns {string} The darken color. + */ + static darkenHex(hex, amount = 0.1) { + const {r, g, b} = Color.hexToRgb(hex); + const darkenRgb = { + r: Math.max(0, Math.round(r * (1 - amount))), + g: Math.max(0, Math.round(g * (1 - amount))), + b: Math.max(0, Math.round(b * (1 - amount))) + }; + return Color.rgbToHex(darkenRgb); + } } module.exports = Color; diff --git a/src/util/gandi.js b/src/util/gandi.js index 38d7ac67..0a6ad79d 100644 --- a/src/util/gandi.js +++ b/src/util/gandi.js @@ -53,6 +53,7 @@ class Gandi { const isDuplicateId = id => this.assets.find(obj => obj.id === id); + // TODO: should allow duplicate asset content but not the same assetId? const newAssets = data.assets.filter(obj => !isDuplicateAsset(obj)); newAssets.forEach(obj => { let newName = `${obj.name}`; @@ -358,14 +359,29 @@ class Gandi { } getExtensionAssets () { - const AssetType = this.runtime.storage.AssetType; - return this.assets.filter(item => item.asset.assetType.name === AssetType.Extension.name); + const AssetType = this.runtime.storage.AssetType; + return this.assets.filter(item => { + const type = item.asset?.assetType?.name || item.assetType?.name; + return type === AssetType.Extension.name; + }); } isExtensionURLInGandiAssets (url) { const sb3Exts = this.getExtensionAssets(); return sb3Exts.find(v => url.endsWith(v.md5)); } + + addAsset (asset) { + // check if the asset is already in the assets + const isDuplicateAsset = b => + this.assets.find(obj => obj.name === b.name && obj.dataFormat === b.dataFormat && obj.md5 === b.md5); + if (isDuplicateAsset(asset)) { + log.warn(`addAsset - Duplicate asset found: ${asset.name}.${asset.dataFormat}. Skipping`); + return false; + } + this.assets.push(asset); + return true; + } } module.exports = Gandi; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 5b99778c..f95f035a 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -341,6 +341,9 @@ class VirtualMachine extends EventEmitter { this.runtime.on(Runtime.ASSET_PROGRESS, (finished, total) => { this.emit(Runtime.ASSET_PROGRESS, finished, total); }); + this.runtime.on(Runtime.LOCALE_CHANGED, locale => { + this.emit(Runtime.LOCALE_CHANGED, locale); + }); this.extensionManager = new ExtensionManager(this); this.securityManager = this.extensionManager.securityManager; @@ -834,12 +837,16 @@ class VirtualMachine extends EventEmitter { this.runtime.gandi.assets.map(obj => obj.asset).filter(obj => obj) : []; const allAssets = this.runtime.targets.reduce( - (acc, target) => - acc - .concat(target.sprite.sounds.filter(sound => !sound.isRuntimeAsyncLoad).map(sound => sound.asset)) - .concat( - target.sprite.costumes.filter(costume => !costume.isRuntimeAsyncLoad).map(costume => costume.asset) - ), + (acc, target) => { + if (target.isOriginal) { + return acc + .concat(target.sprite.sounds.filter(sound => !sound.isRuntimeAsyncLoad).map(sound => sound.asset)) + .concat( + target.sprite.costumes.filter(costume => !costume.isRuntimeAsyncLoad).map(costume => costume.asset) + ); + } + return acc; + }, [] ).concat(gandiAssets); return allAssets; @@ -878,7 +885,7 @@ class VirtualMachine extends EventEmitter { obj.assetId = obj.asset.assetId; obj.md5 = `${obj.assetId}.${obj.dataFormat}`; - this.runtime.gandi.assets.push(obj); + this.runtime.gandi.addAsset(obj); this.emitGandiAssetsUpdate({type: 'add', data: obj}); } @@ -1110,11 +1117,45 @@ class VirtualMachine extends EventEmitter { await this.extensionManager.allAsyncExtensionsLoaded(); const addedGandiObject = this.runtime.gandi.merge(gandiObject); const extensionPromises = []; + // 可选的确认非官方扩展安装回调 + // 在加载扩展前,收集即将加载的非官方扩展信息并等待确认 + const confirmExtensionsCallBack = options?.confirmExtensionsCallBack; + if (confirmExtensionsCallBack) { + /** + * @type {[{id: string; url?: string}]} + */ + const extInfo = []; + extensions.extensionIDs.forEach(extensionID => { + // 跳过已加载 + if (this.extensionManager.isExtensionLoaded(extensionID)) return; + // 跳过 builtin + if (this.extensionManager.isBuiltinExtension(extensionID)) return; + // 检查是否记录了URL + let url = extensions.extensionURLs.get(extensionID); + if (!url) { + // 检查 wildExtensions 是否有记录 + url = this.runtime.gandi?.wildExtensions?.[extensionID]?.url; + } + // 记录了 URL + if (url) { + extInfo.push({id: extensionID, url}); + return; + } + // 跳过官方扩展 + if (this.extensionManager._officialExtensionInfo[extensionID]) return; + // 剩余情况 - 非官方扩展ID + extInfo.push({id: extensionID}); + }); + if (extInfo.length > 0) { + // 等待确认 + await confirmExtensionsCallBack(extInfo); + } + } extensions.extensionIDs.forEach(extensionID => { if (!this.extensionManager.isExtensionLoaded(extensionID)) { let extensionURL = extensionID; if (!this.extensionManager.isBuiltinExtension(extensionID) && extensions.extensionURLs.get(extensionID)) { - extensionURL = extensions.extensionURLs.get(extensionID) + extensionURL = extensions.extensionURLs.get(extensionID); } extensionPromises.push( this.extensionManager.loadExtensionURL(extensionURL) @@ -1267,8 +1308,9 @@ class VirtualMachine extends EventEmitter { } if (Array.isArray(addedGandiObject.assets)) { addedGandiObject.assets.forEach(obj => { - this.runtime.gandi.assets.push(obj); - this.runtime.emitGandiAssetsUpdate({type: 'add', data: obj}); + if (this.runtime.gandi.addAsset(obj)) { + this.runtime.emitGandiAssetsUpdate({type: 'add', data: obj}); + } }); } } @@ -1995,8 +2037,9 @@ class VirtualMachine extends EventEmitter { dataFormat: newAsset.dataFormat }; loadGandiAsset(newAsset.md5ext, file, this.runtime).then(gandiAssetObj => { - this.runtime.gandi.assets.push(gandiAssetObj); - this.runtime.emitGandiAssetsUpdateFromServer({type: 'add', data: gandiAssetObj}); + if (this.runtime.gandi.addAsset(gandiAssetObj)) { + this.runtime.emitGandiAssetsUpdateFromServer({type: 'add', data: gandiAssetObj}); + } }); } @@ -2276,7 +2319,7 @@ class VirtualMachine extends EventEmitter { translations: {[locale]: messages} }); } - this.emit('LOCALE_CHANGED', locale); + this.runtime.emit(Runtime.LOCALE_CHANGED, locale); return this.extensionManager.refreshBlocks(); } diff --git a/test/unit/extension-support-patcher.js b/test/unit/extension-support-patcher.js new file mode 100644 index 00000000..e9d37495 --- /dev/null +++ b/test/unit/extension-support-patcher.js @@ -0,0 +1,1055 @@ +const test = require('tap').test; +const Patcher = require('../../src/extension-support/patcher'); + +test('Patcher - basic functionality', t => { + t.type(Patcher, 'function'); + t.type(Patcher.UNDEFINED, 'symbol'); + t.end(); +}); + +test('Patcher - patch and unpatch', t => { + const obj = { + add: (a, b) => a + b + }; + + const patcher = new Patcher('test-patcher'); + let beforeCalled = false; + let afterCalled = false; + + patcher.patch(obj, 'add', { + before: (a, b) => { + beforeCalled = true; + t.equal(a, 2); + t.equal(b, 3); + }, + after: (result, a, b) => { + afterCalled = true; + t.equal(result, 5); + t.equal(a, 2); + t.equal(b, 3); + return result * 2; + } + }); + + const result = obj.add(2, 3); + t.equal(result, 10); + t.equal(beforeCalled, true); + t.equal(afterCalled, true); + + patcher.unpatch(obj, 'add'); + const resultAfterUnpatch = obj.add(2, 3); + t.equal(resultAfterUnpatch, 5); + + t.end(); +}); + +test('Patcher - before hook with early return', t => { + const obj = { + add: (a, b) => a + b + }; + + const patcher = new Patcher('test-patcher'); + patcher.patch(obj, 'add', { + before: (a, b) => { + if (a > 10) return 999; + } + }); + + t.equal(obj.add(5, 3), 8); + t.equal(obj.add(15, 3), 999); + + t.end(); +}); + +test('Patcher - wrapper function', t => { + const obj = { + add: (a, b) => a + b + }; + + const patcher = new Patcher('test-patcher'); + patcher.patch(obj, 'add', (next, a, b) => { + return next(a, b) * 10; + }); + + t.equal(obj.add(2, 3), 50); + + t.end(); +}); + +test('Patcher - multiple patches on same method', t => { + const obj = { + add: (a, b) => a + b + }; + + const patcher1 = new Patcher('patcher1'); + const patcher2 = new Patcher('patcher2'); + + patcher1.patch(obj, 'add', { + after: (result) => result * 2 + }); + + patcher2.patch(obj, 'add', { + after: (result) => result + 10 + }); + + t.equal(obj.add(2, 3), 20); // (2+3)*2 +10 = 20 + + patcher1.unpatch(obj, 'add'); + t.equal(obj.add(2, 3), 15); // (2+3) +10 =15 + + patcher2.unpatch(obj, 'add'); + t.equal(obj.add(2, 3), 5); + + t.end(); +}); + +test('Patcher - replace function', t => { + const obj = { + add: (a, b) => a + b + }; + + const patcher = new Patcher('test-patcher'); + patcher.patch(obj, 'add', { + replace: (a, b) => a * b + }); + + t.equal(obj.add(2, 3), 6); + + t.end(); +}); + +test('Patcher - patchOnce option', t => { + const obj = { + add: (a, b) => a + b + }; + + const patcher = new Patcher('test-patcher'); + patcher.patch(obj, 'add', { + before: () => {}, + patchOnce: true + }); + + // Second patch should fail + const result = patcher.patch(obj, 'add', { + before: () => {}, + patchOnce: true + }); + + t.equal(result, false); + + t.end(); +}); + +test('Patcher - factory function', t => { + const obj = { + add: (a, b) => a + b + }; + + const patcher = new Patcher('test-factory'); + patcher.patch(obj, 'add', { + factory: (next) => (a, b) => next(a, b) * 3 + }); + + t.equal(obj.add(2, 3), 15); // (2+3)*3 =15 + + t.end(); +}); + +test('Patcher - patch overwrite', t => { + const obj = { + add: (a, b) => a + b + }; + + const patcher = new Patcher('test-overwrite'); + patcher.patch(obj, 'add', { + after: (result) => result * 2 + }); + + t.equal(obj.add(2, 3), 10); + + // Patch again to overwrite + patcher.patch(obj, 'add', { + after: (result) => result * 3 + }); + + t.equal(obj.add(2, 3), 15); // (2+3)*3=15 + + t.end(); +}); + +test('Patcher - unpatchAll', t => { + const obj = { + add: (a, b) => a + b, + multiply: (a, b) => a * b + }; + + const patcher = new Patcher('test-unpatchAll'); + patcher.patch(obj, 'add', { + after: (result) => result * 2 + }); + patcher.patch(obj, 'multiply', { + after: (result) => result + 10 + }); + + t.equal(obj.add(2, 3), 10); + t.equal(obj.multiply(2, 3), 16); + + const count = patcher.unpatchAll(); + t.equal(count, 2); + + t.equal(obj.add(2, 3), 5); + t.equal(obj.multiply(2, 3), 6); + + t.end(); +}); + +test('Patcher - listPatches', t => { + const obj = { + add: (a, b) => a + b + }; + + const patcher = new Patcher('test-listPatches'); + patcher.patch(obj, 'add', { + after: (result) => result * 2 + }); + patcher.patch(obj, 'add', { + name: 'after2', + after: (result) => result * 2 + }); + + const patches = patcher.listPatches(); + t.equal(patches.length, 2); + t.equal(patches[0].methodName, 'add'); + t.equal(patches[1].name, 'after2'); + + t.end(); +}); + +// 补充测试:验证 Patcher.UNDEFINED 在 before 中提前返回 undefined +test('Patcher - UNDEFINED in before to return undefined', t => { + const obj = { + fn: () => 'original' + }; + const patcher = new Patcher('before-undefined'); + patcher.patch(obj, 'fn', { + before: () => Patcher.UNDEFINED + }); + t.equal(obj.fn(), undefined); + patcher.unpatchAll(); + t.end(); +}); + +// 补充测试:验证 Patcher.UNDEFINED 在 after 中将返回值改为 undefined +test('Patcher - UNDEFINED in after to set return value to undefined', t => { + const obj = { + fn: () => 'original' + }; + const patcher = new Patcher('after-undefined'); + patcher.patch(obj, 'fn', { + after: () => Patcher.UNDEFINED + }); + t.equal(obj.fn(), undefined); + patcher.unpatchAll(); + t.end(); +}); + +// 补充测试:patchOwner 默认 true,查找原型链拥有者 +test('Patcher - patchOwner true finds prototype owner', t => { + class Parent { + method() { return 'parent'; } + } + class Child extends Parent {} + const child = new Child(); + const patcher = new Patcher('prototype'); + patcher.patch(child, 'method', { + after: res => res + ' patched' + }); + t.equal(child.method(), 'parent patched'); + const patches = patcher.listPatches(); + t.equal(patches.length, 1); + t.equal(patches[0].owner, Parent.prototype); + t.equal(patches[0].methodName, 'method'); + patcher.unpatchAll(); + t.end(); +}); + +// 补充测试:patchOwner false 直接 patch 在实例上 +test('Patcher - patchOwner false patches instance directly', t => { + class Parent { + method() { return 'parent'; } + } + class Child extends Parent {} + const child = new Child(); + const patcher = new Patcher('instance', { patchOwner: false }); + patcher.patch(child, 'method', { + after: res => res + ' patched' + }); + t.equal(child.method(), 'parent patched'); + const patches = patcher.listPatches(); + t.equal(patches.length, 1); + t.equal(patches[0].owner, child); + patcher.unpatchAll(); + t.end(); +}); + +// 修正:before 钩子顺序与中断 —— 使用多个 patcher +test('Patcher - before hooks order and interruption (multiple patchers)', t => { + const obj = { + fn: () => 'original' + }; + // 创建三个不同的 patcher + const p1 = new Patcher('order-before-1'); + const p2 = new Patcher('order-before-2'); + const p3 = new Patcher('order-before-3'); + + const log = []; + + p1.patch(obj, 'fn', { + before: () => { log.push('before1'); } + }); + p2.patch(obj, 'fn', { + before: () => { log.push('before2'); return 'interrupted'; } + }); + p3.patch(obj, 'fn', { + before: () => { log.push('before3'); } // 不会执行 + }); + + const result = obj.fn(); + t.equal(result, 'interrupted'); + t.same(log, ['before1', 'before2']); // p3 的 before 不会执行 + + // 清理 + p1.unpatchAll(); + p2.unpatchAll(); + p3.unpatchAll(); + t.end(); +}); + +// 修正:after 钩子顺序与值传递 —— 使用多个 patcher +test('Patcher - after hooks order and value modification (multiple patchers)', t => { + const obj = { + fn: () => 5 + }; + const p1 = new Patcher('order-after-1'); + const p2 = new Patcher('order-after-2'); + + p1.patch(obj, 'fn', { + after: res => { t.equal(res, 5); return res + 1; } + }); + p2.patch(obj, 'fn', { + after: res => { t.equal(res, 6); return res * 2; } + }); + + const result = obj.fn(); + t.equal(result, 12); // 5+1=6, 6*2=12 + + p1.unpatchAll(); + p2.unpatchAll(); + t.end(); +}); + +// 修正:混合补丁类型 —— 使用多个 patcher 模拟叠加 +test('Patcher - mix of before, after, wrapper, factory, replace (multiple patchers)', t => { + const obj = { + compute: x => x + }; + // 每个补丁使用独立 patcher + const pBefore = new Patcher('mix-before'); + const pWrapper = new Patcher('mix-wrapper'); + const pFactory = new Patcher('mix-factory'); + const pAfter = new Patcher('mix-after'); + + pBefore.patch(obj, 'compute', { + before: x => { t.equal(x, 5); } // 无返回值,不中断 + }); + pWrapper.patch(obj, 'compute', function(next, x) { + return next(x) * 2; + }); + pFactory.patch(obj, 'compute', { + factory: next => x => next(x) + 3 + }); + pAfter.patch(obj, 'compute', { + after: res => res - 1 + }); + + // 组合顺序(按 patch 顺序): + // before (无影响) -> wrapper (*2) -> factory (+3) -> after (-1) + // 原始结果:5 + // wrapper 后:10 + // factory 后:13 + // after 后:12 + const result = obj.compute(5); + t.equal(result, 12); + + pBefore.unpatchAll(); + pWrapper.unpatchAll(); + pFactory.unpatchAll(); + pAfter.unpatchAll(); + t.end(); +}); + +// 修正:多个 before,第一个返回非 undefined 即停止 +test('Patcher - multiple before hooks, first returns non-undefined stops (multiple patchers)', t => { + const obj = { + fn: () => 'original' + }; + const p = new Patcher('multi-before-1'); + const p2 = new Patcher('multi-before-2'); + const p3 = new Patcher('multi-before-3'); + + const log = []; + p.patch(obj, 'fn', { + name: 'before1', + before: () => { log.push('1'); } + }); + p2.patch(obj, 'fn', { + name: 'before2', + before: () => { log.push('2'); return 'stopped'; } + }); + p3.patch(obj, 'fn', { + name: 'before3', + before: () => { log.push('3'); } // 不会执行 + }); + + const result = obj.fn(); + t.equal(result, 'stopped'); + t.same(log, ['1', '2']); + + p.unpatchAll(); + t.end(); +}); + +// 修正:多个 after 依次修改返回值 +test('Patcher - multiple after hooks modify value sequentially (multiple patchers)', t => { + const obj = { + fn: () => 1 + }; + const p = new Patcher('multi-after-1'); + + p.patch(obj, 'fn', { + name: 'after1', + after: res => res + 1 + }); + p.patch(obj, 'fn', { + name: 'after2', + after: res => res * 2 + }); + p.patch(obj, 'fn', { + name: 'after3', + after: res => res - 3 + }); + // (1+1)*2-3 = 1 + const result = obj.fn(); + t.equal(result, 1); + + p.unpatchAll(); + t.end(); +}); + +// 补充测试:对不存在的方法 patch 返回 false +test('Patcher - patch non-existent method returns false', t => { + const obj = {}; + const patcher = new Patcher('nonexistent'); + const result = patcher.patch(obj, 'missing', { before: () => {} }); + t.equal(result, false); + t.end(); +}); + +// 补充测试:getCustomInfo 返回同一 id 共享的对象 +test('Patcher - getCustomInfo returns shared object for same id', t => { + const patcher1 = new Patcher('custom'); + const patcher2 = new Patcher('custom'); + patcher1.getCustomInfo().foo = 'bar'; + t.equal(patcher2.getCustomInfo().foo, 'bar'); + patcher2.getCustomInfo().baz = 123; + t.equal(patcher1.getCustomInfo().baz, 123); + patcher1.unpatchAll(); // 清理 + t.end(); +}); + +// 补充测试:空 spec 对象不应抛出异常 +test('Patcher - empty spec should not throw', t => { + const obj = { method: () => {} }; + const patcher = new Patcher('empty'); + t.doesNotThrow(() => { + patcher.patch(obj, 'method', {}); + }); + t.equal(obj.method(), undefined); // 无变化 + patcher.unpatchAll(); + t.end(); +}); + +// 补充测试:相同 id 多次 patch 会覆盖旧补丁 +test('Patcher - multiple patch with same id overwrites', t => { + const obj = { calc: () => 1 }; + const patcher = new Patcher('overwrite'); + patcher.patch(obj, 'calc', { replace: () => 2 }); + t.equal(obj.calc(), 2); + patcher.patch(obj, 'calc', { replace: () => 3 }); + t.equal(obj.calc(), 3); + const patches = patcher.listPatches(); + t.equal(patches.length, 1); // 仍为一个补丁 + patcher.unpatchAll(); + t.end(); +}); + +// 补充测试:listPatches 对原型补丁返回正确的 owner +test('Patcher - listPatches returns correct owner for prototype patch', t => { + class A { method() {} } + class B extends A {} + const b = new B(); + const patcher = new Patcher('list-owner'); + patcher.patch(b, 'method', { before: () => {} }); + const patches = patcher.listPatches(); + t.equal(patches.length, 1); + t.equal(patches[0].owner, A.prototype); + t.equal(patches[0].methodName, 'method'); + patcher.unpatchAll(); + t.end(); +}); + +// 补充测试:after 钩子可以访问原函数参数 +test('Patcher - after hook can access original arguments', t => { + const obj = { + sum: (a, b) => a + b + }; + const patcher = new Patcher('after-args'); + patcher.patch(obj, 'sum', { + after: (result, a, b) => { + t.equal(result, 5); + t.equal(a, 2); + t.equal(b, 3); + return result * (a + b); + } + }); + const patches = patcher.listPatches(obj, 'sum'); + t.equal(patches.length, 1); + t.equal(patches[0].name, 'default'); + const result = obj.sum(2, 3); + t.equal(result, 25); // 5 * 5 + patcher.unpatchAll(); + t.end(); +}); + +// 补充测试:多 patch 支持不同名称 +test('Patcher - multiple patches with different names', t => { + const obj = { + add: (a, b) => a + b + }; + const patcher = new Patcher('multi-names'); + let count1 = 0; + let count2 = 0; + patcher.patch(obj, 'add', { + name: 'feature1', + before: () => {count1++} + }); + const patches = patcher.listPatches(obj, 'add'); + t.equal(patches.length, 1); + t.equal(patches[0].name, 'feature1'); + patcher.patch(obj, 'add', { + name: 'feature2', + before: () => {count2++} + }); + const result = obj.add(1, 2); + t.equal(result, 3); + t.equal(count1, 1); + t.equal(count2, 1); + // 同名覆盖 + patcher.patch(obj, 'add', { + name: 'feature1', + before: () => {count1 += 2} + }); + const result2 = obj.add(1, 2); + t.equal(result2, 3); + t.equal(count1, 3); // 1 + 2 + t.equal(count2, 2); // 1 + 1 + // unpatch 特定名称 + patcher.unpatch(obj, 'add', 'feature2'); + const result3 = obj.add(1, 2); + t.equal(result3, 3); + t.equal(count1, 5); // 3 + 2 + t.equal(count2, 2); + patcher.unpatchAll(); + t.end(); +}); + +// 补充测试:pause/resume 功能 +test('Patcher - pause and resume patch', t => { + const obj = { + multiply: (a, b) => a * b + }; + const patcher = new Patcher('pause-resume'); + let called = false; + patcher.patch(obj, 'multiply', { + before: () => { called = true; } + }); + let result = obj.multiply(2, 3); + t.equal(result, 6); + t.equal(called, true); + called = false; + // pause + patcher.pause(obj, 'multiply'); + result = obj.multiply(2, 3); + t.equal(result, 6); + t.equal(called, false); + // resume + patcher.resume(obj, 'multiply'); + result = obj.multiply(2, 3); + t.equal(result, 6); + t.equal(called, true); + patcher.unpatchAll(); + t.end(); +}); + +// 补充测试:pauseAll/resumeAll 功能 +test('Patcher - pauseAll and resumeAll patches', t => { + const obj1 = { add: (a, b) => a + b }; + const obj2 = { multiply: (a, b) => a * b }; + const patcher = new Patcher('pauseall-resumeall'); + let called1 = false; + let called2 = false; + patcher.patch(obj1, 'add', { + before: () => { called1 = true; } + }); + patcher.patch(obj2, 'multiply', { + before: () => { called2 = true; } + }); + // pause all + patcher.pauseAll(); + let result1 = obj1.add(1, 2); + let result2 = obj2.multiply(2, 3); + t.equal(result1, 3); + t.equal(result2, 6); + t.equal(called1, false); + t.equal(called2, false); + // resume all + patcher.resumeAll(); + called1 = false; + called2 = false; + result1 = obj1.add(1, 2); + result2 = obj2.multiply(2, 3); + t.equal(result1, 3); + t.equal(result2, 6); + t.equal(called1, true); + t.equal(called2, true); + patcher.unpatchAll(); + t.end(); +}); + +// ==================== 高级用法测试 ==================== + +// 测试:多次 patch - 默认覆盖行为 +test('Patcher - multiple patches default overwrite', t => { + const obj = { + method: () => 'original' + }; + const patcher = new Patcher('multi-patch-overwrite'); + + // 第一次 patch + patcher.patch(obj, 'method', { + after: () => 'first' + }); + t.equal(obj.method(), 'first'); + + // 第二次 patch(同名,默认覆盖) + patcher.patch(obj, 'method', { + after: () => 'second' + }); + t.equal(obj.method(), 'second'); + + const patches = patcher.listPatches(obj, 'method'); + t.equal(patches.length, 1); // 只有一个 patch + + patcher.unpatchAll(); + t.end(); +}); + +// 测试:多次 patch - 使用不同 name 保留多个 patch +test('Patcher - multiple patches with different names', t => { + const obj = { + method: () => 'original' + }; + const patcher = new Patcher('multi-patch-names'); + + // 使用不同 name 的多个 patch + patcher.patch(obj, 'method', { + name: 'feature1', + after: (res) => res + '-feature1' + }); + patcher.patch(obj, 'method', { + name: 'feature2', + after: (res) => res + '-feature2' + }); + + const result = obj.method(); + t.equal(result, 'original-feature1-feature2'); + + const patches = patcher.listPatches(obj, 'method'); + t.equal(patches.length, 2); + t.equal(patches[0].name, 'feature1'); + t.equal(patches[1].name, 'feature2'); + + // 同名 patch 覆盖 + patcher.patch(obj, 'method', { + name: 'feature2', + after: (res) => res + '-overwritten' + }); + + const result2 = obj.method(); + t.equal(result2, 'original-feature1-overwritten'); + + const patches2 = patcher.listPatches(obj, 'method'); + t.equal(patches2.length, 2); // 仍然是 2 个 + + patcher.unpatchAll(); + t.end(); +}); + +// 测试:unpatch 指定 name +test('Patcher - unpatch with specific name', t => { + const obj = { + method: () => 10 + }; + const patcher = new Patcher('unpatch-name'); + + patcher.patch(obj, 'method', { + name: 'add1', + after: (res) => res + 1 + }); + patcher.patch(obj, 'method', { + name: 'multiply2', + after: (res) => res * 2 + }); + + t.equal(obj.method(), 22); // (10+1)*2 = 22 + + // 移除指定 name 的 patch + patcher.unpatch(obj, 'method', 'multiply2'); + + t.equal(obj.method(), 11); // 10+1 = 11 + + const patches = patcher.listPatches(obj, 'method'); + t.equal(patches.length, 1); + t.equal(patches[0].name, 'add1'); + + patcher.unpatchAll(); + t.end(); +}); + +// 测试:patch 顺序 - ORDER_EARLY, ORDER_NORMAL, ORDER_LATE +test('Patcher - patch order with ORDER constants', t => { + const obj = { + method: () => { log.push('orig'); } + }; + + const patcher1 = new Patcher('order-late'); + const patcher2 = new Patcher('order-early'); + const patcher3 = new Patcher('order-normal'); + + const log = []; + + patcher1.patch(obj, 'method', { + order: Patcher.ORDER_LATE, + before: () => { log.push('late-before'); }, + wrapper: (orig, ...args) => { + log.push('late-wrap'); + return orig.apply(this, args); + }, + after: () => { log.push('late-after'); } + }); + + patcher2.patch(obj, 'method', { + order: Patcher.ORDER_EARLY, + before: () => { log.push('early-before'); }, + wrapper: (orig, ...args) => { + log.push('early-wrap'); + return orig.apply(this, args); + }, + after: () => { log.push('early-after'); } + }); + + patcher3.patch(obj, 'method', { + order: Patcher.ORDER_NORMAL, + before: () => { log.push('normal-before'); }, + wrapper: (orig, ...args) => { + log.push('normal-wrap'); + return orig.apply(this, args); + }, + after: () => { log.push('normal-after'); } + }); + + obj.method(); + + // before 执行顺序:EARLY -> NORMAL -> LATE + t.equal(log[0], 'early-before'); + t.equal(log[1], 'normal-before'); + t.equal(log[2], 'late-before'); + + t.equal(log[3], 'late-wrap'); + t.equal(log[4], 'normal-wrap'); + t.equal(log[5], 'early-wrap'); + t.equal(log[6], 'orig'); + + // after 执行顺序:EARLY -> NORMAL -> LATE + t.equal(log[7], 'early-after'); + t.equal(log[8], 'normal-after'); + t.equal(log[9], 'late-after'); + + t.equal(log.length, 10); + + patcher1.unpatchAll(); + patcher2.unpatchAll(); + patcher3.unpatchAll(); + t.end(); +}); + +// 测试:patch 顺序 - 自定义 order 值 +test('Patcher - patch order with custom order values', t => { + const obj = { + compute: () => 0 + }; + + const p1 = new Patcher('order-10'); + const p2 = new Patcher('order-5'); + const p3 = new Patcher('order-1'); + + const log = []; + + // 以不同顺序 patch,验证 order 值决定执行顺序 + p3.patch(obj, 'compute', { + order: 1, + before: () => { log.push(1); }, + after: (res) => res + 1 + }); + + p1.patch(obj, 'compute', { + order: 10, + before: () => { log.push(10); }, + after: (res) => res + 10 + }); + + p2.patch(obj, 'compute', { + order: 5, + before: () => { log.push(5); }, + after: (res) => res + 5 + }); + + const result = obj.compute(); + + // before 执行顺序:1 -> 5 -> 10 + t.same(log, [1, 5, 10]); + + // after 计算:0 + 1 + 5 + 10 = 16 + t.equal(result, 16); + + p1.unpatchAll(); + p2.unpatchAll(); + p3.unpatchAll(); + t.end(); +}); + +// 测试:patch 顺序 - 混合使用不同 patch 类型 +test('Patcher - patch order with mixed patch types', t => { + const obj = { + value: () => 100 + }; + + const pEarly = new Patcher('early'); + const pLate = new Patcher('late'); + + const log = []; + + // LATE: after 修改返回值 + pLate.patch(obj, 'value', { + order: Patcher.ORDER_LATE, + after: (res) => { + log.push('late-after'); + return res * 2; + } + }); + + // EARLY: before 记录日志 + pEarly.patch(obj, 'value', { + order: Patcher.ORDER_EARLY, + before: () => { + log.push('early-before'); + } + }); + + const result = obj.value(); + + // before 先执行(EARLY) + t.equal(log[0], 'early-before'); + // after 后执行(LATE) + t.equal(log[1], 'late-after'); + + // 返回值被 LATE 的 after 修改 + t.equal(result, 200); // 100 * 2 + + pEarly.unpatchAll(); + pLate.unpatchAll(); + t.end(); +}); + +// 测试:order 影响 before 钩子的中断行为 +test('Patcher - order affects before hook interruption', t => { + const obj = { + method: () => 'should-not-reach' + }; + + const pEarly = new Patcher('early-interrupt'); + const pLate = new Patcher('late-no-interrupt'); + + const log = []; + + // LATE 的 before 返回中断值,但由于 EARLY 先执行,如果 EARLY 中断则 LATE 不会执行 + pLate.patch(obj, 'method', { + order: Patcher.ORDER_LATE, + before: () => { + log.push('late'); + return 'late-interrupted'; + } + }); + + pEarly.patch(obj, 'method', { + order: Patcher.ORDER_EARLY, + before: () => { + log.push('early'); + return 'early-interrupted'; + } + }); + + const result = obj.method(); + + // EARLY 先执行并中断,LATE 不会执行 + t.same(log, ['early']); + t.equal(result, 'early-interrupted'); + + pEarly.unpatchAll(); + pLate.unpatchAll(); + t.end(); +}); + +// 测试:文档示例 - 多次 patch 覆盖 +test('Patcher - doc example: multiple patches overwrite', t => { + const obj = { + _step: () => {} + }; + + const patcher = new Patcher('doc-example-1'); + const log = []; + + patcher.patch(obj, '_step', { + before: function () { + log.push('1'); + } + }); + + // 重复 patch 时,会覆盖之前的 patch + patcher.patch(obj, '_step', { + before: function () { + log.push('2'); + } + }); + + obj._step(); + + t.same(log, ['2']); // 只有第二个执行 + + patcher.unpatchAll(); + t.end(); +}); + +// 测试:文档示例 - 使用 name 保留多个 patch +test('Patcher - doc example: multiple patches with names', t => { + const obj = { + _step: () => {} + }; + + const patcher = new Patcher('doc-example-2'); + const log = []; + + patcher.patch(obj, '_step', { + name: '功能 1', + before: function () { + log.push('fun1'); + } + }); + + patcher.patch(obj, '_step', { + name: '功能 2', + before: function () { + log.push('fun2'); + } + }); + + // 同名则覆盖之前的 patch + patcher.patch(obj, '_step', { + name: '功能 2', + before: function () { + log.push('覆盖!'); + } + }); + + obj._step(); + + t.same(log, ['fun1', '覆盖!']); + + // 移除指定 name 的 patch + patcher.unpatch(obj, '_step', '功能 2'); + + log.length = 0; + obj._step(); + t.same(log, ['fun1']); + + patcher.unpatchAll(); + t.end(); +}); + +// 测试:文档示例 - patch 顺序 +test('Patcher - doc example: patch order', t => { + const obj = { + test: function () { + return 'original'; + } + }; + + const patcher1 = new Patcher('ext1'); + const patcher2 = new Patcher('ext2'); + const patcher3 = new Patcher('ext3'); + + const log = []; + + patcher1.patch(obj, 'test', { + order: Patcher.ORDER_LATE, + before: function () { + log.push('ext1'); + } + }); + + patcher2.patch(obj, 'test', { + order: Patcher.ORDER_EARLY, + before: function () { + log.push('ext2'); + } + }); + + patcher3.patch(obj, 'test', { + order: Patcher.ORDER_NORMAL, + before: function () { + log.push('ext3'); + } + }); + + obj.test(); + + // 输出顺序:ext2, ext3, ext1 + t.same(log, ['ext2', 'ext3', 'ext1']); + + patcher1.unpatchAll(); + patcher2.unpatchAll(); + patcher3.unpatchAll(); + t.end(); +}); + +