Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8c67254
v1.25.4
sylarhcn Jul 21, 2025
fe09d72
feat(vm): 添加非官方扩展加载前的确认回调功能
Aug 26, 2025
90fb7e2
fix(扩展确认): 未记录URL的扩展增加检查wildExtensions
Aug 26, 2025
ae96ab3
v1.25.5
sylarhcn Aug 28, 2025
03c580c
refactor(gandi): replace direct asset manipulation with addAsset method
sylarhcn Aug 29, 2025
17ab923
refactor(gandi): enhance asset duplication check in addAsset method
sylarhcn Oct 20, 2025
96aa484
Apply suggestion to src/util/gandi.js
Oct 20, 2025
cfb511d
Update package version to 1.25.6 and update repository SHA in package…
sylarhcn Jan 7, 2026
8e99afd
fix: 获取vm上的assets不应包含克隆体里的资源
Jan 7, 2026
020f5c3
v1.25.7
Jan 7, 2026
bc32911
fix(extension-translate): 修复两个扩展使用translate.setup时相互覆盖的bug
Mar 2, 2026
c929001
fix(blocks): 修复重置缓存时未处理全局积木的问题
Mar 8, 2026
ac1ae10
v1.25.9
sylarhcn Mar 17, 2026
33f18b4
fix(extension-loader): 限制一次加载一个扩展,避免global.Scratch竞态问题
Mar 22, 2026
cbe3700
refactor(getFormatMessage): 避免每次重新setup,提升性能
Mar 25, 2026
3d28f46
feat: 增加Patcher,用于扩展的patch管理
Mar 25, 2026
f60a7c2
v1.26.0
sylarhcn Mar 25, 2026
72fc9a2
Merge branch 'develop' of gitlab.xiguacity.cn:fee/scratch/scratch-vm …
sylarhcn Mar 25, 2026
fc0ed2b
chore: bump version to 1.26.1 and update repository SHA
sylarhcn Mar 26, 2026
eec138a
fix(extension-load-helper): 修复扩展加载后的gloabl.Scratch清理导致的问题
May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
{
"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",
"homepage": "https://github.com/Gandi-IDE/scratch-vm#readme",
"repository": {
"type": "git",
"url": "https://github.com/Gandi-IDE/scratch-vm.git",
"sha": "7429d67e03f0dacdf629dd5befd704126c8e4c0d"
"sha": "72fc9a2eabb363d61e0a00adddf971406eb21132"
},
"main": "./src/index.js",
"browser": "./src/index.js",
Expand Down
30 changes: 29 additions & 1 deletion src/engine/blocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down
24 changes: 20 additions & 4 deletions src/engine/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2087,7 +2095,6 @@ class Runtime extends EventEmitter {
return `%${argNum}`;
}


/**
* @returns {Array.<object>} scratch-blocks XML for each category of extension blocks, in category order.
* @param {?Target} [target] - the active editing target (optional)
Expand All @@ -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 => {
Expand Down Expand Up @@ -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 () {
Expand Down
74 changes: 53 additions & 21 deletions src/extension-support/extension-load-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,49 @@ 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 => {
const info = extensionInstance.getInfo();
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;
Expand All @@ -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,
Expand All @@ -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}) => {
Expand Down Expand Up @@ -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};
18 changes: 7 additions & 11 deletions src/extension-support/extension-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
}
Expand Down
Loading
Loading