From 5fc8d4ce31c69ea0d69058d5893742015a48cd8f Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Thu, 5 Mar 2026 00:25:55 +0800 Subject: [PATCH 01/36] :tada: feat: init of clipcc-extension Signed-off-by: Alex Cui --- package.json | 3 +- packages/extension/.gitignore | 13 + packages/extension/LICENSE | 21 ++ packages/extension/package.json | 26 ++ packages/extension/src/extension-manager.ts | 119 ++++++ packages/extension/src/index.ts | 8 + .../extension/src/interfaces/i_extension.ts | 22 ++ packages/extension/tsconfig.json | 114 ++++++ packages/extension/webpack.config.js | 51 +++ yarn.lock | 338 ++++++++++++++++-- 10 files changed, 686 insertions(+), 29 deletions(-) create mode 100644 packages/extension/.gitignore create mode 100644 packages/extension/LICENSE create mode 100644 packages/extension/package.json create mode 100644 packages/extension/src/extension-manager.ts create mode 100644 packages/extension/src/index.ts create mode 100644 packages/extension/src/interfaces/i_extension.ts create mode 100644 packages/extension/tsconfig.json create mode 100644 packages/extension/webpack.config.js diff --git a/package.json b/package.json index d50465e1b..824b65e9e 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "storage": "yarn workspace clipcc-storage", "paint": "yarn workspace clipcc-paint", "parser": "yarn workspace clipcc-parser", - "audio": "yarn workspace clipcc-audio" + "audio": "yarn workspace clipcc-audio", + "extension": "yarn workspace clipcc-extension" }, "devDependencies": { "@changesets/changelog-git": "^0.1.14", diff --git a/packages/extension/.gitignore b/packages/extension/.gitignore new file mode 100644 index 000000000..d9529d652 --- /dev/null +++ b/packages/extension/.gitignore @@ -0,0 +1,13 @@ +# Mac OS +.DS_Store + +# NPM +/node_modules +npm-* + +# Editor +/.idea +/.vscode + +# Build +/dist diff --git a/packages/extension/LICENSE b/packages/extension/LICENSE new file mode 100644 index 000000000..578564142 --- /dev/null +++ b/packages/extension/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Clip Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/extension/package.json b/packages/extension/package.json new file mode 100644 index 000000000..461438200 --- /dev/null +++ b/packages/extension/package.json @@ -0,0 +1,26 @@ +{ + "name": "clipcc-extension", + "version": "3.2.0", + "description": "Extension manager and interfaces for ClipCC", + "author": "Clip Team", + "license": "MIT", + "main": "./dist/clipcc-extension.js", + "types": "./dist/clipcc-extension.d.ts", + "repository": "https://github.com/Clipteam/clipcc.git", + "scripts": { + "build": "rimraf dist && mkdirp dist && webpack --progress --color --bail", + "lint": "eslint ." + }, + "devDependencies": { + "eslint": "^10.0.2", + "lodash.defaultsdeep": "^4.6.1", + "terser-webpack-plugin": "^5.3.16", + "ts-loader": "^9.5.4", + "typescript": "^5.9.3", + "webpack": "^5.105.3", + "webpack-cli": "^6.0.1" + }, + "dependencies": { + "eslint-config-clipcc": "^9.0.9" + } +} diff --git a/packages/extension/src/extension-manager.ts b/packages/extension/src/extension-manager.ts new file mode 100644 index 000000000..46c24df9e --- /dev/null +++ b/packages/extension/src/extension-manager.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MIT + */ + +import {EventEmitter} from 'events'; +import {IExtension} from './interfaces/i_extension'; + +/** + * Class to manage all of the extensions. + */ +export class ExtensionManager { + /** Map from ID to all loaded extensions. */ + private loadedExtensions: Map = new Map(); + + /** Event emitter. */ + private eventEmitter: EventEmitter = new EventEmitter(); + + constructor() {} + + /** + * Load a extension to editor. The method only adds the extension to + * manager, and the extension will still be disabled until `enableExtension` + * is called. + * @throws Will throw an error if extension already loaded. + * @param extension The extension to load. + */ + loadExtension(extension: IExtension): void { + const extensionId = extension.getId(); + if (this.loadedExtensions.has(extensionId)) { + throw new Error(`Extension with id ${extensionId} already exists.`); + } + + this.loadedExtensions.set(extensionId, extension); + } + + /** + * Check whether the extension has been loaded. + * @param extensionId Extension ID. + * @returns True if the extension is loaded. + */ + isExtensionLoaded(extensionId: string): boolean { + return this.loadedExtensions.has(extensionId); + } + + /** + * Enable the extension with given ID. + * @throws Will throw an error if extension is not found or has been enabled. + * @param extensionId ID of the extension to enable. + */ + enableExtension(extensionId: string): void { + const extension = this.getExtensionById(extensionId); + if (!extension) { + throw new Error(`Extension ${extensionId} is not found.`); + } + + if (extension.isEnabled()) { + throw new Error(`Extension ${extensionId} is already enabled.`); + } + + // @TODO + } + + /** + * Disable the extension with given ID. + * @throws Will throw an error if extension is not found or not enabled. + * @param extensionId ID of the extension to disable. + */ + disableExtension(extensionId: string): void { + const extension = this.getExtensionById(extensionId); + if (!extension) { + throw new Error(`Extension ${extensionId} is not found.`); + } + + if (!extension.isEnabled()) { + throw new Error(`Extension ${extensionId} is not enabled.`); + } + + // @TODO + } + + /** + * Check whether the extension is enabled. + * @param extensionId ID of the extension to check. + * @returns True if the extension is enabled. + */ + isExtensionEnabled(extensionId: string): boolean { + const extension = this.getExtensionById(extensionId); + return !!extension && extension.isEnabled(); + } + + /** + * Get the extension object. + * @param extensionId ID of the extension. + * @returns The extension object. + */ + protected getExtensionById(extensionId: string): IExtension | null { + return this.loadedExtensions.get(extensionId) ?? null; + } + + /** + * Add an event listener. + * @param event Name of the event. + * @param listener Callback function. + */ + addEventListener(event: string, listener: (...args: any[]) => void): void { + this.eventEmitter.addListener(event, listener); + } + + /** + * Remove an event listener. + * @param event Name of the event. + * @param listener Callback function. + */ + removeEventListener(event: string, listener: (...args: any[]) => void): void { + this.eventEmitter.removeListener(event, listener); + } +} diff --git a/packages/extension/src/index.ts b/packages/extension/src/index.ts new file mode 100644 index 000000000..d7925fb13 --- /dev/null +++ b/packages/extension/src/index.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MIT + */ + +export {ExtensionManager} from './extension-manager'; +export {IExtension} from './interfaces/i_extension'; diff --git a/packages/extension/src/interfaces/i_extension.ts b/packages/extension/src/interfaces/i_extension.ts new file mode 100644 index 000000000..1bd13e493 --- /dev/null +++ b/packages/extension/src/interfaces/i_extension.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MIT + */ + +/** + * Interface of extension object. + */ +export interface IExtension { + /** + * Get ID of the extension. + * @returns ID of the extension. + */ + getId(): string; + + /** + * Check whether the extension is enabled. + * @returns True if the extension is enabled. + */ + isEnabled(): boolean; +} diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json new file mode 100644 index 000000000..a62650f6b --- /dev/null +++ b/packages/extension/tsconfig.json @@ -0,0 +1,114 @@ +{ + "include": [ + "./src" + ], + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "Node16", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node16", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist/types", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/packages/extension/webpack.config.js b/packages/extension/webpack.config.js new file mode 100644 index 000000000..f7c0cfdbf --- /dev/null +++ b/packages/extension/webpack.config.js @@ -0,0 +1,51 @@ +const defaultsDeep = require('lodash.defaultsdeep'); +const path = require('path'); +const TerserPlugin = require('terser-webpack-plugin'); + +const baseConfig = { + mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', + devtool: 'cheap-module-source-map', + entry: { + 'clipcc-extension': './src/index.ts' + }, + output: { + library: 'Extension', + filename: '[name].js' + }, + resolve: { + extensions: ['.ts', '.js'] + }, + module: { + rules: [{ + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/ + }] + }, + optimization: { + minimizer: [ + new TerserPlugin({ + include: /\.min\.js$/ + }) + ] + } +}; + +module.exports = [ + // Web-compatible + defaultsDeep({}, baseConfig, { + target: 'web', + output: { + libraryTarget: 'umd', + path: path.resolve(__dirname, 'dist', 'web') + } + }), + // Node-compatible + defaultsDeep({}, baseConfig, { + target: 'node', + output: { + libraryTarget: 'commonjs2', + path: path.resolve(__dirname, 'dist', 'node') + } + }) +]; diff --git a/yarn.lock b/yarn.lock index 6b0579e17..a23b5d6ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2841,11 +2841,23 @@ dependencies: eslint-visitor-keys "^3.4.3" +"@eslint-community/eslint-utils@^4.8.0": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" + integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== + dependencies: + eslint-visitor-keys "^3.4.3" + "@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.6.1": version "4.12.1" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== +"@eslint-community/regexpp@^4.12.2": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + "@eslint/config-array@^0.19.2": version "0.19.2" resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.19.2.tgz#3060b809e111abfc97adb0bb1172778b90cb46aa" @@ -2855,6 +2867,22 @@ debug "^4.3.1" minimatch "^3.1.2" +"@eslint/config-array@^0.23.2": + version "0.23.2" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.2.tgz#db85beeff7facc685a5775caacb1c845669b9470" + integrity sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A== + dependencies: + "@eslint/object-schema" "^3.0.2" + debug "^4.3.1" + minimatch "^10.2.1" + +"@eslint/config-helpers@^0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.2.tgz#314c7b03d02a371ad8c0a7f6821d5a8a8437ba9d" + integrity sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ== + dependencies: + "@eslint/core" "^1.1.0" + "@eslint/core@^0.12.0": version "0.12.0" resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.12.0.tgz#5f960c3d57728be9f6c65bd84aa6aa613078798e" @@ -2862,6 +2890,13 @@ dependencies: "@types/json-schema" "^7.0.15" +"@eslint/core@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.0.tgz#51f5cd970e216fbdae6721ac84491f57f965836d" + integrity sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw== + dependencies: + "@types/json-schema" "^7.0.15" + "@eslint/eslintrc@^0.4.3": version "0.4.3" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c" @@ -2942,6 +2977,11 @@ resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f" integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA== +"@eslint/object-schema@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.2.tgz#c59c6a94aa4b428ed7f1615b6a4495c0a21f7a22" + integrity sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw== + "@eslint/plugin-kit@^0.2.7": version "0.2.7" resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz#9901d52c136fb8f375906a73dcc382646c3b6a27" @@ -2950,6 +2990,14 @@ "@eslint/core" "^0.12.0" levn "^0.4.1" +"@eslint/plugin-kit@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz#e0cb12ec66719cb2211ad36499fb516f2a63899d" + integrity sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ== + dependencies: + "@eslint/core" "^1.1.0" + levn "^0.4.1" + "@gar/promisify@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -5227,6 +5275,11 @@ "@types/estree" "*" "@types/json-schema" "*" +"@types/esrecurse@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" + integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + "@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.5", "@types/estree@^1.0.6": version "1.0.6" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" @@ -5237,6 +5290,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/estree@^1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + "@types/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0": version "5.0.6" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz#41fec4ea20e9c7b22f024ab88a95c6bb288f51b8" @@ -6132,6 +6190,11 @@ acorn-import-attributes@^1.9.5: resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== +acorn-import-phases@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7" + integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ== + acorn-jsx@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b" @@ -6186,6 +6249,11 @@ acorn@^8.11.0, acorn@^8.14.0, acorn@^8.4.1, acorn@^8.7.1, acorn@^8.8.2, acorn@^8 resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== +acorn@^8.16.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== + adm-zip@0.4.11: version "0.4.11" resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.11.tgz#2aa54c84c4b01a9d0fb89bb11982a51f13e3d62a" @@ -6330,6 +6398,16 @@ ajv@^6.0.1, ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.12.4, ajv@ json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^6.14.0: + version "6.14.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" + integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + ajv@^8.0.0, ajv@^8.0.1, ajv@^8.11.0, ajv@^8.6.0, ajv@^8.9.0: version "8.17.1" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" @@ -7845,6 +7923,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + base64-js@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978" @@ -7873,6 +7956,11 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" +baseline-browser-mapping@^2.9.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz#5b09935025bf8a80e29130251e337c6a7fc8cbb9" + integrity sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA== + batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -8069,6 +8157,13 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" +brace-expansion@^5.0.2: + version "5.0.4" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.4.tgz#614daaecd0a688f660bbbc909a8748c3d80d4336" + integrity sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg== + dependencies: + balanced-match "^4.0.2" + braces@^1.8.2: version "1.8.5" resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" @@ -8209,6 +8304,17 @@ browserslist@^4.12.0, browserslist@^4.21.10, browserslist@^4.24.0, browserslist@ node-releases "^2.0.19" update-browserslist-db "^1.1.1" +browserslist@^4.28.1: + version "4.28.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" + integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== + dependencies: + baseline-browser-mapping "^2.9.0" + caniuse-lite "^1.0.30001759" + electron-to-chromium "^1.5.263" + node-releases "^2.0.27" + update-browserslist-db "^1.2.0" + bs-logger@^0.2.6: version "0.2.6" resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" @@ -8598,6 +8704,11 @@ caniuse-lite@^1.0.30000844, caniuse-lite@^1.0.30001020, caniuse-lite@^1.0.300011 resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz#26cd429cf09b4fd4e745daf4916039c794d720f6" integrity sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ== +caniuse-lite@^1.0.30001759: + version "1.0.30001776" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz#3c64d006348a2e92037aa4302345129806a42d24" + integrity sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw== + canvas-toBlob@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/canvas-toBlob/-/canvas-toBlob-1.0.0.tgz#9bf32b286bb4e125218b208eecc8321fd033e6c3" @@ -10858,6 +10969,11 @@ electron-to-chromium@^1.3.47, electron-to-chromium@^1.5.73: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.104.tgz#e92a1ec54f279d8fc60eb7e8cf6add9631631f38" integrity sha512-Us9M2L4cO/zMBqVkJtnj353nQhMju9slHm62NprKTmdF3HH8wYOtNvDFq/JB2+ZRoGLzdvYDiATlMHs98XBM1g== +electron-to-chromium@^1.5.263: + version "1.5.307" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz#09f8973100c39fb0d003b890393cd1d58932b1c8" + integrity sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg== + elliptic@^6.5.3, elliptic@^6.5.5: version "6.6.1" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.6.1.tgz#3b8ffb02670bf69e382c7f65bf524c97c5405c06" @@ -10938,6 +11054,14 @@ enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.0, enhanced-resolve@^5.17.1: graceful-fs "^4.2.4" tapable "^2.2.0" +enhanced-resolve@^5.19.0: + version "5.20.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz#323c2a70d2aa7fb4bdfd6d3c24dfc705c581295d" + integrity sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.3.0" + enhanced-resolve@~0.9.0: version "0.9.1" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz#4d6e689b3725f86090927ccc86cd9f1635b89e2e" @@ -11229,6 +11353,11 @@ es-module-lexer@^1.2.1, es-module-lexer@^1.5.3: resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.6.0.tgz#da49f587fd9e68ee2404fe4e256c0c7d3a81be21" integrity sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ== +es-module-lexer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.0.0.tgz#f657cd7a9448dcdda9c070a3cb75e5dc1e85f5b1" + integrity sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw== + es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" @@ -11626,6 +11755,16 @@ eslint-scope@^8.2.0: esrecurse "^4.3.0" estraverse "^5.2.0" +eslint-scope@^9.1.1: + version "9.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.1.tgz#f6a209486e38bd28356b5feb07d445cc99c89967" + integrity sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw== + dependencies: + "@types/esrecurse" "^4.3.1" + "@types/estree" "^1.0.8" + esrecurse "^4.3.0" + estraverse "^5.2.0" + eslint-utils@^1.3.1: version "1.4.3" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f" @@ -11667,6 +11806,11 @@ eslint-visitor-keys@^4.2.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz#687bacb2af884fcdda8a6e7d65c606f46a14cd45" integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw== +eslint-visitor-keys@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" + integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + eslint@3.19.0, eslint@^3.19.0: version "3.19.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-3.19.0.tgz#c8fc6201c7f40dd08941b87c085767386a679acc" @@ -12016,6 +12160,42 @@ eslint@8.57.1: strip-ansi "^6.0.1" text-table "^0.2.0" +eslint@^10.0.2: + version "10.0.2" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.0.2.tgz#1009263467591810320f2e1ad52b8a750d1acbab" + integrity sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.2" + "@eslint/config-array" "^0.23.2" + "@eslint/config-helpers" "^0.5.2" + "@eslint/core" "^1.1.0" + "@eslint/plugin-kit" "^0.6.0" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + ajv "^6.14.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^9.1.1" + eslint-visitor-keys "^5.0.1" + espree "^11.1.1" + esquery "^1.7.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + minimatch "^10.2.1" + natural-compare "^1.4.0" + optionator "^0.9.3" + eslint@^4.19.1: version "4.19.1" resolved "https://registry.yarnpkg.com/eslint/-/eslint-4.19.1.tgz#32d1d653e1d90408854bfb296f076ec7e186a300" @@ -12124,6 +12304,15 @@ espree@^10.0.1, espree@^10.1.0, espree@^10.3.0: acorn-jsx "^5.3.2" eslint-visitor-keys "^4.2.0" +espree@^11.1.1: + version "11.1.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-11.1.1.tgz#866f6bc9ccccd6f28876b7a6463abb281b9cb847" + integrity sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ== + dependencies: + acorn "^8.16.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^5.0.1" + espree@^3.4.0, espree@^3.5.4: version "3.5.4" resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.4.tgz#b0f447187c8a8bed944b815a660bddf5deb5d1a7" @@ -12185,6 +12374,13 @@ esquery@^1.0.0, esquery@^1.0.1, esquery@^1.4.0, esquery@^1.4.2, esquery@^1.5.0, dependencies: estraverse "^5.1.0" +esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + esrecurse@^4.1.0, esrecurse@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" @@ -18269,6 +18465,11 @@ loader-runner@^4.2.0: resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== +loader-runner@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.1.tgz#6c76ed29b0ccce9af379208299f07f876de737e3" + integrity sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q== + loader-utils@^1.1.0: version "1.4.2" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.2.tgz#29a957f3a63973883eb684f10ffd3d151fec01a3" @@ -19233,6 +19434,13 @@ minimatch@^10.0.0: dependencies: brace-expansion "^2.0.1" +minimatch@^10.2.1: + version "10.2.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde" + integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg== + dependencies: + brace-expansion "^5.0.2" + minimatch@^5.0.1, minimatch@^5.1.0: version "5.1.6" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" @@ -19813,6 +20021,11 @@ node-releases@^2.0.19: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== +node-releases@^2.0.27: + version "2.0.27" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" + integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== + nomnom@^1.5.x: version "1.8.1" resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.8.1.tgz#2151f722472ba79e50a76fc125bb8c8f2e4dc2a7" @@ -23443,6 +23656,16 @@ schema-utils@^4.0.0, schema-utils@^4.2.0, schema-utils@^4.3.0: ajv-formats "^2.1.1" ajv-keywords "^5.1.0" +schema-utils@^4.3.3: + version "4.3.3" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.3.tgz#5b1850912fa31df90716963d45d9121fdfc09f46" + integrity sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA== + dependencies: + "@types/json-schema" "^7.0.9" + ajv "^8.9.0" + ajv-formats "^2.1.1" + ajv-keywords "^5.1.0" + scratch-render-fonts@1.0.0-prerelease.20221024190656: version "1.0.0-prerelease.20221024190656" resolved "https://registry.yarnpkg.com/scratch-render-fonts/-/scratch-render-fonts-1.0.0-prerelease.20221024190656.tgz#fc0441924f199c6ca5274ba024bea733295452d6" @@ -24613,7 +24836,7 @@ string-length@^6.0.0: dependencies: strip-ansi "^7.1.0" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -24631,15 +24854,6 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -24778,7 +24992,7 @@ stringify-package@^1.0.0, stringify-package@^1.0.1: resolved "https://registry.yarnpkg.com/stringify-package/-/stringify-package-1.0.1.tgz#e5aa3643e7f74d0f28628b72f3dad5cecfc3ba85" integrity sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg== -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -24806,13 +25020,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -25162,6 +25369,11 @@ tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== +tapable@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" + integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== + tar-stream@^1.5.2: version "1.6.2" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" @@ -25290,6 +25502,17 @@ terser-webpack-plugin@^5.3.10, terser-webpack-plugin@^5.3.11: serialize-javascript "^6.0.2" terser "^5.31.1" +terser-webpack-plugin@^5.3.16: + version "5.3.16" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz#741e448cc3f93d8026ebe4f7ef9e4afacfd56330" + integrity sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q== + dependencies: + "@jridgewell/trace-mapping" "^0.3.25" + jest-worker "^27.4.5" + schema-utils "^4.3.0" + serialize-javascript "^6.0.2" + terser "^5.31.1" + terser@^5.10.0, terser@^5.17.4, terser@^5.31.1: version "5.39.0" resolved "https://registry.yarnpkg.com/terser/-/terser-5.39.0.tgz#0e82033ed57b3ddf1f96708d123cca717d86ca3a" @@ -25699,6 +25922,17 @@ ts-loader@^9.5.1, ts-loader@^9.5.2: semver "^7.3.4" source-map "^0.7.4" +ts-loader@^9.5.4: + version "9.5.4" + resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.5.4.tgz#44b571165c10fb5a90744aa5b7e119233c4f4585" + integrity sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ== + dependencies: + chalk "^4.1.0" + enhanced-resolve "^5.0.0" + micromatch "^4.0.0" + semver "^7.3.4" + source-map "^0.7.4" + ts-node@^10.8.1: version "10.9.2" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" @@ -25949,6 +26183,11 @@ typescript@5.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e" integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw== +typescript@^5.9.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + ua-parser-js@^0.7.30: version "0.7.40" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.40.tgz#c87d83b7bb25822ecfa6397a0da5903934ea1562" @@ -26194,6 +26433,14 @@ update-browserslist-db@^1.1.1: escalade "^3.2.0" picocolors "^1.1.1" +update-browserslist-db@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + update-notifier@^2.3.0, update-notifier@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6" @@ -26494,6 +26741,14 @@ watchpack@^2.4.1: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" +watchpack@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.5.1.tgz#dd38b601f669e0cbf567cb802e75cead82cde102" + integrity sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" + wav-encoder@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/wav-encoder/-/wav-encoder-1.3.0.tgz#ed1ca1e59c8d2bca50010d6a380ab8d473b12b65" @@ -26757,6 +27012,11 @@ webpack-sources@^3.2.3: resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== +webpack-sources@^3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891" + integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q== + webpack@5.93.0: version "5.93.0" resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.93.0.tgz#2e89ec7035579bdfba9760d26c63ac5c3462a5e5" @@ -26816,6 +27076,37 @@ webpack@5.96.1: watchpack "^2.4.1" webpack-sources "^3.2.3" +webpack@^5.105.3: + version "5.105.3" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.105.3.tgz#307ad95bafffd08bc81049d6519477b16e42e7ba" + integrity sha512-LLBBA4oLmT7sZdHiYE/PeVuifOxYyE2uL/V+9VQP7YSYdJU7bSf7H8bZRRxW8kEPMkmVjnrXmoR3oejIdX0xbg== + dependencies: + "@types/eslint-scope" "^3.7.7" + "@types/estree" "^1.0.8" + "@types/json-schema" "^7.0.15" + "@webassemblyjs/ast" "^1.14.1" + "@webassemblyjs/wasm-edit" "^1.14.1" + "@webassemblyjs/wasm-parser" "^1.14.1" + acorn "^8.16.0" + acorn-import-phases "^1.0.3" + browserslist "^4.28.1" + chrome-trace-event "^1.0.2" + enhanced-resolve "^5.19.0" + es-module-lexer "^2.0.0" + eslint-scope "5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.11" + json-parse-even-better-errors "^2.3.1" + loader-runner "^4.3.1" + mime-types "^2.1.27" + neo-async "^2.6.2" + schema-utils "^4.3.3" + tapable "^2.3.0" + terser-webpack-plugin "^5.3.16" + watchpack "^2.5.1" + webpack-sources "^3.3.4" + webpack@^5.75.0, webpack@^5.98.0: version "5.98.0" resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.98.0.tgz#44ae19a8f2ba97537978246072fb89d10d1fbd17" @@ -27243,7 +27534,7 @@ worker-farm@^1.3.1, worker-farm@^1.6.0, worker-farm@^1.7.0: dependencies: errno "~0.1.7" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -27278,15 +27569,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From d8e9b51115ed2f9ee934f0aa7f43d87f14282094 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Thu, 5 Mar 2026 20:57:17 +0800 Subject: [PATCH 02/36] :sparkles: chore: copy extension types for scratch extension Signed-off-by: Alex Cui --- .../adapter/scratch/types/argument-type.ts | 52 +++++++++ .../src/adapter/scratch/types/block-type.ts | 55 ++++++++++ .../scratch/types/extension-metadata.ts | 102 ++++++++++++++++++ .../adapter/scratch/types/reporter-scope.ts | 23 ++++ .../src/adapter/scratch/types/target-type.ts | 22 ++++ 5 files changed, 254 insertions(+) create mode 100644 packages/extension/src/adapter/scratch/types/argument-type.ts create mode 100644 packages/extension/src/adapter/scratch/types/block-type.ts create mode 100644 packages/extension/src/adapter/scratch/types/extension-metadata.ts create mode 100644 packages/extension/src/adapter/scratch/types/reporter-scope.ts create mode 100644 packages/extension/src/adapter/scratch/types/target-type.ts diff --git a/packages/extension/src/adapter/scratch/types/argument-type.ts b/packages/extension/src/adapter/scratch/types/argument-type.ts new file mode 100644 index 000000000..445083c9a --- /dev/null +++ b/packages/extension/src/adapter/scratch/types/argument-type.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2017 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * Block argument types + */ +enum ArgumentType { + /** + * Numeric value with angle picker + */ + ANGLE = 'angle', + + /** + * Boolean value with hexagonal placeholder + */ + BOOLEAN = 'Boolean', + + /** + * Numeric value with color picker + */ + COLOR = 'color', + + /** + * Numeric value with text field + */ + NUMBER = 'number', + + /** + * String value with text field + */ + STRING = 'string', + + /** + * String value with matrix field + */ + MATRIX = 'matrix', + + /** + * MIDI note number with note picker (piano) field + */ + NOTE = 'note', + + /** + * Inline image on block (as part of the label) + */ + IMAGE = 'image' +} + +export default ArgumentType; diff --git a/packages/extension/src/adapter/scratch/types/block-type.ts b/packages/extension/src/adapter/scratch/types/block-type.ts new file mode 100644 index 000000000..ed93317c1 --- /dev/null +++ b/packages/extension/src/adapter/scratch/types/block-type.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2017 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * Types of block + */ +enum BlockType { + /** + * Boolean reporter with hexagonal shape + */ + BOOLEAN = 'Boolean', + + /** + * A button (not an actual block) for some special action, like making a variable + */ + BUTTON = 'button', + + /** + * Command block + */ + COMMAND = 'command', + + /** + * Specialized command block which may or may not run a child branch + * The thread continues with the next block whether or not a child branch ran. + */ + CONDITIONAL = 'conditional', + + /** + * Specialized hat block with no implementation function + * This stack only runs if the corresponding event is emitted by other code. + */ + EVENT = 'event', + + /** + * Hat block which conditionally starts a block stack + */ + HAT = 'hat', + + /** + * Specialized command block which may or may not run a child branch + * If a child branch runs, the thread evaluates the loop block again. + */ + LOOP = 'loop', + + /** + * General reporter with numeric or string value + */ + REPORTER = 'reporter' +} + +export default BlockType; diff --git a/packages/extension/src/adapter/scratch/types/extension-metadata.ts b/packages/extension/src/adapter/scratch/types/extension-metadata.ts new file mode 100644 index 000000000..586300124 --- /dev/null +++ b/packages/extension/src/adapter/scratch/types/extension-metadata.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2018 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ + +import type ArgumentType from './argument-type'; +import type BlockType from './block-type'; +import type ReporterScope from './reporter-scope'; +import type TargetType from './target-type'; + +/** + * All the metadata needed to register an extension. + */ +export interface ExtensionMetadata { + /** A unique alphanumeric identifier for this extension. No special characters allowed. */ + id: string; + /** The human-readable name of this extension. */ + name?: string; + /** URI for an image to be placed on each block in this extension. Data URI ok. */ + blockIconURI?: string; + /** URI for an image to be placed on this extension's category menu item. Data URI ok. */ + menuIconURI?: string; + /** Link to documentation content for this extension. */ + docsURI?: string; + /** The blocks provided by this extension, plus separators. */ + blocks: Array; + /** Map of menu name to metadata for each of this extension's menus. */ + menus?: Record; +} + +/** + * All the metadata needed to register an extension block. + */ +export interface ExtensionBlockMetadata { + /** A unique alphanumeric identifier for this block. No special characters allowed. */ + opcode: string; + /** The name of the function implementing this block. Can be shared by other blocks/opcodes. */ + func?: string; + /** The type of block (command, reporter, etc.) being described. */ + blockType: BlockType; + /** The text on the block, with [PLACEHOLDERS] for arguments. */ + text: string; + /** True if this block should not appear in the block palette. */ + hideFromPalette?: boolean; + /** True if the block ends a stack - no blocks can be connected after it. */ + isTerminal?: boolean; + /** True if this block is a reporter but should not allow a monitor. */ + disableMonitor?: boolean; + /** If this block is a reporter, this is the scope/context for its value. */ + reporterScope?: ReporterScope; + /** Sets whether a hat block is edge-activated. */ + isEdgeActivated?: boolean; + /** Sets whether a hat/event block should restart existing threads. */ + shouldRestartExistingThreads?: boolean; + /** For flow control blocks, the number of branches/substacks for this block. */ + branchCount?: number; + /** Map of argument placeholder to metadata about each arg. */ + arguments?: Record; +} + +/** + * All the metadata needed to register an argument for an extension block. + */ +export interface ExtensionArgumentMetadata { + /** The type of the argument (number, string, etc.). */ + type: ArgumentType; + /** The default value of this argument. */ + defaultValue?: any; + /** The name of the menu to use for this argument, if any. */ + menu?: string; +} + +/** + * All the metadata needed to register an extension drop-down menu. + */ +export type ExtensionMenuMetadata = ExtensionDynamicMenu | ExtensionMenuItems; + +/** + * The string name of a function which returns menu items. + */ +export type ExtensionDynamicMenu = string; + +/** + * Items in an extension menu. + */ +export type ExtensionMenuItems = Array; + +/** + * A menu item for which the label and value are identical strings. + */ +export type ExtensionMenuItemSimple = string; + +/** + * A menu item for which the label and value can differ. + */ +export interface ExtensionMenuItemComplex { + /** The value of the block argument when this menu item is selected. */ + value: any; + /** The human-readable label of this menu item in the menu. */ + text: string; +} diff --git a/packages/extension/src/adapter/scratch/types/reporter-scope.ts b/packages/extension/src/adapter/scratch/types/reporter-scope.ts new file mode 100644 index 000000000..a8c53ee49 --- /dev/null +++ b/packages/extension/src/adapter/scratch/types/reporter-scope.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2018 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * Indicate the scope for a reporter's value. + */ +enum ReporterScope { + /** + * This reporter's value is global and does not depend on context. + */ + GLOBAL = 'global', + + /** + * This reporter's value is specific to a particular target/sprite. + * Another target may have a different value or may not even have a value. + */ + TARGET = 'target' +} + +export default ReporterScope; diff --git a/packages/extension/src/adapter/scratch/types/target-type.ts b/packages/extension/src/adapter/scratch/types/target-type.ts new file mode 100644 index 000000000..816174455 --- /dev/null +++ b/packages/extension/src/adapter/scratch/types/target-type.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2018 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * Default types of Target supported by the VM. + */ +enum TargetType { + /** + * Rendered target which can move, change costumes, etc. + */ + SPRITE = 'sprite', + + /** + * Rendered target which cannot move but can change backdrops. + */ + STAGE = 'stage' +} + +export default TargetType; From e2c26954866cb3f138e1c0dcdbbf21e8f3e02c5c Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Sat, 7 Mar 2026 19:57:24 +0800 Subject: [PATCH 03/36] :page_facing_up: chore(ext): re-licensing with MPL-2.0 Signed-off-by: Alex Cui --- packages/extension/LICENSE | 394 +++++++++++++++++- packages/extension/README.md | 11 + packages/extension/package.json | 11 +- packages/extension/src/extension-manager.ts | 2 +- packages/extension/src/index.ts | 2 +- .../extension/src/interfaces/i_extension.ts | 2 +- 6 files changed, 394 insertions(+), 28 deletions(-) create mode 100644 packages/extension/README.md diff --git a/packages/extension/LICENSE b/packages/extension/LICENSE index 578564142..a612ad981 100644 --- a/packages/extension/LICENSE +++ b/packages/extension/LICENSE @@ -1,21 +1,373 @@ -MIT License - -Copyright (c) 2026 Clip Team - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/packages/extension/README.md b/packages/extension/README.md new file mode 100644 index 000000000..691240152 --- /dev/null +++ b/packages/extension/README.md @@ -0,0 +1,11 @@ +# ClipCC Extension + +Extension manager and interfaces for ClipCC. + +## License + +All codes except the files in the following table are licensed in MPL-2.0. + +|Files|Description|License| +|---|---|---| +|src/adapter/scratch|Adapter for loading extensions of original Scratch, ported from scratch-vm.|BSD-3-Clause| diff --git a/packages/extension/package.json b/packages/extension/package.json index 461438200..88e97d7d5 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -3,9 +3,10 @@ "version": "3.2.0", "description": "Extension manager and interfaces for ClipCC", "author": "Clip Team", - "license": "MIT", - "main": "./dist/clipcc-extension.js", - "types": "./dist/clipcc-extension.d.ts", + "license": "MPL-2.0", + "main": "./dist/node/clipcc-extension.js", + "browser": "./dist/web/clipcc-extension.js", + "types": "./dist/types/index.d.ts", "repository": "https://github.com/Clipteam/clipcc.git", "scripts": { "build": "rimraf dist && mkdirp dist && webpack --progress --color --bail", @@ -21,6 +22,8 @@ "webpack-cli": "^6.0.1" }, "dependencies": { - "eslint-config-clipcc": "^9.0.9" + "eslint-config-clipcc": "^9.0.9", + "format-message": "^6.2.4", + "tslog": "^4.10.2" } } diff --git a/packages/extension/src/extension-manager.ts b/packages/extension/src/extension-manager.ts index 46c24df9e..aaae40d44 100644 --- a/packages/extension/src/extension-manager.ts +++ b/packages/extension/src/extension-manager.ts @@ -1,7 +1,7 @@ /** * @license * Copyright 2026 Clip Team - * SPDX-License-Identifier: MIT + * SPDX-License-Identifier: MPL-2.0 */ import {EventEmitter} from 'events'; diff --git a/packages/extension/src/index.ts b/packages/extension/src/index.ts index d7925fb13..c50febd47 100644 --- a/packages/extension/src/index.ts +++ b/packages/extension/src/index.ts @@ -1,7 +1,7 @@ /** * @license * Copyright 2026 Clip Team - * SPDX-License-Identifier: MIT + * SPDX-License-Identifier: MPL-2.0 */ export {ExtensionManager} from './extension-manager'; diff --git a/packages/extension/src/interfaces/i_extension.ts b/packages/extension/src/interfaces/i_extension.ts index 1bd13e493..20f868bf6 100644 --- a/packages/extension/src/interfaces/i_extension.ts +++ b/packages/extension/src/interfaces/i_extension.ts @@ -1,7 +1,7 @@ /** * @license * Copyright 2026 Clip Team - * SPDX-License-Identifier: MIT + * SPDX-License-Identifier: MPL-2.0 */ /** From 3d1b35ab1c8d8473b161859840702edc652d6135 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Sat, 7 Mar 2026 20:15:56 +0800 Subject: [PATCH 04/36] :sparkles: feat(ext): adapter for scratch extensions Signed-off-by: Alex Cui --- .../extension/src/adapter/scratch/adapter.ts | 340 ++++++++++++++++++ .../scratch/types/extension-metadata.ts | 18 +- .../src/adapter/scratch/types/manifest.ts | 30 ++ packages/extension/src/extension-manager.ts | 16 +- packages/extension/src/index.ts | 2 + .../extension/src/interfaces/i_extension.ts | 10 + packages/extension/src/utils/logger.ts | 13 + packages/extension/tsconfig.json | 4 +- yarn.lock | 5 + 9 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 packages/extension/src/adapter/scratch/adapter.ts create mode 100644 packages/extension/src/adapter/scratch/types/manifest.ts create mode 100644 packages/extension/src/utils/logger.ts diff --git a/packages/extension/src/adapter/scratch/adapter.ts b/packages/extension/src/adapter/scratch/adapter.ts new file mode 100644 index 000000000..4cc7a5610 --- /dev/null +++ b/packages/extension/src/adapter/scratch/adapter.ts @@ -0,0 +1,340 @@ +/** + * @license + * Copyright 2017 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ + +import formatMessage from 'format-message'; +import type {IExtension} from '../../interfaces/i_extension'; +import logger from '../../utils/logger'; +import BlockType from './types/block-type'; +import { + isSimpleMenuMetadata, + type ExtensionBlockMetadata, + type ExtensionMenuItems, + type ExtensionMenuMetadata, + type ExtensionMetadata +} from './types/extension-metadata'; +import type ExtensionManifest from './types/manifest'; +import type ArgumentType from './types/argument-type'; + +interface ScratchExtension { + /** + * Get metadata of the extension. + * @returns Metadata for this extension and its blocks. + */ + getInfo(): ExtensionMetadata; + + /** Other methods and properties. */ + [key: string]: unknown; +} + +type ScratchExtensionClass = new (runtime: any) => ScratchExtension; + +/** + * Information about an extension block argument. + */ +interface ArgumentInfo { + /** The type of value this argument can take. */ + type: ArgumentType; + /** The default value of this argument (default: blank). */ + default?: any; +} + +/** + * Raw extension block data paired with processed data ready for scratch-blocks. + */ +interface ConvertedBlockInfo { + /** The raw block info. */ + info: ExtensionBlockMetadata; + /** The scratch-blocks JSON definition for this block. */ + json: object; + /** The scratch-blocks XML definition for this block. */ + xml: string; +} + +/** + * Information about a block category. + */ +interface CategoryInfo { + /** The unique ID of this category. */ + id: string; + /** The human-readable name of this category. */ + name: string; + /** Optional URI for the block icon image. */ + blockIconURI?: string; + /** The primary color for this category, in '#rrggbb' format. */ + color1: string; + /** The secondary color for this category, in '#rrggbb' format. */ + color2: string; + /** The tertiary color for this category, in '#rrggbb' format. */ + color3: string; + /** The blocks, separators, etc. in this category. */ + blocks: ConvertedBlockInfo[]; + /** The menus provided by this category. */ + menus: any[]; +} + +/** + * Check if `maybeMessage` looks like a message object, and if so pass it to `formatMessage`. + * Otherwise, return `maybeMessage` as-is. + * @param maybeMessage Something that might be a message descriptor object. + * @param args The arguments to pass to `formatMessage` if it gets called. + * @param locale The locale to pass to `formatMessage` if it gets called. + * @returns The formatted message OR the original `maybeMessage` input. + */ +function maybeFormatMessage(maybeMessage: any, args?: object, locale?: string): any { + if (maybeMessage && maybeMessage.id && maybeMessage.default) { + return formatMessage(maybeMessage, args, locale); + } + return maybeMessage; +} + +/** + * Adapter to load scratch extension. + */ +export class ScratchExtensionAdapter implements IExtension { + /** Whether the extension is enabled. */ + private enabled: boolean = false; + + private instance: ScratchExtension | null = null; + + constructor( + private manifest: ExtensionManifest, + private extensionModule: () => ScratchExtensionClass, + private runtime: any + ) {} + + /** + * Get ID of the extension. + * @returns ID of the extension. + */ + getId(): string { + return this.manifest.extensionId; + } + + /** + * Check whether the extension is enabled. + * @returns True if the extension is enabled. + */ + isEnabled(): boolean { + return this.enabled; + } + + /** + * Enable the extension. + */ + enable(): void { + const ExtensionClass = this.extensionModule(); + this.instance = new ExtensionClass(this.runtime); + try { + const info = this.prepareExtensionInfo(this.instance.getInfo()); + this.runtime._registerExtensionPrimitives(info); + } catch (e) { + logger.error(`Failed to register primitives for extension ${this.getId()}:`, e); + } + + this.enabled = true; + } + + /** + * Disable the extension. + */ + disable(): void { + // @todo support disable extension. + this.enabled = false; + } + + /** + * Get toolbox content for Blockly. + */ + refreshPrimitives(): void { + // @todo test only, should be replaced later. + try { + const info = this.instance!.getInfo(); + this.runtime._refreshExtensionPrimitives(this.prepareExtensionInfo(info)); + } catch (e) { + logger.error(`Failed to refresh built-in extension primitives: ${e}`); + } + } + + private callExtensionMethod(method: string, ...args: any[]): any { + if (this.instance && method in this.instance && typeof this.instance[method] === 'function') { + return this.instance[method](args); + } + + logger.warn(`Could not find extension block function called ${method}`); + return undefined; + } + + /// Methods from scratch-vm/src/extension-support/extension-manager.js + + /** + * Modify the provided text as necessary to ensure that it may be used as an attribute value in valid XML. + * @param text The text to be sanitized. + * @returns The sanitized text. + */ + private sanitizeID(text: string): string { + return text.toString().replace(/[<"&]/, '_'); + } + + /** + * Apply minor cleanup and defaults for optional extension fields. + * TODO: make the ID unique in cases where two copies of the same extension are loaded. + * @param extensionInfo The extension info to be sanitized. + * @returns A new extension info object with cleaned-up values. + */ + private prepareExtensionInfo(extensionInfo: ExtensionMetadata): ExtensionMetadata { + extensionInfo = Object.assign({}, extensionInfo); + if (!/^[a-z0-9]+$/i.test(extensionInfo.id)) { + throw new Error('Invalid extension id'); + } + extensionInfo.name = extensionInfo.name || extensionInfo.id; + extensionInfo.blocks = extensionInfo.blocks || []; + extensionInfo.targetTypes = extensionInfo.targetTypes || []; + extensionInfo.blocks = extensionInfo.blocks.reduce((results, blockInfo) => { + try { + let result; + switch (blockInfo) { + case '---': // separator + result = '---'; + break; + default: // an ExtensionBlockMetadata object + result = this.prepareBlockInfo(blockInfo as ExtensionBlockMetadata); + break; + } + results.push(result); + } catch (e) { + // TODO: more meaningful error reporting + logger.error(`Error processing block: ${(e as Error).message}, Block:\n${JSON.stringify(blockInfo)}`); + } + return results; + }, []); + extensionInfo.menus = extensionInfo.menus || {}; + extensionInfo.menus = this.prepareMenuInfo(extensionInfo.menus); + return extensionInfo; + } + + /** + * Prepare extension menus. e.g. setup binding for dynamic menu functions. + * @param menus The menu defined by the extension. + * @returns A menuInfo object with all preprocessing done. + */ + private prepareMenuInfo( + menus: Record + ): Record { + const menuNames = Object.getOwnPropertyNames(menus); + for (const menuName of menuNames) { + let menuInfo = menus[menuName]; + + // If the menu description is in short form (items only) then normalize it to general form: an object with + // its items listed in an `items` property. + if (isSimpleMenuMetadata(menuInfo)) { + menuInfo = { + items: menuInfo + }; + menus[menuName] = menuInfo; + } + + // If `items` is a string, it should be the name of a function in the extension object. Calling the + // function should return an array of items to populate the menu when it is opened. + if (typeof menuInfo.items === 'string') { + const menuItemFunctionName = menuInfo.items; + // Bind the function here so we can pass a simple item generation function to Scratch Blocks later. + menuInfo.items = this.getExtensionMenuItems.bind(this, menuItemFunctionName); + } + } + return menus; + } + + /** + * Fetch the items for a particular extension menu, providing the target ID for context. + * @param menuItemFunctionName The name of the menu function to call. + * @returns Menu items ready for scratch-blocks. + */ + private getExtensionMenuItems( + menuItemFunctionName: string + ): [string, string][] { + // Fetch the items appropriate for the target currently being edited. This assumes that menus only + // collect items when opened by the user while editing a particular target. + const editingTarget = this.runtime.getEditingTarget() || this.runtime.getTargetForStage(); + const editingTargetID = editingTarget ? editingTarget.id : null; + const extensionMessageContext = this.runtime.makeMessageContextForTarget(editingTarget); + + // TODO: Fix this to use dispatch.call when extensions are running in workers. + const menuFunc = this.instance![menuItemFunctionName] as (...args: any) => ExtensionMenuItems; + const menuItems = menuFunc.call(this.instance, editingTargetID).map<[string, string]>( + (item) => { + item = maybeFormatMessage(item, extensionMessageContext); + switch (typeof item) { + case 'object': + return [ + maybeFormatMessage(item.text, extensionMessageContext), + item.value + ]; + case 'string': + return [item, item]; + default: + return item; + } + }); + + if (!menuItems || menuItems.length < 1) { + throw new Error(`Extension menu returned no items: ${menuItemFunctionName}`); + } + return menuItems; + } + + /** + * Apply defaults for optional block fields. + * @param blockInfo The block info from the extension. + * @returns A new block info object which has values for all relevant optional fields. + * @private + */ + private prepareBlockInfo(blockInfo: ExtensionBlockMetadata): ExtensionBlockMetadata { + blockInfo = Object.assign({}, { + blockType: BlockType.COMMAND, + terminal: false, + blockAllThreads: false, + arguments: {} + }, blockInfo); + blockInfo.opcode = blockInfo.opcode && this.sanitizeID(blockInfo.opcode); + blockInfo.text = blockInfo.text || blockInfo.opcode; + + switch (blockInfo.blockType) { + case BlockType.EVENT: + if (blockInfo.func) { + logger.warn(`Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}`); + } + break; + case BlockType.BUTTON: + if (blockInfo.opcode) { + logger.warn(`Ignoring opcode "${blockInfo.opcode}" for button with text: ${blockInfo.text}`); + } + break; + default: { + if (!blockInfo.opcode) { + throw new Error('Missing opcode for block'); + } + + const funcName = blockInfo.func ? this.sanitizeID(blockInfo.func as string) : blockInfo.opcode; + + const getBlockInfo = blockInfo.isDynamic ? + (args: Record) => args && args.mutation && args.mutation.blockInfo : + () => blockInfo; + const callBlockFunc = (args: Record, util: any, realBlockInfo: ExtensionBlockMetadata) => { + return this.callExtensionMethod(funcName, args, util, realBlockInfo); + }; + + blockInfo.func = (args: Record, util: any) => { + const realBlockInfo = getBlockInfo(args); + // TODO: filter args using the keys of realBlockInfo.arguments? maybe only if sandboxed? + return callBlockFunc(args, util, realBlockInfo); + }; + break; + } + } + + return blockInfo; + } +} diff --git a/packages/extension/src/adapter/scratch/types/extension-metadata.ts b/packages/extension/src/adapter/scratch/types/extension-metadata.ts index 586300124..520cdd1b1 100644 --- a/packages/extension/src/adapter/scratch/types/extension-metadata.ts +++ b/packages/extension/src/adapter/scratch/types/extension-metadata.ts @@ -27,6 +27,8 @@ export interface ExtensionMetadata { blocks: Array; /** Map of menu name to metadata for each of this extension's menus. */ menus?: Record; + /** List new target type(s) provided by this extension. */ + targetTypes?: string[]; } /** @@ -36,7 +38,7 @@ export interface ExtensionBlockMetadata { /** A unique alphanumeric identifier for this block. No special characters allowed. */ opcode: string; /** The name of the function implementing this block. Can be shared by other blocks/opcodes. */ - func?: string; + func?: string | ((...args: any) => any); /** The type of block (command, reporter, etc.) being described. */ blockType: BlockType; /** The text on the block, with [PLACEHOLDERS] for arguments. */ @@ -57,6 +59,8 @@ export interface ExtensionBlockMetadata { branchCount?: number; /** Map of argument placeholder to metadata about each arg. */ arguments?: Record; + /** True if creating a block factory / constructor. */ + isDynamic?: boolean; } /** @@ -74,7 +78,10 @@ export interface ExtensionArgumentMetadata { /** * All the metadata needed to register an extension drop-down menu. */ -export type ExtensionMenuMetadata = ExtensionDynamicMenu | ExtensionMenuItems; +export type ExtensionMenuMetadata = { + acceptReporters?: boolean; + items: ExtensionDynamicMenu | ExtensionMenuItems | (() => [string, string][]); +} | ExtensionDynamicMenu | ExtensionMenuItems; /** * The string name of a function which returns menu items. @@ -100,3 +107,10 @@ export interface ExtensionMenuItemComplex { /** The human-readable label of this menu item in the menu. */ text: string; } + +/** + * Check if the menu description is in short form (items only). + */ +export function isSimpleMenuMetadata(menu: ExtensionMenuMetadata): menu is ExtensionDynamicMenu | ExtensionMenuItems { + return !(menu as any).items; +} diff --git a/packages/extension/src/adapter/scratch/types/manifest.ts b/packages/extension/src/adapter/scratch/types/manifest.ts new file mode 100644 index 000000000..4cc9196a8 --- /dev/null +++ b/packages/extension/src/adapter/scratch/types/manifest.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +/** + * All metadata needed for extension to be shown in gui. + */ +interface ExtensionManifest { + name: string; + extensionId: string; + collaborator?: string; + iconURL: string; + insetIconURL: string; + description: string; + featured: boolean; + disabled?: boolean; + bluetoothRequired?: boolean; + internetConnectionRequired?: boolean; + launchPeripheralConnectionFlow?: boolean; + useAutoScan?: boolean; + connectionIconURL?: string; + connectionSmallIconURL?: string; + connectionTipIconURL?: string; + connectingMessage?: string; + helpLink?: string; +} + +export default ExtensionManifest; diff --git a/packages/extension/src/extension-manager.ts b/packages/extension/src/extension-manager.ts index aaae40d44..bc238afa9 100644 --- a/packages/extension/src/extension-manager.ts +++ b/packages/extension/src/extension-manager.ts @@ -6,6 +6,7 @@ import {EventEmitter} from 'events'; import {IExtension} from './interfaces/i_extension'; +import {ScratchExtensionAdapter} from './adapter/scratch/adapter'; /** * Class to manage all of the extensions. @@ -59,7 +60,7 @@ export class ExtensionManager { throw new Error(`Extension ${extensionId} is already enabled.`); } - // @TODO + extension.enable(); } /** @@ -77,7 +78,7 @@ export class ExtensionManager { throw new Error(`Extension ${extensionId} is not enabled.`); } - // @TODO + extension.disable(); } /** @@ -95,10 +96,19 @@ export class ExtensionManager { * @param extensionId ID of the extension. * @returns The extension object. */ - protected getExtensionById(extensionId: string): IExtension | null { + private getExtensionById(extensionId: string): IExtension | null { return this.loadedExtensions.get(extensionId) ?? null; } + refreshBlocks() { + // @todo test only, should be replaced later. + for (const extension of this.loadedExtensions.values()) { + if (!extension.isEnabled()) continue; + + (extension as ScratchExtensionAdapter).refreshPrimitives(); + } + } + /** * Add an event listener. * @param event Name of the event. diff --git a/packages/extension/src/index.ts b/packages/extension/src/index.ts index c50febd47..82b6fb7b6 100644 --- a/packages/extension/src/index.ts +++ b/packages/extension/src/index.ts @@ -4,5 +4,7 @@ * SPDX-License-Identifier: MPL-2.0 */ +export {ScratchExtensionAdapter} from './adapter/scratch/adapter'; + export {ExtensionManager} from './extension-manager'; export {IExtension} from './interfaces/i_extension'; diff --git a/packages/extension/src/interfaces/i_extension.ts b/packages/extension/src/interfaces/i_extension.ts index 20f868bf6..64c1691a7 100644 --- a/packages/extension/src/interfaces/i_extension.ts +++ b/packages/extension/src/interfaces/i_extension.ts @@ -19,4 +19,14 @@ export interface IExtension { * @returns True if the extension is enabled. */ isEnabled(): boolean; + + /** + * Enable the extension. + */ + enable(): void; + + /** + * Disable the extension. + */ + disable(): void; } diff --git a/packages/extension/src/utils/logger.ts b/packages/extension/src/utils/logger.ts new file mode 100644 index 000000000..d0f098c90 --- /dev/null +++ b/packages/extension/src/utils/logger.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +import {Logger} from 'tslog'; + +const logger = new Logger({ + name: 'clipcc-extension' +}); + +export default logger; diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index a62650f6b..1b9c5d425 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -28,9 +28,9 @@ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ - "module": "Node16", /* Specify what module code is generated. */ + "module": "es2022", /* Specify what module code is generated. */ // "rootDir": "./", /* Specify the root folder within your source files. */ - "moduleResolution": "node16", /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ diff --git a/yarn.lock b/yarn.lock index a23b5d6ca..0d04f21b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25989,6 +25989,11 @@ tslib@^2.0.0, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.6.2: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +tslog@^4.10.2: + version "4.10.2" + resolved "https://registry.yarnpkg.com/tslog/-/tslog-4.10.2.tgz#45c870d7fe9558d59ce288eeb86ba49eaaf25e1e" + integrity sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" From d1167ff24fe50787021a5b2de03ae8f02881788f Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Sat, 7 Mar 2026 20:16:27 +0800 Subject: [PATCH 05/36] :wrench: chore: webpack config for running clipcc-extension in gui Signed-off-by: Alex Cui --- packages/gui/package.json | 1 + packages/gui/webpack.config.js | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/gui/package.json b/packages/gui/package.json index c88a8b937..c207bcd0e 100644 --- a/packages/gui/package.json +++ b/packages/gui/package.json @@ -39,6 +39,7 @@ "classnames": "2.2.6", "clipcc-audio": "3.2.0", "clipcc-block": "3.2.0", + "clipcc-extension": "3.2.0", "clipcc-l10n": "3.2.0", "clipcc-paint": "3.2.0", "clipcc-render": "3.2.0", diff --git a/packages/gui/webpack.config.js b/packages/gui/webpack.config.js index d1663ee43..1aebbac48 100644 --- a/packages/gui/webpack.config.js +++ b/packages/gui/webpack.config.js @@ -31,7 +31,8 @@ const base = { 'clipcc-vm': path.resolve(__dirname, '../vm/src/index.js'), 'clipcc-block': path.resolve(__dirname, '../block/src/index.ts'), 'clipcc-render': path.resolve(__dirname, '../render/src/index.js'), - 'clipcc-audio': path.resolve(__dirname, '../audio/src/index.js') + 'clipcc-audio': path.resolve(__dirname, '../audio/src/index.js'), + 'clipcc-extension': path.resolve(__dirname, '../extension/src/index.ts') }, symlinks: false }, @@ -47,7 +48,8 @@ const base = { path.resolve(__dirname, '../vm/src'), path.resolve(__dirname, '../block/src'), path.resolve(__dirname, '../audio/src'), - path.resolve(__dirname, '../svg-renderer/src') + path.resolve(__dirname, '../svg-renderer/src'), + path.resolve(__dirname, '../extension/src') ], test: /\.([cm]?ts|tsx)$/, loader: 'ts-loader', From ab6362cb7265287de8da8093fe7f15ffc240954c Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Sun, 8 Mar 2026 17:31:11 +0800 Subject: [PATCH 06/36] :sparkles: feat(ext): new way to register primitives and blocks Signed-off-by: Alex Cui --- .../extension/src/adapter/scratch/adapter.ts | 217 ++++++++++++++++-- .../scratch/types/extension-metadata.ts | 2 + packages/extension/src/events.ts | 26 +++ packages/extension/src/extension-manager.ts | 26 ++- packages/extension/src/interfaces/common.ts | 8 + .../extension/src/interfaces/i_extension.ts | 17 ++ 6 files changed, 281 insertions(+), 15 deletions(-) create mode 100644 packages/extension/src/events.ts create mode 100644 packages/extension/src/interfaces/common.ts diff --git a/packages/extension/src/adapter/scratch/adapter.ts b/packages/extension/src/adapter/scratch/adapter.ts index 4cc7a5610..d58ecdf4b 100644 --- a/packages/extension/src/adapter/scratch/adapter.ts +++ b/packages/extension/src/adapter/scratch/adapter.ts @@ -17,6 +17,9 @@ import { } from './types/extension-metadata'; import type ExtensionManifest from './types/manifest'; import type ArgumentType from './types/argument-type'; +import type {ExtensionManager} from '../../extension-manager'; +import TargetType from './types/target-type'; +import {UpdateBlocksEvent, UpdatePrimitivesEvent} from '../../events'; interface ScratchExtension { /** @@ -48,7 +51,7 @@ interface ConvertedBlockInfo { /** The raw block info. */ info: ExtensionBlockMetadata; /** The scratch-blocks JSON definition for this block. */ - json: object; + json: Record; /** The scratch-blocks XML definition for this block. */ xml: string; } @@ -73,8 +76,15 @@ interface CategoryInfo { blocks: ConvertedBlockInfo[]; /** The menus provided by this category. */ menus: any[]; + + showStatusButton?: boolean; + menuIconURI?: string; + customFieldTypes?: any; + menuInfo?: Record; } +const DEFAULT_COLORS = ['#0FBD8C', '#0DA57A', '#0B8E69']; + /** * Check if `maybeMessage` looks like a message object, and if so pass it to `formatMessage`. * Otherwise, return `maybeMessage` as-is. @@ -94,9 +104,13 @@ function maybeFormatMessage(maybeMessage: any, args?: object, locale?: string): * Adapter to load scratch extension. */ export class ScratchExtensionAdapter implements IExtension { + /** Extension manager. */ + private manager: ExtensionManager | null = null; + /** Whether the extension is enabled. */ private enabled: boolean = false; + /** Instance of extension object. */ private instance: ScratchExtension | null = null; constructor( @@ -105,6 +119,16 @@ export class ScratchExtensionAdapter implements IExtension { private runtime: any ) {} + /** + * Attach the extension to given manager. + * The method will be called when loading the extension. + * @param manager Extension manager instance. + * @internal + */ + attachManager(manager: ExtensionManager): void { + this.manager = manager; + } + /** * Get ID of the extension. * @returns ID of the extension. @@ -127,14 +151,16 @@ export class ScratchExtensionAdapter implements IExtension { enable(): void { const ExtensionClass = this.extensionModule(); this.instance = new ExtensionClass(this.runtime); + this.enabled = true; + try { - const info = this.prepareExtensionInfo(this.instance.getInfo()); - this.runtime._registerExtensionPrimitives(info); + const extensionInfo = this.prepareExtensionInfo(this.instance.getInfo()); + const categoryInfo = this.buildCategoryInfo(extensionInfo); + this.registerExtensionPrimitives(extensionInfo, categoryInfo); + this.registerBlocks(categoryInfo); } catch (e) { logger.error(`Failed to register primitives for extension ${this.getId()}:`, e); } - - this.enabled = true; } /** @@ -147,15 +173,16 @@ export class ScratchExtensionAdapter implements IExtension { /** * Get toolbox content for Blockly. + * The method should only be called when extension is enabled. */ - refreshPrimitives(): void { - // @todo test only, should be replaced later. - try { - const info = this.instance!.getInfo(); - this.runtime._refreshExtensionPrimitives(this.prepareExtensionInfo(info)); - } catch (e) { - logger.error(`Failed to refresh built-in extension primitives: ${e}`); - } + getToolboxContents(isStage: boolean): any { + console.log(isStage); + const extensionInfo = this.prepareExtensionInfo(this.instance!.getInfo()); + const categoryInfo = this.buildCategoryInfo(extensionInfo); + return { + id: this.getId(), + xml: this.buildToolboxXML(categoryInfo, isStage) + }; } private callExtensionMethod(method: string, ...args: any[]): any { @@ -167,6 +194,170 @@ export class ScratchExtensionAdapter implements IExtension { return undefined; } + private buildCategoryInfo(extensionInfo: any): CategoryInfo { + const categoryInfo: CategoryInfo = { + id: extensionInfo.id, + name: maybeFormatMessage(extensionInfo.name), + showStatusButton: extensionInfo.showStatusButton, + blockIconURI: extensionInfo.blockIconURI, + menuIconURI: extensionInfo.menuIconURI, + color1: extensionInfo.color1 ? extensionInfo.color1 : DEFAULT_COLORS[0], + color2: extensionInfo.color1 ? extensionInfo.color2 : DEFAULT_COLORS[1], + color3: extensionInfo.color1 ? extensionInfo.color3 : DEFAULT_COLORS[2], + blocks: [], + menus: [], + customFieldTypes: {}, + menuInfo: {} + }; + + // Menus. + for (const menuName in extensionInfo.menus) { + if (Object.prototype.hasOwnProperty.call(extensionInfo.menus, menuName)) { + const menuInfo = extensionInfo.menus[menuName]; + const convertedMenu = this.runtime._buildMenuForScratchBlocks(menuName, menuInfo, categoryInfo); + categoryInfo.menus.push(convertedMenu); + categoryInfo.menuInfo![menuName] = menuInfo; + } + } + + // Custom field types. + for (const fieldTypeName in extensionInfo.customFieldTypes) { + if (Object.prototype.hasOwnProperty.call(extensionInfo.customFieldTypes, fieldTypeName)) { + const fieldType = extensionInfo.customFieldTypes[fieldTypeName]; + const fieldTypeInfo = this.runtime._buildCustomFieldInfo( + fieldTypeName, + fieldType, + extensionInfo.id, + categoryInfo + ); + + categoryInfo.customFieldTypes[fieldTypeName] = fieldTypeInfo; + } + } + + // Blocks. + for (const blockInfo of extensionInfo.blocks) { + try { + const convertedBlock = this.runtime._convertForScratchBlocks(blockInfo, categoryInfo); + categoryInfo.blocks.push(convertedBlock); + } catch (e) { + logger.error('Error parsing block: ', {block: blockInfo, error: e}); + } + } + + return categoryInfo; + } + + private registerExtensionPrimitives(extensionInfo: any, categoryInfo: CategoryInfo): void { + const updatePrimitivesPayload: Required = { + type: 'UPDATE_PRIMITIVES', + primitives: Object.create(null), + hats: Object.create(null) + }; + + for (const blockInfo of extensionInfo.blocks) { + try { + const convertedBlock = this.runtime._convertForScratchBlocks(blockInfo, categoryInfo); + if (convertedBlock.json) { + const opcode = convertedBlock.json.type; + const block = blockInfo as ExtensionBlockMetadata; + const blockType = block.blockType; + + if (blockType !== BlockType.EVENT) { + updatePrimitivesPayload.primitives[opcode] = convertedBlock.info.func; + } + + if (blockType === BlockType.EVENT || blockType === BlockType.HAT) { + updatePrimitivesPayload.hats[opcode] = { + edgeActivated: block.isEdgeActivated, + restartExistingThreads: block.shouldRestartExistingThreads + }; + } + } + } catch (e) { + logger.error('Error parsing block: ', {block: blockInfo, error: e}); + } + } + + this.manager!.emitEvent(updatePrimitivesPayload); + } + + private registerBlocks(categoryInfo: CategoryInfo): void { + // scratch-blocks implements a menu or custom field as a special kind of block ("shadow" block) + // these actually define blocks and MUST run regardless of the UI state + const blockInfoArray = categoryInfo.blocks.concat(categoryInfo.menus).concat( + Object.getOwnPropertyNames(categoryInfo.customFieldTypes).map( + fieldTypeName => categoryInfo.customFieldTypes[fieldTypeName].scratchBlocksDefinition + ) + ); + + const updateBlocksPayload: UpdateBlocksEvent = { + type: 'UPDATE_BLOCKS', + blocks: [], + fields: [] + }; + + if (blockInfoArray.length > 0) { + blockInfoArray.forEach(blockInfo => { + if (blockInfo.info && blockInfo.info.isDynamic) { + // This is creating the block factory / constructor -- NOT a specific instance of the block. + // The factory should only know static info about the block: the category info and the opcode. + // Anything else will be picked up from the XML attached to the block instance. + // const extendedOpcode = `${categoryInfo.id}_${blockInfo.info.opcode}`; + // const blockDefinition = + // defineDynamicBlock(this.ScratchBlocks, categoryInfo, blockInfo, extendedOpcode); + // this.ScratchBlocks.Blocks[extendedOpcode] = blockDefinition; + } else if (blockInfo.json) { + // Static blocks. + updateBlocksPayload.blocks.push(blockInfo.json); + } + // otherwise it's a non-block entry such as '---' + }); + } + + this.manager!.emitEvent(updateBlocksPayload); + } + + private buildToolboxXML(categoryInfo: CategoryInfo, isStage: boolean): string { + const {name, color1, color2} = 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 => { + let blockFilterIncludesTarget = true; + // If the block info doesn't include a `filter` property, always include it + if (block.info.filter) { + blockFilterIncludesTarget = block.info.filter.includes( + isStage ? TargetType.STAGE : TargetType.SPRITE + ); + } + // If the block info's `hideFromPalette` is true, then filter out this block + return blockFilterIncludesTarget && !block.info.hideFromPalette; + }); + + const colorXML = `colour="${color1}" secondaryColour="${color2}"`; + + // Use a menu icon if there is one. Otherwise, use the block icon. If there's no icon, + // the category menu will show its default colored circle. + let menuIconURI = ''; + if (categoryInfo.menuIconURI) { + menuIconURI = categoryInfo.menuIconURI; + } else if (categoryInfo.blockIconURI) { + menuIconURI = categoryInfo.blockIconURI; + } + const menuIconXML = menuIconURI ? + `iconURI="${menuIconURI}"` : ''; + + let statusButtonXML = ''; + if (categoryInfo.showStatusButton) { + statusButtonXML = 'showStatusButton="true"'; + } + + const xml = `` + + `${paletteBlocks.map(block => block.xml).join('')}`; + return xml; + } + /// Methods from scratch-vm/src/extension-support/extension-manager.js /** diff --git a/packages/extension/src/adapter/scratch/types/extension-metadata.ts b/packages/extension/src/adapter/scratch/types/extension-metadata.ts index 520cdd1b1..a96dee3bc 100644 --- a/packages/extension/src/adapter/scratch/types/extension-metadata.ts +++ b/packages/extension/src/adapter/scratch/types/extension-metadata.ts @@ -61,6 +61,8 @@ export interface ExtensionBlockMetadata { arguments?: Record; /** True if creating a block factory / constructor. */ isDynamic?: boolean; + /** Optional list of target types for which this block should appear. */ + filter?: TargetType[]; } /** diff --git a/packages/extension/src/events.ts b/packages/extension/src/events.ts new file mode 100644 index 000000000..9a1b8db8f --- /dev/null +++ b/packages/extension/src/events.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +import type {BlockFunction} from './interfaces/common'; + +export interface AbstractEvent { + type: string; +} + +export interface UpdatePrimitivesEvent extends AbstractEvent { + type: 'UPDATE_PRIMITIVES'; + primitives?: Record; + hats?: Record; +} + +export interface UpdateBlocksEvent extends AbstractEvent { + type: 'UPDATE_BLOCKS'; + blocks: any[]; + fields: any[]; +} diff --git a/packages/extension/src/extension-manager.ts b/packages/extension/src/extension-manager.ts index bc238afa9..7d3a95e9b 100644 --- a/packages/extension/src/extension-manager.ts +++ b/packages/extension/src/extension-manager.ts @@ -6,7 +6,7 @@ import {EventEmitter} from 'events'; import {IExtension} from './interfaces/i_extension'; -import {ScratchExtensionAdapter} from './adapter/scratch/adapter'; +import {AbstractEvent} from './events'; /** * Class to manage all of the extensions. @@ -33,6 +33,7 @@ export class ExtensionManager { throw new Error(`Extension with id ${extensionId} already exists.`); } + extension.attachManager(this); this.loadedExtensions.set(extensionId, extension); } @@ -104,9 +105,21 @@ export class ExtensionManager { // @todo test only, should be replaced later. for (const extension of this.loadedExtensions.values()) { if (!extension.isEnabled()) continue; + console.log('REFRESH BLOCKS'); + } + } - (extension as ScratchExtensionAdapter).refreshPrimitives(); + /** + * Get toolbox contents for Blockly. + * @returns Toolbox contents. + */ + getToolboxContents(isStage: boolean): any { + const toolboxContents = []; + for (const extension of this.loadedExtensions.values()) { + if (!extension.isEnabled()) continue; + toolboxContents.push(extension.getToolboxContents(isStage)); } + return toolboxContents; } /** @@ -126,4 +139,13 @@ export class ExtensionManager { removeEventListener(event: string, listener: (...args: any[]) => void): void { this.eventEmitter.removeListener(event, listener); } + + /** + * Emit a event. + * @param event Payload of event. + */ + emitEvent(event: T): void { + console.log(event); + this.eventEmitter.emit(event.type, event); + } } diff --git a/packages/extension/src/interfaces/common.ts b/packages/extension/src/interfaces/common.ts new file mode 100644 index 000000000..f600e2392 --- /dev/null +++ b/packages/extension/src/interfaces/common.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +/** Type for block function, used to register primitives. */ +export type BlockFunction = (args: Record, util: any) => any; diff --git a/packages/extension/src/interfaces/i_extension.ts b/packages/extension/src/interfaces/i_extension.ts index 64c1691a7..a407249f5 100644 --- a/packages/extension/src/interfaces/i_extension.ts +++ b/packages/extension/src/interfaces/i_extension.ts @@ -4,10 +4,20 @@ * SPDX-License-Identifier: MPL-2.0 */ +import type {ExtensionManager} from '../extension-manager'; + /** * Interface of extension object. */ export interface IExtension { + /** + * Attach the extension to given manager. + * The method will be called when loading the extension. + * @param manager Extension manager instance. + * @internal + */ + attachManager(manager: ExtensionManager): void; + /** * Get ID of the extension. * @returns ID of the extension. @@ -29,4 +39,11 @@ export interface IExtension { * Disable the extension. */ disable(): void; + + /** + * Get toolbox content for Blockly. + * The method should only be called when extension is enabled. + * @param isStage True if current target is stage. + */ + getToolboxContents(isStage: boolean): any; } From a43e43ff7b6470481cc44d53049886a78b10e31f Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Mon, 9 Mar 2026 02:14:33 +0800 Subject: [PATCH 07/36] :bug: fix(ext): unpacked arguments passed to func call Signed-off-by: Alex Cui --- packages/extension/src/adapter/scratch/adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/src/adapter/scratch/adapter.ts b/packages/extension/src/adapter/scratch/adapter.ts index d58ecdf4b..63bbe707c 100644 --- a/packages/extension/src/adapter/scratch/adapter.ts +++ b/packages/extension/src/adapter/scratch/adapter.ts @@ -187,7 +187,7 @@ export class ScratchExtensionAdapter implements IExtension { private callExtensionMethod(method: string, ...args: any[]): any { if (this.instance && method in this.instance && typeof this.instance[method] === 'function') { - return this.instance[method](args); + return this.instance[method](...args); } logger.warn(`Could not find extension block function called ${method}`); From 0cb9c3a382ae969f5a197d2d7d387cf84fa2a712 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Mon, 9 Mar 2026 02:16:43 +0800 Subject: [PATCH 08/36] :sparkles: feat: integration with extension manager Signed-off-by: Alex Cui --- .../extension/src/adapter/scratch/adapter.ts | 1 - packages/extension/src/extension-manager.ts | 10 +- packages/extension/src/index.ts | 2 + packages/gui/src/containers/blocks.jsx | 31 ++++- .../gui/src/containers/extension-library.jsx | 13 +-- packages/gui/src/lib/vm-manager-hoc.jsx | 8 +- .../gui/src/reducers/extension-manager.js | 28 +++++ packages/gui/src/reducers/gui.js | 7 +- packages/vm/package.json | 1 + packages/vm/src/virtual-machine.js | 106 +++++++++++++----- 10 files changed, 153 insertions(+), 54 deletions(-) create mode 100644 packages/gui/src/reducers/extension-manager.js diff --git a/packages/extension/src/adapter/scratch/adapter.ts b/packages/extension/src/adapter/scratch/adapter.ts index 63bbe707c..064175f92 100644 --- a/packages/extension/src/adapter/scratch/adapter.ts +++ b/packages/extension/src/adapter/scratch/adapter.ts @@ -176,7 +176,6 @@ export class ScratchExtensionAdapter implements IExtension { * The method should only be called when extension is enabled. */ getToolboxContents(isStage: boolean): any { - console.log(isStage); const extensionInfo = this.prepareExtensionInfo(this.instance!.getInfo()); const categoryInfo = this.buildCategoryInfo(extensionInfo); return { diff --git a/packages/extension/src/extension-manager.ts b/packages/extension/src/extension-manager.ts index 7d3a95e9b..d33da6d07 100644 --- a/packages/extension/src/extension-manager.ts +++ b/packages/extension/src/extension-manager.ts @@ -101,16 +101,9 @@ export class ExtensionManager { return this.loadedExtensions.get(extensionId) ?? null; } - refreshBlocks() { - // @todo test only, should be replaced later. - for (const extension of this.loadedExtensions.values()) { - if (!extension.isEnabled()) continue; - console.log('REFRESH BLOCKS'); - } - } - /** * Get toolbox contents for Blockly. + * @param isStage True if current target is stage. * @returns Toolbox contents. */ getToolboxContents(isStage: boolean): any { @@ -145,7 +138,6 @@ export class ExtensionManager { * @param event Payload of event. */ emitEvent(event: T): void { - console.log(event); this.eventEmitter.emit(event.type, event); } } diff --git a/packages/extension/src/index.ts b/packages/extension/src/index.ts index 82b6fb7b6..64dba7bd1 100644 --- a/packages/extension/src/index.ts +++ b/packages/extension/src/index.ts @@ -6,5 +6,7 @@ export {ScratchExtensionAdapter} from './adapter/scratch/adapter'; +export {AbstractEvent, UpdateBlocksEvent, UpdatePrimitivesEvent} from './events'; + export {ExtensionManager} from './extension-manager'; export {IExtension} from './interfaces/i_extension'; diff --git a/packages/gui/src/containers/blocks.jsx b/packages/gui/src/containers/blocks.jsx index e4d37dd36..a517a9425 100644 --- a/packages/gui/src/containers/blocks.jsx +++ b/packages/gui/src/containers/blocks.jsx @@ -6,6 +6,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import VMScratchBlocks from '../lib/blocks'; import VM from 'clipcc-vm'; +import {ExtensionManager} from 'clipcc-extension'; import log from '../lib/log.js'; import Prompt from './prompt.jsx'; @@ -66,6 +67,7 @@ class Blocks extends React.Component { 'handlePromptCallback', 'handlePromptClose', 'handleCustomProceduresClose', + 'handleExtensionUpdateBlocks', 'onScriptGlowOn', 'onScriptGlowOff', 'onBlockGlowOn', @@ -139,6 +141,9 @@ class Blocks extends React.Component { addFunctionListener(this.workspace, 'translate', this.onWorkspaceMetricsChange); addFunctionListener(this.workspace, 'zoom', this.onWorkspaceMetricsChange); + // Handle events from extension manager to modify clipcc-block. + this.props.extensionManager.addEventListener('UPDATE_BLOCKS', this.handleExtensionUpdateBlocks); + this.attachVM(); // Only update blocks/vm locale when visible to avoid sizing issues // If locale changes while not visible it will get handled in didUpdate @@ -211,6 +216,9 @@ class Blocks extends React.Component { this.workspace.dispose(); clearTimeout(this.toolboxUpdateTimeout); + // Remove event listeners for extension manager. + this.props.extensionManager.addEventListener('UPDATE_BLOCKS', this.handleExtensionUpdateBlocks); + // Clear the flyout blocks so that they can be recreated on mount. this.props.vm.clearFlyoutBlocks(); } @@ -390,7 +398,7 @@ class Blocks extends React.Component { const targetCostumes = target.getCostumes(); const targetSounds = target.getSounds(); const dynamicBlocksXML = injectExtensionCategoryTheme( - this.props.vm.runtime.getBlocksXML(target), + this.props.vm.extensionManager.getToolboxContents(target.isStage), this.props.theme ); return makeToolbox(false, target.isStage, target.id, dynamicBlocksXML, @@ -585,6 +593,18 @@ class Blocks extends React.Component { if (!target) return; this.props.vm.setEditingTarget(target.id); } + + handleExtensionUpdateBlocks (event) { + this.ScratchBlocks.defineBlocksWithJsonArray(event.blocks); + + // Update the toolbox. + const toolbox = this.getToolbox(); + if (toolbox) { + this.props.updateToolboxState(toolbox); + } + this.requestToolboxUpdate(); + } + render () { /* eslint-disable no-unused-vars */ const { @@ -608,6 +628,7 @@ class Blocks extends React.Component { toolbox, updateMetrics: updateMetricsProp, workspaceMetrics, + extensionManager, ...props } = this.props; /* eslint-enable no-unused-vars */ @@ -634,7 +655,7 @@ class Blocks extends React.Component { ) : null} {extensionLibraryVisible ? ( @@ -687,7 +708,8 @@ Blocks.propTypes = { vm: PropTypes.instanceOf(VM).isRequired, workspaceMetrics: PropTypes.shape({ targets: PropTypes.objectOf(PropTypes.object) - }) + }), + extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired }; Blocks.defaultOptions = { @@ -737,7 +759,8 @@ const mapStateToProps = state => ({ blockMessages: state.locales.blockMessages, toolbox: state.scratchGui.toolbox.toolbox, customProceduresVisible: state.scratchGui.customProcedures.active, - workspaceMetrics: state.scratchGui.workspaceMetrics + workspaceMetrics: state.scratchGui.workspaceMetrics, + extensionManager: state.scratchGui.extensionManager }); const mapDispatchToProps = dispatch => ({ diff --git a/packages/gui/src/containers/extension-library.jsx b/packages/gui/src/containers/extension-library.jsx index 95b06d8da..5684148f9 100644 --- a/packages/gui/src/containers/extension-library.jsx +++ b/packages/gui/src/containers/extension-library.jsx @@ -1,7 +1,7 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; -import VM from 'clipcc-vm'; +import {ExtensionManager} from 'clipcc-extension'; import {defineMessages, injectIntl, intlShape} from 'react-intl'; import extensionLibraryContent from '../lib/libraries/extensions/index.jsx'; @@ -37,13 +37,10 @@ class ExtensionLibrary extends React.PureComponent { url = prompt(this.props.intl.formatMessage(messages.extensionUrl)); } if (id && !item.disabled) { - if (this.props.vm.extensionManager.isExtensionLoaded(url)) { - this.props.onCategorySelected(id); - } else { - this.props.vm.extensionManager.loadExtensionURL(url).then(() => { - this.props.onCategorySelected(id); - }); + if (!this.props.extensionManager.isExtensionEnabled(url)) { + this.props.extensionManager.enableExtension(url); } + this.props.onCategorySelected(id); } } render () { @@ -70,7 +67,7 @@ ExtensionLibrary.propTypes = { onCategorySelected: PropTypes.func, onRequestClose: PropTypes.func, visible: PropTypes.bool, - vm: PropTypes.instanceOf(VM).isRequired // eslint-disable-line react/no-unused-prop-types + extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired }; export default injectIntl(ExtensionLibrary); diff --git a/packages/gui/src/lib/vm-manager-hoc.jsx b/packages/gui/src/lib/vm-manager-hoc.jsx index 883fb1041..8f453ad5f 100644 --- a/packages/gui/src/lib/vm-manager-hoc.jsx +++ b/packages/gui/src/lib/vm-manager-hoc.jsx @@ -5,6 +5,7 @@ import {connect} from 'react-redux'; import VM from 'clipcc-vm'; import AudioEngine from 'clipcc-audio'; +import {ExtensionManager} from 'clipcc-extension'; import {setProjectUnchanged} from '../reducers/project-changed'; import { @@ -31,6 +32,7 @@ const vmManagerHOC = function (WrappedComponent) { if (!this.props.vm.initialized) { this.audioEngine = new AudioEngine(); this.props.vm.attachAudioEngine(this.audioEngine); + this.props.vm.attachExtensionManager(this.props.extensionManager); this.props.vm.setCompatibilityMode(true); this.props.vm.initialized = true; this.props.vm.setLocale(this.props.locale, this.props.messages); @@ -176,7 +178,8 @@ const vmManagerHOC = function (WrappedComponent) { accurateCoordinates: PropTypes.bool.isRequired, stageWidth: PropTypes.number.isRequired, stageHeight: PropTypes.number.isRequired, - vm: PropTypes.instanceOf(VM).isRequired + vm: PropTypes.instanceOf(VM).isRequired, + extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired }; const mapStateToProps = state => { @@ -199,7 +202,8 @@ const vmManagerHOC = function (WrappedComponent) { unlimitedSoundStuffs: state.scratchGui.settings.unlimitedSoundStuffs, accurateCoordinates: state.scratchGui.settings.accurateCoordinates, stageWidth: state.scratchGui.settings.stageWidth, - stageHeight: state.scratchGui.settings.stageHeight + stageHeight: state.scratchGui.settings.stageHeight, + extensionManager: state.scratchGui.extensionManager }; }; diff --git a/packages/gui/src/reducers/extension-manager.js b/packages/gui/src/reducers/extension-manager.js new file mode 100644 index 000000000..c7894189f --- /dev/null +++ b/packages/gui/src/reducers/extension-manager.js @@ -0,0 +1,28 @@ +import {ExtensionManager} from 'clipcc-extension'; + +const SET_EXTENSION_MANAGER = 'scratch-gui/extension-manager/SET_EXTENSION_MANAGER'; +const defaultExtensionManager = new ExtensionManager(); +const initialState = defaultExtensionManager; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case SET_EXTENSION_MANAGER: + return action.extensionManager; + default: + return state; + } +}; + +const setExtensionManager = function (extensionManager) { + return { + type: SET_EXTENSION_MANAGER, + extensionManager: extensionManager + }; +}; + +export { + reducer as default, + initialState as extensionManagerInitialState, + setExtensionManager +}; diff --git a/packages/gui/src/reducers/gui.js b/packages/gui/src/reducers/gui.js index 0ef4487a3..7d2cbfd4b 100644 --- a/packages/gui/src/reducers/gui.js +++ b/packages/gui/src/reducers/gui.js @@ -27,6 +27,7 @@ import toolboxReducer, {toolboxInitialState} from './toolbox'; import vmReducer, {vmInitialState} from './vm'; import vmStatusReducer, {vmStatusInitialState} from './vm-status'; import workspaceMetricsReducer, {workspaceMetricsInitialState} from './workspace-metrics'; +import extensionManagerReducer, {extensionManagerInitialState} from './extension-manager'; import throttle from 'redux-throttle'; @@ -60,7 +61,8 @@ const guiInitialState = { toolbox: toolboxInitialState, vm: vmInitialState, vmStatus: vmStatusInitialState, - workspaceMetrics: workspaceMetricsInitialState + workspaceMetrics: workspaceMetricsInitialState, + extensionManager: extensionManagerInitialState }; const initPlayer = function (currentState) { @@ -142,7 +144,8 @@ const guiReducer = combineReducers({ toolbox: toolboxReducer, vm: vmReducer, vmStatus: vmStatusReducer, - workspaceMetrics: workspaceMetricsReducer + workspaceMetrics: workspaceMetricsReducer, + extensionManager: extensionManagerReducer }); export { diff --git a/packages/vm/package.json b/packages/vm/package.json index 756a71792..ed4dc7b49 100644 --- a/packages/vm/package.json +++ b/packages/vm/package.json @@ -69,6 +69,7 @@ "callsite": "1.0.0", "clipcc-audio": "3.2.0", "clipcc-block": "3.2.0", + "clipcc-extension": "3.2.0", "clipcc-l10n": "3.2.0", "clipcc-render": "3.2.0", "clipcc-storage": "3.2.0", diff --git a/packages/vm/src/virtual-machine.js b/packages/vm/src/virtual-machine.js index de8a222a3..93dbecc4a 100644 --- a/packages/vm/src/virtual-machine.js +++ b/packages/vm/src/virtual-machine.js @@ -7,10 +7,10 @@ if (typeof TextEncoder === 'undefined') { } const EventEmitter = require('events'); const JSZip = require('jszip'); +const {ScratchExtensionAdapter} = require('clipcc-extension'); const Buffer = require('buffer').Buffer; const centralDispatch = require('./dispatch/central-dispatch'); -const ExtensionManager = require('./extension-support/extension-manager'); const log = require('./util/log'); const MathUtil = require('./util/math-util'); const Runtime = require('./engine/runtime'); @@ -28,21 +28,6 @@ require('canvas-toBlob'); const RESERVED_NAMES = ['_mouse_', '_stage_', '_edge_', '_myself_', '_random_']; -/** - * @type {string[]} - */ -const CORE_EXTENSIONS = [ - // 'motion', - // 'looks', - // 'sound', - // 'events', - // 'control', - // 'sensing', - // 'operators', - // 'variables', - // 'myBlocks' -]; - /** * @typedef {number} int * @typedef {import('./engine/target')} Target @@ -50,6 +35,8 @@ const CORE_EXTENSIONS = [ * @typedef {import('clipcc-audio')} AudioEngine * @typedef {import('clipcc-render')} RenderWebGL * @typedef {import('clipcc-storage').ScratchStorage} ScratchStorage + * @typedef {import('clipcc-extension').ExtensionManager} ExtensionManager + * @typedef {import('clipcc-extension').UpdatePrimitivesEvent} UpdatePrimitivesEvent */ /** @@ -147,7 +134,7 @@ class VirtualMachine extends EventEmitter { this.emitWorkspaceUpdate(); }); this.runtime.on(Runtime.TOOLBOX_EXTENSIONS_NEED_UPDATE, () => { - this.extensionManager.refreshBlocks(); + // this.extensionManager.refreshBlocks(); }); this.runtime.on(Runtime.PERIPHERAL_LIST_UPDATE, info => { this.emit(Runtime.PERIPHERAL_LIST_UPDATE, info); @@ -183,17 +170,11 @@ class VirtualMachine extends EventEmitter { this.emit(Runtime.STAGE_SIZE_UPDATE, width, height); }); - this.extensionManager = new ExtensionManager(this.runtime); - - // Load core extensions - for (const id of CORE_EXTENSIONS) { - this.extensionManager.loadExtensionIdSync(id); - } - this.blockListener = this.blockListener.bind(this); this.flyoutBlockListener = this.flyoutBlockListener.bind(this); this.monitorBlockListener = this.monitorBlockListener.bind(this); this.variableListener = this.variableListener.bind(this); + this.extensionListener = this.extensionListener.bind(this); } /** @@ -640,8 +621,7 @@ class VirtualMachine extends EventEmitter { extensions.extensionIDs.forEach(extensionID => { if (!this.extensionManager.isExtensionLoaded(extensionID)) { - const extensionURL = extensions.extensionURLs.get(extensionID) || extensionID; - extensionPromises.push(this.extensionManager.loadExtensionURL(extensionURL)); + this.extensionManager.enableExtension(extensionID); } }); @@ -1246,6 +1226,73 @@ class VirtualMachine extends EventEmitter { this.runtime.attachStorage(storage); } + /** + * Attach the extension manager. + * @param {!ExtensionManager} extensionManager The extension manager to attach + */ + attachExtensionManager (extensionManager) { + this.extensionManager = extensionManager; + + this.extensionManager.addEventListener('UPDATE_PRIMITIVES', this.extensionListener); + + const builtinExtensions = { + // This is an example that isn't loaded with the other core blocks, + // but serves as a reference for loading core blocks as extensions. + coreExample: () => require('./blocks/scratch3_core_example'), + // These are the non-core built-in extensions. + pen: () => require('./extensions/scratch3_pen'), + wedo2: () => require('./extensions/scratch3_wedo2'), + music: () => require('./extensions/scratch3_music'), + microbit: () => require('./extensions/scratch3_microbit'), + text2speech: () => require('./extensions/scratch3_text2speech'), + translate: () => require('./extensions/scratch3_translate'), + videoSensing: () => require('./extensions/scratch3_video_sensing'), + ev3: () => require('./extensions/scratch3_ev3'), + makeymakey: () => require('./extensions/scratch3_makeymakey'), + boost: () => require('./extensions/scratch3_boost'), + gdxfor: () => require('./extensions/scratch3_gdx_for') + }; + + // Register builtin extensions. + // @todo should be removed later to make builtin extension external. + for (const extensionId in builtinExtensions) { + if (this.extensionManager.isExtensionLoaded(extensionId)) { + log.error(`Duplicated builtin extension: ${extensionId}`); + continue; + } + + const manifest = { + extensionId: extensionId + }; + + this.extensionManager.loadExtension(new ScratchExtensionAdapter( + manifest, builtinExtensions[extensionId], this.runtime + )); + } + } + + /** + * Event listener for extension manager. + * @param {!UpdatePrimitivesEvent} event Event payload. + */ + extensionListener (event) { + for (const opcode in event.primitives) { + if (Object.hasOwnProperty.call(this.runtime._primitives, opcode)) { + log.error(`Duplicated opcode to add to primitives: ${opcode}`); + continue; + } + this.runtime._primitives[opcode] = event.primitives[opcode]; + } + + for (const opcode in event.hats) { + if (Object.hasOwnProperty.call(this.runtime._hats, opcode)) { + log.error(`Duplicated opcode to add to hats: ${opcode}`); + continue; + } + this.runtime._hats[opcode] = event.hats[opcode]; + } + } + /** * set the current locale and builtin messages for the VM * @param {!string} locale current locale @@ -1257,7 +1304,7 @@ class VirtualMachine extends EventEmitter { if (locale !== formatMessage.setup().locale) { formatMessage.setup({locale: locale, translations: {[locale]: messages}}); } - return this.extensionManager.refreshBlocks(); + return Promise.resolve(); } /** @@ -1384,7 +1431,10 @@ class VirtualMachine extends EventEmitter { // Create an array promises for extensions to load const extensionPromises = Array.from(extensionIDs, - id => this.extensionManager.loadExtensionURL(id) + id => { + this.extensionManager.enableExtension(id); + return Promise.resolve(); + } ); return Promise.all(extensionPromises).then(() => { From cbb2b00e4a68e53a0ffe1780194e1a9c8c9ec8da Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Mon, 9 Mar 2026 17:14:25 +0800 Subject: [PATCH 09/36] :wrench: fix(ext): type annotations for scratch extension adapter Signed-off-by: Alex Cui --- .../extension/src/adapter/scratch/adapter.ts | 39 ++++++++++--------- .../scratch/types/extension-metadata.ts | 27 +++++++++++-- packages/extension/src/index.ts | 8 ++-- packages/extension/src/utils/type-traits.ts | 34 ++++++++++++++++ 4 files changed, 81 insertions(+), 27 deletions(-) create mode 100644 packages/extension/src/utils/type-traits.ts diff --git a/packages/extension/src/adapter/scratch/adapter.ts b/packages/extension/src/adapter/scratch/adapter.ts index 064175f92..671f9cf19 100644 --- a/packages/extension/src/adapter/scratch/adapter.ts +++ b/packages/extension/src/adapter/scratch/adapter.ts @@ -10,6 +10,9 @@ import logger from '../../utils/logger'; import BlockType from './types/block-type'; import { isSimpleMenuMetadata, + type ProcessedExtensionBlockMetadata, + type ProcessedExtensionMenuMetadata, + type ProcessedExtensionMetadata, type ExtensionBlockMetadata, type ExtensionMenuItems, type ExtensionMenuMetadata, @@ -374,23 +377,22 @@ export class ScratchExtensionAdapter implements IExtension { * @param extensionInfo The extension info to be sanitized. * @returns A new extension info object with cleaned-up values. */ - private prepareExtensionInfo(extensionInfo: ExtensionMetadata): ExtensionMetadata { - extensionInfo = Object.assign({}, extensionInfo); - if (!/^[a-z0-9]+$/i.test(extensionInfo.id)) { + private prepareExtensionInfo(extensionInfo: ExtensionMetadata): ProcessedExtensionMetadata { + const info = Object.assign({}, extensionInfo) as unknown as ProcessedExtensionMetadata; + if (!/^[a-z0-9]+$/i.test(info.id)) { throw new Error('Invalid extension id'); } - extensionInfo.name = extensionInfo.name || extensionInfo.id; - extensionInfo.blocks = extensionInfo.blocks || []; - extensionInfo.targetTypes = extensionInfo.targetTypes || []; - extensionInfo.blocks = extensionInfo.blocks.reduce((results, blockInfo) => { + info.name = extensionInfo.name || extensionInfo.id; + info.targetTypes = extensionInfo.targetTypes || []; + info.blocks = extensionInfo.blocks.reduce((results, blockInfo) => { try { - let result; + let result: '---' | ProcessedExtensionBlockMetadata; switch (blockInfo) { case '---': // separator - result = '---'; + result = '---' as '---'; break; default: // an ExtensionBlockMetadata object - result = this.prepareBlockInfo(blockInfo as ExtensionBlockMetadata); + result = this.prepareBlockInfo(blockInfo); break; } results.push(result); @@ -400,9 +402,8 @@ export class ScratchExtensionAdapter implements IExtension { } return results; }, []); - extensionInfo.menus = extensionInfo.menus || {}; - extensionInfo.menus = this.prepareMenuInfo(extensionInfo.menus); - return extensionInfo; + info.menus = this.prepareMenuInfo(extensionInfo.menus || {}); + return info; } /** @@ -412,7 +413,7 @@ export class ScratchExtensionAdapter implements IExtension { */ private prepareMenuInfo( menus: Record - ): Record { + ): Record { const menuNames = Object.getOwnPropertyNames(menus); for (const menuName of menuNames) { let menuInfo = menus[menuName]; @@ -431,10 +432,10 @@ export class ScratchExtensionAdapter implements IExtension { if (typeof menuInfo.items === 'string') { const menuItemFunctionName = menuInfo.items; // Bind the function here so we can pass a simple item generation function to Scratch Blocks later. - menuInfo.items = this.getExtensionMenuItems.bind(this, menuItemFunctionName); + (menuInfo as unknown as ProcessedExtensionMenuMetadata).items = this.getExtensionMenuItems.bind(this, menuItemFunctionName); } } - return menus; + return menus as unknown as Record; } /** @@ -481,7 +482,7 @@ export class ScratchExtensionAdapter implements IExtension { * @returns A new block info object which has values for all relevant optional fields. * @private */ - private prepareBlockInfo(blockInfo: ExtensionBlockMetadata): ExtensionBlockMetadata { + private prepareBlockInfo(blockInfo: ExtensionBlockMetadata): ProcessedExtensionBlockMetadata { blockInfo = Object.assign({}, { blockType: BlockType.COMMAND, terminal: false, @@ -516,7 +517,7 @@ export class ScratchExtensionAdapter implements IExtension { return this.callExtensionMethod(funcName, args, util, realBlockInfo); }; - blockInfo.func = (args: Record, util: any) => { + (blockInfo as ProcessedExtensionBlockMetadata).func = (args: Record, util: any) => { const realBlockInfo = getBlockInfo(args); // TODO: filter args using the keys of realBlockInfo.arguments? maybe only if sandboxed? return callBlockFunc(args, util, realBlockInfo); @@ -525,6 +526,6 @@ export class ScratchExtensionAdapter implements IExtension { } } - return blockInfo; + return blockInfo as ProcessedExtensionBlockMetadata; } } diff --git a/packages/extension/src/adapter/scratch/types/extension-metadata.ts b/packages/extension/src/adapter/scratch/types/extension-metadata.ts index a96dee3bc..8a4a67f57 100644 --- a/packages/extension/src/adapter/scratch/types/extension-metadata.ts +++ b/packages/extension/src/adapter/scratch/types/extension-metadata.ts @@ -8,6 +8,8 @@ import type ArgumentType from './argument-type'; import type BlockType from './block-type'; import type ReporterScope from './reporter-scope'; import type TargetType from './target-type'; +import type {BlockFunction} from '../../../interfaces/common'; +import {Modify} from '../../../utils/type-traits'; /** * All the metadata needed to register an extension. @@ -24,13 +26,18 @@ export interface ExtensionMetadata { /** Link to documentation content for this extension. */ docsURI?: string; /** The blocks provided by this extension, plus separators. */ - blocks: Array; + blocks: Array; /** Map of menu name to metadata for each of this extension's menus. */ menus?: Record; /** List new target type(s) provided by this extension. */ targetTypes?: string[]; } +export type ProcessedExtensionMetadata = Modify; + menus: Record; +}>; + /** * All the metadata needed to register an extension block. */ @@ -38,7 +45,7 @@ export interface ExtensionBlockMetadata { /** A unique alphanumeric identifier for this block. No special characters allowed. */ opcode: string; /** The name of the function implementing this block. Can be shared by other blocks/opcodes. */ - func?: string | ((...args: any) => any); + func?: string; /** The type of block (command, reporter, etc.) being described. */ blockType: BlockType; /** The text on the block, with [PLACEHOLDERS] for arguments. */ @@ -46,7 +53,9 @@ export interface ExtensionBlockMetadata { /** True if this block should not appear in the block palette. */ hideFromPalette?: boolean; /** True if the block ends a stack - no blocks can be connected after it. */ - isTerminal?: boolean; + terminal?: boolean; + /** Whether or not to block all threads while. */ + blockAllThreads?: boolean; /** True if this block is a reporter but should not allow a monitor. */ disableMonitor?: boolean; /** If this block is a reporter, this is the scope/context for its value. */ @@ -65,6 +74,10 @@ export interface ExtensionBlockMetadata { filter?: TargetType[]; } +export type ProcessedExtensionBlockMetadata = Modify; + /** * All the metadata needed to register an argument for an extension block. */ @@ -82,9 +95,15 @@ export interface ExtensionArgumentMetadata { */ export type ExtensionMenuMetadata = { acceptReporters?: boolean; - items: ExtensionDynamicMenu | ExtensionMenuItems | (() => [string, string][]); + items: ExtensionDynamicMenu | ExtensionMenuItems; } | ExtensionDynamicMenu | ExtensionMenuItems; + +export type ProcessedExtensionMenuMetadata = { + acceptReporters?: boolean; + items: (() => [string, string][]); +} + /** * The string name of a function which returns menu items. */ diff --git a/packages/extension/src/index.ts b/packages/extension/src/index.ts index 64dba7bd1..e7d571b6a 100644 --- a/packages/extension/src/index.ts +++ b/packages/extension/src/index.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: MPL-2.0 */ -export {ScratchExtensionAdapter} from './adapter/scratch/adapter'; - -export {AbstractEvent, UpdateBlocksEvent, UpdatePrimitivesEvent} from './events'; +export * from './events'; +export * from './interfaces/i_extension'; export {ExtensionManager} from './extension-manager'; -export {IExtension} from './interfaces/i_extension'; + +export {ScratchExtensionAdapter} from './adapter/scratch/adapter'; diff --git a/packages/extension/src/utils/type-traits.ts b/packages/extension/src/utils/type-traits.ts new file mode 100644 index 000000000..1d46ad48e --- /dev/null +++ b/packages/extension/src/utils/type-traits.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +/** + * Create a new type that overrides properties in T with properties from U. + * @typeParam T - The original object type. + * @typeParam U - The type containing properties to override, which should only + * contains keys existing in T. + */ +export type Modify = Required extends {[K in keyof U]: any} + ? Omit & U + : never; + +/** + * Create a new type that mark specified properties in T optional. It is useful + * when creating a function that initialized a interface. + * @typeParam T - The original object type. + * @typeParam K - Keys that needs to be marked optional in T. + */ +export type PartialKeys = Omit & { + [I in K]?: T[I] +}; + +/** + * Create a new type that mark specified properties in T required. + * @typeParam T - The original object type. + * @typeParam K - Keys that needs to be marked required in T. + */ +export type RequiredKeys = Omit & { + [I in K]-?: T[I] +}; From b351de81f6b0e8470da22eae4d5f766911b61d75 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Mon, 9 Mar 2026 18:04:18 +0800 Subject: [PATCH 10/36] :wrench: chore: fix typing Signed-off-by: Alex Cui --- packages/extension/src/adapter/scratch/adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/src/adapter/scratch/adapter.ts b/packages/extension/src/adapter/scratch/adapter.ts index 671f9cf19..3e35d6560 100644 --- a/packages/extension/src/adapter/scratch/adapter.ts +++ b/packages/extension/src/adapter/scratch/adapter.ts @@ -389,7 +389,7 @@ export class ScratchExtensionAdapter implements IExtension { let result: '---' | ProcessedExtensionBlockMetadata; switch (blockInfo) { case '---': // separator - result = '---' as '---'; + result = '---'; break; default: // an ExtensionBlockMetadata object result = this.prepareBlockInfo(blockInfo); From 6e0f8cf58ac01f04c3453956eca9485275f56cc8 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Mon, 9 Mar 2026 18:30:41 +0800 Subject: [PATCH 11/36] :wrench: chore(ext): type annotation for custom field Signed-off-by: Alex Cui --- .../extension/src/adapter/scratch/adapter.ts | 38 ++++++++++++------- .../scratch/types/extension-metadata.ts | 17 +++++++++ 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/extension/src/adapter/scratch/adapter.ts b/packages/extension/src/adapter/scratch/adapter.ts index 3e35d6560..950dffe2f 100644 --- a/packages/extension/src/adapter/scratch/adapter.ts +++ b/packages/extension/src/adapter/scratch/adapter.ts @@ -54,11 +54,21 @@ interface ConvertedBlockInfo { /** The raw block info. */ info: ExtensionBlockMetadata; /** The scratch-blocks JSON definition for this block. */ - json: Record; + json: object; /** The scratch-blocks XML definition for this block. */ xml: string; } +interface CustomFieldInfo { + fieldName: string; + extendedName: string; + argumentTypeInfo: object; + scratchBlocksDefinition: { + json: object; + }; + fieldImplementation: object; +} + /** * Information about a block category. */ @@ -83,7 +93,7 @@ interface CategoryInfo { showStatusButton?: boolean; menuIconURI?: string; customFieldTypes?: any; - menuInfo?: Record; + menuInfo?: Record; } const DEFAULT_COLORS = ['#0FBD8C', '#0DA57A', '#0B8E69']; @@ -196,21 +206,21 @@ export class ScratchExtensionAdapter implements IExtension { return undefined; } - private buildCategoryInfo(extensionInfo: any): CategoryInfo { - const categoryInfo: CategoryInfo = { + private buildCategoryInfo(extensionInfo: ProcessedExtensionMetadata): CategoryInfo { + const categoryInfo = { id: extensionInfo.id, name: maybeFormatMessage(extensionInfo.name), showStatusButton: extensionInfo.showStatusButton, blockIconURI: extensionInfo.blockIconURI, menuIconURI: extensionInfo.menuIconURI, - color1: extensionInfo.color1 ? extensionInfo.color1 : DEFAULT_COLORS[0], - color2: extensionInfo.color1 ? extensionInfo.color2 : DEFAULT_COLORS[1], - color3: extensionInfo.color1 ? extensionInfo.color3 : DEFAULT_COLORS[2], - blocks: [], - menus: [], - customFieldTypes: {}, - menuInfo: {} - }; + color1: extensionInfo.color1 ? extensionInfo.color1! : DEFAULT_COLORS[0], + color2: extensionInfo.color1 ? extensionInfo.color2! : DEFAULT_COLORS[1], + color3: extensionInfo.color1 ? extensionInfo.color3! : DEFAULT_COLORS[2], + blocks: [] as ConvertedBlockInfo[], + menus: [] as object[], + customFieldTypes: {} as Record, + menuInfo: {} as Record + } satisfies CategoryInfo; // Menus. for (const menuName in extensionInfo.menus) { @@ -218,7 +228,7 @@ export class ScratchExtensionAdapter implements IExtension { const menuInfo = extensionInfo.menus[menuName]; const convertedMenu = this.runtime._buildMenuForScratchBlocks(menuName, menuInfo, categoryInfo); categoryInfo.menus.push(convertedMenu); - categoryInfo.menuInfo![menuName] = menuInfo; + categoryInfo.menuInfo[menuName] = menuInfo; } } @@ -250,7 +260,7 @@ export class ScratchExtensionAdapter implements IExtension { return categoryInfo; } - private registerExtensionPrimitives(extensionInfo: any, categoryInfo: CategoryInfo): void { + private registerExtensionPrimitives(extensionInfo: ProcessedExtensionMetadata, categoryInfo: CategoryInfo): void { const updatePrimitivesPayload: Required = { type: 'UPDATE_PRIMITIVES', primitives: Object.create(null), diff --git a/packages/extension/src/adapter/scratch/types/extension-metadata.ts b/packages/extension/src/adapter/scratch/types/extension-metadata.ts index 8a4a67f57..e39967a4e 100644 --- a/packages/extension/src/adapter/scratch/types/extension-metadata.ts +++ b/packages/extension/src/adapter/scratch/types/extension-metadata.ts @@ -31,6 +31,14 @@ export interface ExtensionMetadata { menus?: Record; /** List new target type(s) provided by this extension. */ targetTypes?: string[]; + /** True if show status button for the category. */ + showStatusButton?: boolean; + /** Override the default extension block colors. */ + color1?: string; + color2?: string; + color3?: string; + /** Custom fields for scratch-blocks. */ + customFieldTypes?: Record; } export type ProcessedExtensionMetadata = Modify Date: Mon, 9 Mar 2026 19:22:20 +0800 Subject: [PATCH 12/36] :sparkles: feat(ext,gui,vm): fetch extension manifest from extension manager Signed-off-by: Alex Cui --- .../extension/src/adapter/scratch/adapter.ts | 15 ++++++++++++++- packages/extension/src/extension-manager.ts | 13 +++++++++++++ .../extension-manifest.ts} | 0 .../extension/src/interfaces/i_extension.ts | 7 +++++++ .../gui/src/containers/extension-library.jsx | 4 +--- packages/gui/src/lib/vm-manager-hoc.jsx | 5 ++++- packages/vm/src/virtual-machine.js | 17 +++++++++++------ 7 files changed, 50 insertions(+), 11 deletions(-) rename packages/extension/src/{adapter/scratch/types/manifest.ts => interfaces/extension-manifest.ts} (100%) diff --git a/packages/extension/src/adapter/scratch/adapter.ts b/packages/extension/src/adapter/scratch/adapter.ts index 950dffe2f..992039062 100644 --- a/packages/extension/src/adapter/scratch/adapter.ts +++ b/packages/extension/src/adapter/scratch/adapter.ts @@ -18,7 +18,7 @@ import { type ExtensionMenuMetadata, type ExtensionMetadata } from './types/extension-metadata'; -import type ExtensionManifest from './types/manifest'; +import type ExtensionManifest from '../../interfaces/extension-manifest'; import type ArgumentType from './types/argument-type'; import type {ExtensionManager} from '../../extension-manager'; import TargetType from './types/target-type'; @@ -126,6 +126,11 @@ export class ScratchExtensionAdapter implements IExtension { /** Instance of extension object. */ private instance: ScratchExtension | null = null; + /** + * @param manifest Manifest for extension library to display info. + * @param extensionModule Extension module that returns the extension class. + * @param runtime Runtime object of virtual machine. + */ constructor( private manifest: ExtensionManifest, private extensionModule: () => ScratchExtensionClass, @@ -150,6 +155,14 @@ export class ScratchExtensionAdapter implements IExtension { return this.manifest.extensionId; } + /** + * Get info to display in extension library. + * @returns Manifest of the extension. + */ + getManifest(): ExtensionManifest { + return this.manifest; + } + /** * Check whether the extension is enabled. * @returns True if the extension is enabled. diff --git a/packages/extension/src/extension-manager.ts b/packages/extension/src/extension-manager.ts index d33da6d07..d9ec64c7e 100644 --- a/packages/extension/src/extension-manager.ts +++ b/packages/extension/src/extension-manager.ts @@ -7,6 +7,7 @@ import {EventEmitter} from 'events'; import {IExtension} from './interfaces/i_extension'; import {AbstractEvent} from './events'; +import ExtensionManifest from './interfaces/extension-manifest'; /** * Class to manage all of the extensions. @@ -115,6 +116,18 @@ export class ExtensionManager { return toolboxContents; } + /** + * Get data needed to list all extensions in extension library. + * @returns Manifest of all extensions. + */ + getManifest(): ExtensionManifest[] { + const result: ExtensionManifest[] = []; + this.loadedExtensions.forEach((extension) => { + result.push(extension.getManifest()); + }); + return result; + } + /** * Add an event listener. * @param event Name of the event. diff --git a/packages/extension/src/adapter/scratch/types/manifest.ts b/packages/extension/src/interfaces/extension-manifest.ts similarity index 100% rename from packages/extension/src/adapter/scratch/types/manifest.ts rename to packages/extension/src/interfaces/extension-manifest.ts diff --git a/packages/extension/src/interfaces/i_extension.ts b/packages/extension/src/interfaces/i_extension.ts index a407249f5..0b87ac5d5 100644 --- a/packages/extension/src/interfaces/i_extension.ts +++ b/packages/extension/src/interfaces/i_extension.ts @@ -5,6 +5,7 @@ */ import type {ExtensionManager} from '../extension-manager'; +import type ExtensionManifest from './extension-manifest'; /** * Interface of extension object. @@ -24,6 +25,12 @@ export interface IExtension { */ getId(): string; + /** + * Get info to display in extension library. + * @returns Manifest of the extension. + */ + getManifest(): ExtensionManifest; + /** * Check whether the extension is enabled. * @returns True if the extension is enabled. diff --git a/packages/gui/src/containers/extension-library.jsx b/packages/gui/src/containers/extension-library.jsx index 5684148f9..8da2ec5b0 100644 --- a/packages/gui/src/containers/extension-library.jsx +++ b/packages/gui/src/containers/extension-library.jsx @@ -4,8 +4,6 @@ import React from 'react'; import {ExtensionManager} from 'clipcc-extension'; import {defineMessages, injectIntl, intlShape} from 'react-intl'; -import extensionLibraryContent from '../lib/libraries/extensions/index.jsx'; - import LibraryComponent from '../components/library/library.jsx'; import extensionIcon from '../components/action-menu/icon--sprite.svg'; @@ -44,7 +42,7 @@ class ExtensionLibrary extends React.PureComponent { } } render () { - const extensionLibraryThumbnailData = extensionLibraryContent.map(extension => ({ + const extensionLibraryThumbnailData = this.props.extensionManager.getManifest().map(extension => ({ rawURL: extension.iconURL || extensionIcon, ...extension })); diff --git a/packages/gui/src/lib/vm-manager-hoc.jsx b/packages/gui/src/lib/vm-manager-hoc.jsx index 8f453ad5f..c83b579e4 100644 --- a/packages/gui/src/lib/vm-manager-hoc.jsx +++ b/packages/gui/src/lib/vm-manager-hoc.jsx @@ -7,6 +7,8 @@ import VM from 'clipcc-vm'; import AudioEngine from 'clipcc-audio'; import {ExtensionManager} from 'clipcc-extension'; +import extensionLibraryContent from '../lib/libraries/extensions/index.jsx'; + import {setProjectUnchanged} from '../reducers/project-changed'; import { LoadingStates, @@ -32,7 +34,7 @@ const vmManagerHOC = function (WrappedComponent) { if (!this.props.vm.initialized) { this.audioEngine = new AudioEngine(); this.props.vm.attachAudioEngine(this.audioEngine); - this.props.vm.attachExtensionManager(this.props.extensionManager); + this.props.vm.attachExtensionManager(this.props.extensionManager, extensionLibraryContent); this.props.vm.setCompatibilityMode(true); this.props.vm.initialized = true; this.props.vm.setLocale(this.props.locale, this.props.messages); @@ -138,6 +140,7 @@ const vmManagerHOC = function (WrappedComponent) { onLoadedProject: onLoadedProjectProp, onSetProjectUnchanged, projectData, + extensionManager, /* eslint-enable no-unused-vars */ isLoadingWithId: isLoadingWithIdProp, vm, diff --git a/packages/vm/src/virtual-machine.js b/packages/vm/src/virtual-machine.js index 93dbecc4a..65eb3154a 100644 --- a/packages/vm/src/virtual-machine.js +++ b/packages/vm/src/virtual-machine.js @@ -1229,8 +1229,10 @@ class VirtualMachine extends EventEmitter { /** * Attach the extension manager. * @param {!ExtensionManager} extensionManager The extension manager to attach + * @param {!Array} extensionLibraryContent Content for extension library + * (for test only, should be replace later) */ - attachExtensionManager (extensionManager) { + attachExtensionManager (extensionManager, extensionLibraryContent) { this.extensionManager = extensionManager; this.extensionManager.addEventListener('UPDATE_PRIMITIVES', this.extensionListener); @@ -1255,18 +1257,21 @@ class VirtualMachine extends EventEmitter { // Register builtin extensions. // @todo should be removed later to make builtin extension external. - for (const extensionId in builtinExtensions) { + for (const content of extensionLibraryContent) { + const extensionId = content.extensionId; + if (this.extensionManager.isExtensionLoaded(extensionId)) { log.error(`Duplicated builtin extension: ${extensionId}`); continue; } - const manifest = { - extensionId: extensionId - }; + if (!Object.hasOwnProperty.call(builtinExtensions, extensionId)) { + log.error(`Unexpected builtin extension: ${extensionId}`); + continue; + } this.extensionManager.loadExtension(new ScratchExtensionAdapter( - manifest, builtinExtensions[extensionId], this.runtime + content, builtinExtensions[extensionId], this.runtime )); } } From 8200fe17223ba1a255fd035cd44d6944a64bf557 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Mon, 9 Mar 2026 21:54:26 +0800 Subject: [PATCH 13/36] :sparkles: feat(ext): support for dynamic blocks Signed-off-by: Alex Cui --- .../extension/src/adapter/scratch/adapter.ts | 30 +++-- .../adapter/scratch/define-dynamic-block.ts | 121 ++++++++++++++++++ .../scratch/types/scratch-blocks-constants.ts | 22 ++++ packages/extension/src/events.ts | 3 +- packages/gui/src/containers/blocks.jsx | 6 +- 5 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 packages/extension/src/adapter/scratch/define-dynamic-block.ts create mode 100644 packages/extension/src/adapter/scratch/types/scratch-blocks-constants.ts diff --git a/packages/extension/src/adapter/scratch/adapter.ts b/packages/extension/src/adapter/scratch/adapter.ts index 992039062..c9163637d 100644 --- a/packages/extension/src/adapter/scratch/adapter.ts +++ b/packages/extension/src/adapter/scratch/adapter.ts @@ -23,6 +23,7 @@ import type ArgumentType from './types/argument-type'; import type {ExtensionManager} from '../../extension-manager'; import TargetType from './types/target-type'; import {UpdateBlocksEvent, UpdatePrimitivesEvent} from '../../events'; +import defineDynamicBlock from './define-dynamic-block'; interface ScratchExtension { /** @@ -54,7 +55,7 @@ interface ConvertedBlockInfo { /** The raw block info. */ info: ExtensionBlockMetadata; /** The scratch-blocks JSON definition for this block. */ - json: object; + json: any; /** The scratch-blocks XML definition for this block. */ xml: string; } @@ -113,6 +114,19 @@ function maybeFormatMessage(maybeMessage: any, args?: object, locale?: string): return maybeMessage; } +/** + * Define a block with given JSON content. + * @param json JSON content of block. + * @returns Block definition. + */ +function defineStaticBlock(json: any) { + return { + init(this: any) { + this.jsonInit(json); + } + }; +} + /** * Adapter to load scratch extension. */ @@ -316,9 +330,9 @@ export class ScratchExtensionAdapter implements IExtension { ) ); - const updateBlocksPayload: UpdateBlocksEvent = { + const payload: UpdateBlocksEvent = { type: 'UPDATE_BLOCKS', - blocks: [], + blocks: Object.create(null), fields: [] }; @@ -328,19 +342,17 @@ export class ScratchExtensionAdapter implements IExtension { // This is creating the block factory / constructor -- NOT a specific instance of the block. // The factory should only know static info about the block: the category info and the opcode. // Anything else will be picked up from the XML attached to the block instance. - // const extendedOpcode = `${categoryInfo.id}_${blockInfo.info.opcode}`; - // const blockDefinition = - // defineDynamicBlock(this.ScratchBlocks, categoryInfo, blockInfo, extendedOpcode); - // this.ScratchBlocks.Blocks[extendedOpcode] = blockDefinition; + const extendedOpcode = `${categoryInfo.id}_${blockInfo.info.opcode}`; + payload.blocks[extendedOpcode] = defineDynamicBlock(categoryInfo, blockInfo, extendedOpcode); } else if (blockInfo.json) { // Static blocks. - updateBlocksPayload.blocks.push(blockInfo.json); + payload.blocks[blockInfo.json.type] = defineStaticBlock(blockInfo.json); } // otherwise it's a non-block entry such as '---' }); } - this.manager!.emitEvent(updateBlocksPayload); + this.manager!.emitEvent(payload); } private buildToolboxXML(categoryInfo: CategoryInfo, isStage: boolean): string { diff --git a/packages/extension/src/adapter/scratch/define-dynamic-block.ts b/packages/extension/src/adapter/scratch/define-dynamic-block.ts new file mode 100644 index 000000000..eff796b6a --- /dev/null +++ b/packages/extension/src/adapter/scratch/define-dynamic-block.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2019 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ + +import type * as Blocks from 'clipcc-block'; // @todo for type checker only. + +// TODO: access `BlockType` and `ArgumentType` without reaching into VM +// Should we move these into a new extension support module or something? +import BlockType from './types/block-type'; +import ArgumentType from './types/argument-type'; +import ScratchBlocksConstants from './types/scratch-blocks-constants'; + +interface DynamicBlock extends Blocks.Block, Blocks.ICheckboxInFlyout { + blockInfoText: string; + needsBlockInfoUpdate?: boolean; +} + +/** + * Define a block using extension info which has the ability to dynamically determine (and update) its layout. + * This functionality is used for extension blocks which can change its properties based on different state + * information. For example, the `control_stop` block changes its shape based on which menu item is selected + * and a variable block changes its text to reflect the variable name without using an editable field. + * @param categoryInfo - Information about this block's extension category, including any menus and icons. + * @param staticBlockInfo - The base block information before any dynamic changes. + * @param extendedOpcode - The opcode for the block (including the extension ID). + */ +// TODO: grow this until it can fully replace `_convertForScratchBlocks` in the VM runtime +const defineDynamicBlock = (categoryInfo: any, staticBlockInfo: any, extendedOpcode: string) => ({ + init: function (this: DynamicBlock) { + const blockJson: any = { + type: extendedOpcode, + inputsInline: true, + category: categoryInfo.name, + colour: categoryInfo.color1, + colourSecondary: categoryInfo.color2, + colourTertiary: categoryInfo.color3 + }; + // There is a scratch-blocks / Blockly extension called "scratch_extension" which adjusts the styling of + // blocks to allow for an icon, a feature of Scratch extension blocks. However, Scratch "core" extension + // blocks don't have icons and so they should not use 'scratch_extension'. Adding a scratch-blocks / Blockly + // extension after `jsonInit` isn't fully supported (?), so we decide now whether there will be an icon. + if (staticBlockInfo.blockIconURI || categoryInfo.blockIconURI) { + blockJson.extensions = ['scratch_extension']; + } + // initialize the basics of the block, to be overridden & extended later by `domToMutation` + this.jsonInit(blockJson); + // initialize the cached block info used to carry block info from `domToMutation` to `mutationToDom` + this.blockInfoText = '{}'; + // we need a block info update (through `domToMutation`) before we have a completely initialized block + this.needsBlockInfoUpdate = true; + }, + mutationToDom: function (this: DynamicBlock) { + const container = document.createElement('mutation'); + container.setAttribute('blockInfo', this.blockInfoText); + return container; + }, + domToMutation: function (this: DynamicBlock, xmlElement: Element) { + const blockInfoText = xmlElement.getAttribute('blockInfo'); + if (!blockInfoText) return; + if (!this.needsBlockInfoUpdate) { + throw new Error('Attempted to update block info twice'); + } + delete this.needsBlockInfoUpdate; + this.blockInfoText = blockInfoText; + const blockInfo = JSON.parse(blockInfoText); + + switch (blockInfo.blockType) { + case BlockType.COMMAND: + case BlockType.CONDITIONAL: + case BlockType.LOOP: + this.setOutputShape(ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE); + this.setPreviousStatement(true); + this.setNextStatement(!blockInfo.isTerminal); + break; + case BlockType.REPORTER: + this.setOutput(true); + this.setOutputShape(ScratchBlocksConstants.OUTPUT_SHAPE_ROUND); + if (!blockInfo.disableMonitor) { + this.checkboxInFlyout = true; + } + break; + case BlockType.BOOLEAN: + this.setOutput(true); + this.setOutputShape(ScratchBlocksConstants.OUTPUT_SHAPE_HEXAGONAL); + break; + case BlockType.HAT: + case BlockType.EVENT: + this.setOutputShape(ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE); + this.setNextStatement(true); + break; + } + + if (blockInfo.color1 || blockInfo.color2 || blockInfo.color3) { + // `setColour` handles undefined parameters by adjusting defined colors + this.setColour(blockInfo.color1); + } + + // Layout block arguments + // TODO handle E/C Blocks + const blockText = blockInfo.text as string; + const args: any[] = []; + let argCount = 0; + const scratchBlocksStyleText = blockText.replace(/\[(.+?)]/g, (match, argName) => { + const arg = blockInfo.arguments[argName]; + switch (arg.type) { + case ArgumentType.STRING: + args.push({type: 'input_value', name: argName}); + break; + case ArgumentType.BOOLEAN: + args.push({type: 'input_value', name: argName, check: 'Boolean'}); + break; + } + return `%${++argCount}`; + }); + this['interpolate'](scratchBlocksStyleText, args); + } +}); + +export default defineDynamicBlock; diff --git a/packages/extension/src/adapter/scratch/types/scratch-blocks-constants.ts b/packages/extension/src/adapter/scratch/types/scratch-blocks-constants.ts new file mode 100644 index 000000000..3f28dcc1f --- /dev/null +++ b/packages/extension/src/adapter/scratch/types/scratch-blocks-constants.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2018 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * These constants are copied from scratch-blocks/core/constants.js + * @TODO find a way to require() these straight from scratch-blocks... maybe make a scratch-blocks/dist/constants.js? + */ +enum ScratchBlocksConstants { + /** ENUM for output shape: hexagonal (booleans/predicates). */ + OUTPUT_SHAPE_HEXAGONAL = 1, + + /** ENUM for output shape: rounded (numbers). */ + OUTPUT_SHAPE_ROUND = 2, + + /** ENUM for output shape: squared (any/all values; strings). */ + OUTPUT_SHAPE_SQUARE = 3 +}; + +export default ScratchBlocksConstants; diff --git a/packages/extension/src/events.ts b/packages/extension/src/events.ts index 9a1b8db8f..b8147ae9d 100644 --- a/packages/extension/src/events.ts +++ b/packages/extension/src/events.ts @@ -21,6 +21,7 @@ export interface UpdatePrimitivesEvent extends AbstractEvent { export interface UpdateBlocksEvent extends AbstractEvent { type: 'UPDATE_BLOCKS'; - blocks: any[]; + /** Map of block definitions. */ + blocks: Record; fields: any[]; } diff --git a/packages/gui/src/containers/blocks.jsx b/packages/gui/src/containers/blocks.jsx index a517a9425..2535e067e 100644 --- a/packages/gui/src/containers/blocks.jsx +++ b/packages/gui/src/containers/blocks.jsx @@ -594,8 +594,12 @@ class Blocks extends React.Component { this.props.vm.setEditingTarget(target.id); } + /** + * Event handler for updating block definitions. + * @param {import('clipcc-extension').UpdateBlocksEvent} event Event payload. + */ handleExtensionUpdateBlocks (event) { - this.ScratchBlocks.defineBlocksWithJsonArray(event.blocks); + this.ScratchBlocks.common.defineBlocks(event.blocks); // Update the toolbox. const toolbox = this.getToolbox(); From bc28e77356da453d77f4e8e8e6768357584a1475 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Mon, 9 Mar 2026 22:24:02 +0800 Subject: [PATCH 14/36] :sparkles: feat(ext): incomplete support for custom field type Signed-off-by: Alex Cui --- packages/extension/src/adapter/scratch/adapter.ts | 8 +++++++- packages/extension/src/events.ts | 3 ++- packages/gui/src/containers/blocks.jsx | 2 ++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/adapter/scratch/adapter.ts b/packages/extension/src/adapter/scratch/adapter.ts index c9163637d..6910753e3 100644 --- a/packages/extension/src/adapter/scratch/adapter.ts +++ b/packages/extension/src/adapter/scratch/adapter.ts @@ -333,7 +333,7 @@ export class ScratchExtensionAdapter implements IExtension { const payload: UpdateBlocksEvent = { type: 'UPDATE_BLOCKS', blocks: Object.create(null), - fields: [] + fields: Object.create(null) }; if (blockInfoArray.length > 0) { @@ -352,6 +352,12 @@ export class ScratchExtensionAdapter implements IExtension { }); } + for (const fieldTypeName in categoryInfo.customFieldTypes) { + const fieldTypeInfo: CustomFieldInfo = categoryInfo.customFieldTypes[fieldTypeName]; + const fieldName = `field_${fieldTypeInfo.extendedName}`; + payload.fields[fieldName] = fieldTypeInfo.fieldImplementation; + } + this.manager!.emitEvent(payload); } diff --git a/packages/extension/src/events.ts b/packages/extension/src/events.ts index b8147ae9d..b04458660 100644 --- a/packages/extension/src/events.ts +++ b/packages/extension/src/events.ts @@ -23,5 +23,6 @@ export interface UpdateBlocksEvent extends AbstractEvent { type: 'UPDATE_BLOCKS'; /** Map of block definitions. */ blocks: Record; - fields: any[]; + /** Map of custom field implementations. */ + fields: Record; } diff --git a/packages/gui/src/containers/blocks.jsx b/packages/gui/src/containers/blocks.jsx index cdaa5d04f..1f318e5f6 100644 --- a/packages/gui/src/containers/blocks.jsx +++ b/packages/gui/src/containers/blocks.jsx @@ -606,6 +606,8 @@ class Blocks extends React.Component { handleExtensionUpdateBlocks (event) { this.ScratchBlocks.common.defineBlocks(event.blocks); + // @todo support for custom field type + // Update the toolbox. const toolbox = this.getToolbox(); if (toolbox) { From 03d8ebee9a7873211435f88b8d262a0deed73292 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Mon, 9 Mar 2026 22:47:11 +0800 Subject: [PATCH 15/36] :fire: chore(gui): remove unused function for extension loading Signed-off-by: Alex Cui --- packages/gui/src/containers/blocks.jsx | 51 -------------------------- 1 file changed, 51 deletions(-) diff --git a/packages/gui/src/containers/blocks.jsx b/packages/gui/src/containers/blocks.jsx index 1f318e5f6..b9d78adc2 100644 --- a/packages/gui/src/containers/blocks.jsx +++ b/packages/gui/src/containers/blocks.jsx @@ -73,8 +73,6 @@ class Blocks extends React.Component { 'onBlockGlowOn', 'onBlockGlowOff', 'handleMonitorsUpdate', - 'handleExtensionAdded', - 'handleBlocksInfoUpdate', 'onTargetsUpdate', 'onVisualReport', 'onWorkspaceUpdate', @@ -309,8 +307,6 @@ class Blocks extends React.Component { this.props.vm.on('workspaceUpdate', this.onWorkspaceUpdate); this.props.vm.on('targetsUpdate', this.onTargetsUpdate); this.props.vm.on('MONITORS_UPDATE', this.handleMonitorsUpdate); - this.props.vm.on('EXTENSION_ADDED', this.handleExtensionAdded); - this.props.vm.on('BLOCKSINFO_UPDATE', this.handleBlocksInfoUpdate); this.props.vm.on('PERIPHERAL_CONNECTED', this.handleStatusButtonUpdate); this.props.vm.on('PERIPHERAL_DISCONNECTED', this.handleStatusButtonUpdate); } @@ -329,8 +325,6 @@ class Blocks extends React.Component { this.props.vm.off('workspaceUpdate', this.onWorkspaceUpdate); this.props.vm.off('targetsUpdate', this.onTargetsUpdate); this.props.vm.off('MONITORS_UPDATE', this.handleMonitorsUpdate); - this.props.vm.off('EXTENSION_ADDED', this.handleExtensionAdded); - this.props.vm.off('BLOCKSINFO_UPDATE', this.handleBlocksInfoUpdate); this.props.vm.off('PERIPHERAL_CONNECTED', this.handleStatusButtonUpdate); this.props.vm.off('PERIPHERAL_DISCONNECTED', this.handleStatusButtonUpdate); } @@ -475,51 +469,6 @@ class Blocks extends React.Component { } } } - handleExtensionAdded (categoryInfo) { - const defineBlocks = blockInfoArray => { - if (blockInfoArray && blockInfoArray.length > 0) { - const staticBlocksJson = []; - const dynamicBlocksInfo = []; - blockInfoArray.forEach(blockInfo => { - if (blockInfo.info && blockInfo.info.isDynamic) { - dynamicBlocksInfo.push(blockInfo); - } else if (blockInfo.json) { - staticBlocksJson.push(injectExtensionBlockTheme(blockInfo.json, this.props.theme)); - } - // otherwise it's a non-block entry such as '---' - }); - - this.ScratchBlocks.defineBlocksWithJsonArray(staticBlocksJson); - dynamicBlocksInfo.forEach(blockInfo => { - // This is creating the block factory / constructor -- NOT a specific instance of the block. - // The factory should only know static info about the block: the category info and the opcode. - // Anything else will be picked up from the XML attached to the block instance. - const extendedOpcode = `${categoryInfo.id}_${blockInfo.info.opcode}`; - const blockDefinition = - defineDynamicBlock(this.ScratchBlocks, categoryInfo, blockInfo, extendedOpcode); - this.ScratchBlocks.Blocks[extendedOpcode] = blockDefinition; - }); - } - }; - - // scratch-blocks implements a menu or custom field as a special kind of block ("shadow" block) - // these actually define blocks and MUST run regardless of the UI state - defineBlocks( - Object.getOwnPropertyNames(categoryInfo.customFieldTypes) - .map(fieldTypeName => categoryInfo.customFieldTypes[fieldTypeName].scratchBlocksDefinition)); - defineBlocks(categoryInfo.menus); - defineBlocks(categoryInfo.blocks); - - // Update the toolbox with new blocks if possible - const toolbox = this.getToolbox(); - if (toolbox) { - this.props.updateToolboxState(toolbox); - } - } - handleBlocksInfoUpdate (categoryInfo) { - // @todo Later we should replace this to avoid all the warnings from redefining blocks. - this.handleExtensionAdded(categoryInfo); - } handleCategorySelected (categoryId) { const extension = extensionData.find(ext => ext.extensionId === categoryId); if (extension && extension.launchPeripheralConnectionFlow) { From 6e9dda70f65e8ea8332e9b12de13fd852e5afe57 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Mon, 9 Mar 2026 23:29:39 +0800 Subject: [PATCH 16/36] :bug: fix(gui,block): blockly button callbacks for extension Signed-off-by: Alex Cui --- packages/block/src/index.ts | 2 ++ packages/gui/src/containers/blocks.jsx | 21 ++++++++------------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/block/src/index.ts b/packages/block/src/index.ts index b32a80a91..719d30752 100644 --- a/packages/block/src/index.ts +++ b/packages/block/src/index.ts @@ -231,6 +231,8 @@ export * from 'blockly/core'; export * as callbackRegistry from './callback_registry'; export * as constants from './constants'; export * as scratchBlocksUtils from './utils'; +export * as DataCatagory from './variables'; +export * as ProceduresCategory from './procedures_category'; export {reportValue} from './report_value'; export {Colours} from './theme'; diff --git a/packages/gui/src/containers/blocks.jsx b/packages/gui/src/containers/blocks.jsx index b9d78adc2..affbb0ef1 100644 --- a/packages/gui/src/containers/blocks.jsx +++ b/packages/gui/src/containers/blocks.jsx @@ -117,19 +117,14 @@ class Blocks extends React.Component { // Register buttons under new callback keys for creating variables, // lists, and procedures from extensions. - // cc - These callbacks are the same as those in blockly, maybe unnecessary to register. - - // const toolboxWorkspace = this.workspace.getFlyout().getWorkspace(); - - // const varListButtonCallback = type => - // (() => this.ScratchBlocks.Variables.createVariable(this.workspace, null, type)); - // const procButtonCallback = () => { - // this.ScratchBlocks.Procedures.createProcedureDefCallback(this.workspace); - // }; - - // toolboxWorkspace.registerButtonCallback('MAKE_A_VARIABLE', varListButtonCallback('')); - // toolboxWorkspace.registerButtonCallback('MAKE_A_LIST', varListButtonCallback('list')); - // toolboxWorkspace.registerButtonCallback('MAKE_A_PROCEDURE', procButtonCallback); + const varListButtonCallback = type => + (() => this.ScratchBlocks.DataCatagory.createVariable(this.workspace, null, type)); + const procButtonCallback = () => { + this.ScratchBlocks.ProceduresCategory.createProcedureDefCallback(this.workspace); + }; + this.workspace.registerButtonCallback('MAKE_A_VARIABLE', varListButtonCallback('')); + this.workspace.registerButtonCallback('MAKE_A_LIST', varListButtonCallback('list')); + this.workspace.registerButtonCallback('MAKE_A_PROCEDURE', procButtonCallback); // Store the toolbox that is actually rendered. // This is used in componentDidUpdate instead of prevProps, because From 5b88cc8bda52f7212a97fe3c074ebf7476625ab3 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Sat, 21 Mar 2026 17:09:54 +0800 Subject: [PATCH 17/36] :wrench: chore(ext): update lockfile for clipcc-extension Signed-off-by: Alex Cui --- pnpm-lock.yaml | 157 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 119564432..81e9f6e08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,40 @@ importers: specifier: ^5.2.3 version: 5.2.3(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.105.4) + packages/extension: + dependencies: + eslint-config-clipcc: + specifier: workspace:* + version: link:../lint-config + format-message: + specifier: ^6.2.4 + version: 6.2.4 + tslog: + specifier: ^4.10.2 + version: 4.10.2 + devDependencies: + eslint: + specifier: ^10.0.2 + version: 10.1.0(jiti@2.6.1) + lodash.defaultsdeep: + specifier: ^4.6.1 + version: 4.6.1 + terser-webpack-plugin: + specifier: ^5.3.16 + version: 5.3.17(webpack@5.105.4) + ts-loader: + specifier: ^9.5.4 + version: 9.5.4(typescript@5.9.3)(webpack@5.105.4) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + webpack: + specifier: ^5.105.3 + version: 5.105.4(webpack-cli@6.0.1) + webpack-cli: + specifier: ^6.0.1 + version: 6.0.1(webpack@5.105.4) + packages/gui: dependencies: '@microbit/microbit-universal-hex': @@ -201,6 +235,9 @@ importers: clipcc-block: specifier: workspace:~ version: link:../block + clipcc-extension: + specifier: workspace:~ + version: link:../extension clipcc-l10n: specifier: workspace:~ version: link:../l10n @@ -1209,6 +1246,9 @@ importers: clipcc-block: specifier: workspace:~ version: link:../block + clipcc-extension: + specifier: workspace:~ + version: link:../extension clipcc-l10n: specifier: workspace:~ version: link:../l10n @@ -2246,14 +2286,26 @@ packages: resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-array@0.23.3': + resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/config-helpers@0.4.2': resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.5.3': + resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/core@0.17.0': resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.1.1': + resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/eslintrc@3.3.5': resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2270,10 +2322,18 @@ packages: resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@3.0.3': + resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/plugin-kit@0.4.1': resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2990,6 +3050,9 @@ packages: '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -5109,6 +5172,10 @@ packages: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint-utils@1.4.3: resolution: {integrity: sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==} engines: {node: '>=6'} @@ -5133,6 +5200,16 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@10.1.0: + resolution: {integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + eslint@6.8.0: resolution: {integrity: sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==} engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} @@ -9002,6 +9079,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tslog@4.10.2: + resolution: {integrity: sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==} + engines: {node: '>=16'} + tsutils@3.21.0: resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} @@ -10796,6 +10877,11 @@ snapshots: '@es-joy/resolve.exports@1.2.0': {} + '@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.6.1))': + dependencies: + eslint: 10.1.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -10811,14 +10897,30 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint/config-array@0.23.3': + dependencies: + '@eslint/object-schema': 3.0.3 + debug: 4.4.3 + minimatch: 10.2.4 + transitivePeerDependencies: + - supports-color + '@eslint/config-helpers@0.4.2': dependencies: '@eslint/core': 0.17.0 + '@eslint/config-helpers@0.5.3': + dependencies: + '@eslint/core': 1.1.1 + '@eslint/core@0.17.0': dependencies: '@types/json-schema': 7.0.15 + '@eslint/core@1.1.1': + dependencies: + '@types/json-schema': 7.0.15 + '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.14.0 @@ -10839,11 +10941,18 @@ snapshots: '@eslint/object-schema@2.1.7': {} + '@eslint/object-schema@3.0.3': {} + '@eslint/plugin-kit@0.4.1': dependencies: '@eslint/core': 0.17.0 levn: 0.4.1 + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.1.1 + levn: 0.4.1 + '@exodus/bytes@1.15.0': {} '@formatjs/ecma402-abstract@1.5.0': @@ -11922,6 +12031,8 @@ snapshots: '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.8': {} '@types/express-serve-static-core@4.19.8': @@ -14508,6 +14619,13 @@ snapshots: esrecurse: 4.3.0 estraverse: 5.3.0 + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + eslint-utils@1.4.3: dependencies: eslint-visitor-keys: 1.3.0 @@ -14522,6 +14640,43 @@ snapshots: eslint-visitor-keys@5.0.1: {} + eslint@10.1.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.3 + '@eslint/config-helpers': 0.5.3 + '@eslint/core': 1.1.1 + '@eslint/plugin-kit': 0.6.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.4 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + eslint@6.8.0: dependencies: '@babel/code-frame': 7.29.0 @@ -19101,6 +19256,8 @@ snapshots: tslib@2.8.1: {} + tslog@4.10.2: {} + tsutils@3.21.0(typescript@5.9.3): dependencies: tslib: 1.14.1 From d4904b5f5744d938978b60b994c2443fdfd52e6a Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Wed, 15 Apr 2026 00:57:25 +0800 Subject: [PATCH 18/36] :wrench: build: add clipcc-extension to RuleInheritancePlugin in gui Signed-off-by: Alex Cui --- packages/extension/package.json | 12 +++++++++--- packages/gui/webpack.config.js | 3 ++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index 0608b913f..7310df87e 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -4,10 +4,16 @@ "description": "Extension manager and interfaces for ClipCC", "author": "Clip Team", "license": "MPL-2.0", - "main": "./dist/node/clipcc-extension.js", - "browser": "./dist/web/clipcc-extension.js", - "types": "./dist/types/index.d.ts", "repository": "https://github.com/Clipteam/clipcc.git", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "webpack": "./src/index.ts", + "node": "./dist/node/clipcc-extension.js", + "browser": "./dist/web/clipcc-extension.js", + "default": "./dist/node/clipcc-extension.js" + } + }, "scripts": { "build": "rimraf dist && mkdirp dist && webpack --progress --color --bail", "lint": "eslint ." diff --git a/packages/gui/webpack.config.js b/packages/gui/webpack.config.js index d55460d00..3a9c0e4d7 100644 --- a/packages/gui/webpack.config.js +++ b/packages/gui/webpack.config.js @@ -109,7 +109,8 @@ const base = { path.resolve(__dirname, '../render'), path.resolve(__dirname, '../storage'), path.resolve(__dirname, '../svg-renderer'), - path.resolve(__dirname, '../vm') + path.resolve(__dirname, '../vm'), + path.resolve(__dirname, '../extension') ] }), new NodePolyfillPlugin(), From ee8928ca3fb83775c719a5da2d8d3b5ba2140960 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Wed, 15 Apr 2026 01:12:13 +0800 Subject: [PATCH 19/36] :bug: fix(ext): dependency and tsconfig errors Signed-off-by: Alex Cui --- packages/extension/package.json | 5 + packages/extension/tsconfig.json | 4 +- packages/extension/webpack.config.js | 6 +- pnpm-lock.yaml | 450 +++++++++++++++++++++++++-- 4 files changed, 439 insertions(+), 26 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index 7310df87e..a6f88810b 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -19,8 +19,13 @@ "lint": "eslint ." }, "devDependencies": { + "@types/node": "^25.6.0", + "clipcc-block": "workspace:~", "eslint": "^10.0.2", "lodash.defaultsdeep": "^4.6.1", + "mkdirp": "3.0.1", + "node-polyfill-webpack-plugin": "^3.0.0", + "rimraf": "^6.1.3", "terser-webpack-plugin": "^5.3.16", "ts-loader": "^9.5.4", "typescript": "^5.9.3", diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index 1b9c5d425..4a1e77dd9 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -29,13 +29,13 @@ /* Modules */ "module": "es2022", /* Specify what module code is generated. */ - // "rootDir": "./", /* Specify the root folder within your source files. */ + "rootDir": "./src", /* Specify the root folder within your source files. */ "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + "types": ["node"], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ diff --git a/packages/extension/webpack.config.js b/packages/extension/webpack.config.js index f7c0cfdbf..0826de82d 100644 --- a/packages/extension/webpack.config.js +++ b/packages/extension/webpack.config.js @@ -1,6 +1,7 @@ const defaultsDeep = require('lodash.defaultsdeep'); const path = require('path'); const TerserPlugin = require('terser-webpack-plugin'); +const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); const baseConfig = { mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', @@ -28,7 +29,10 @@ const baseConfig = { include: /\.min\.js$/ }) ] - } + }, + plugins: [ + new NodePolyfillPlugin() + ] }; module.exports = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22716f45e..a6f626c71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,10 +36,10 @@ importers: version: 0.2.1 '@changesets/cli': specifier: ^2.30.0 - version: 2.30.0(@types/node@25.5.2) + version: 2.30.0(@types/node@25.6.0) '@commitlint/cli': specifier: ^20.5.0 - version: 20.5.0(@types/node@25.5.2)(conventional-commits-parser@6.3.0)(typescript@5.9.3) + version: 20.5.0(@types/node@25.6.0)(conventional-commits-parser@6.3.0)(typescript@5.9.3) '@crowdin/cli': specifier: ^4.12.0 version: 4.14.0(encoding@0.1.13) @@ -94,7 +94,7 @@ importers: version: link:../lint-config tap: specifier: 21.0.1 - version: 21.0.1(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 21.0.1(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -193,12 +193,27 @@ importers: specifier: ^4.10.2 version: 4.10.2 devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + clipcc-block: + specifier: workspace:~ + version: link:../block eslint: specifier: ^10.0.2 version: 10.1.0(jiti@2.6.1) lodash.defaultsdeep: specifier: ^4.6.1 version: 4.6.1 + mkdirp: + specifier: 3.0.1 + version: 3.0.1 + node-polyfill-webpack-plugin: + specifier: ^3.0.0 + version: 3.0.0(webpack@5.105.4) + rimraf: + specifier: ^6.1.3 + version: 6.1.3 terser-webpack-plugin: specifier: ^5.3.16 version: 5.3.17(webpack@5.105.4) @@ -490,7 +505,7 @@ importers: version: 5.6.6(webpack@5.105.4) jest: specifier: 'catalog:' - version: 30.3.0(@types/node@25.5.2) + version: 30.3.0(@types/node@25.6.0) jest-environment-jsdom: specifier: ^30.3.0 version: 30.3.0 @@ -760,7 +775,7 @@ importers: version: 5.6.6(webpack@5.105.4) jest: specifier: 'catalog:' - version: 30.3.0(@types/node@25.5.2) + version: 30.3.0(@types/node@25.6.0) jest-canvas-mock: specifier: 2.3.1 version: 2.3.1 @@ -881,7 +896,7 @@ importers: version: 13.0.6 tap: specifier: ^21.0.1 - version: 21.0.1(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 21.0.1(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)(typescript@5.9.3) packages/render: dependencies: @@ -960,7 +975,7 @@ importers: version: 1.0.252 tap: specifier: 21.0.1 - version: 21.0.1(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 21.0.1(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)(typescript@5.9.3) terser-webpack-plugin: specifier: ^5.3.17 version: 5.3.17(webpack@5.105.4) @@ -1157,7 +1172,7 @@ importers: version: 1.0.252 tap: specifier: 21.0.1 - version: 21.0.1(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 21.0.1(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)(typescript@5.9.3) terser-webpack-plugin: specifier: ^5.3.17 version: 5.3.17(webpack@5.105.4) @@ -3145,6 +3160,9 @@ packages: '@types/node@25.5.2': resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -9219,6 +9237,9 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici-types@7.22.0: resolution: {integrity: sha512-RKZvifiL60xdsIuC80UY0dq8Z7DbJUV8/l2hOVbyZAxBzEeQU4Z58+4ZzJ6WN2Lidi9KzT5EbiGX+PI/UGYuRw==} @@ -10593,7 +10614,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.30.0(@types/node@25.5.2)': + '@changesets/cli@2.30.0(@types/node@25.6.0)': dependencies: '@changesets/apply-release-plan': 7.1.0 '@changesets/assemble-release-plan': 6.0.9 @@ -10609,7 +10630,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@25.5.2) + '@inquirer/external-editor': 1.0.3(@types/node@25.6.0) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 enquirer: 2.4.1 @@ -10707,11 +10728,11 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 - '@commitlint/cli@20.5.0(@types/node@25.5.2)(conventional-commits-parser@6.3.0)(typescript@5.9.3)': + '@commitlint/cli@20.5.0(@types/node@25.6.0)(conventional-commits-parser@6.3.0)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.5.0 '@commitlint/lint': 20.5.0 - '@commitlint/load': 20.5.0(@types/node@25.5.2)(typescript@5.9.3) + '@commitlint/load': 20.5.0(@types/node@25.6.0)(typescript@5.9.3) '@commitlint/read': 20.5.0(conventional-commits-parser@6.3.0) '@commitlint/types': 20.5.0 tinyexec: 1.0.4 @@ -10755,14 +10776,14 @@ snapshots: '@commitlint/rules': 20.5.0 '@commitlint/types': 20.5.0 - '@commitlint/load@20.5.0(@types/node@25.5.2)(typescript@5.9.3)': + '@commitlint/load@20.5.0(@types/node@25.6.0)(typescript@5.9.3)': dependencies: '@commitlint/config-validator': 20.5.0 '@commitlint/execute-rule': 20.0.0 '@commitlint/resolve-extends': 20.5.0 '@commitlint/types': 20.5.0 cosmiconfig: 9.0.1(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.2.0(@types/node@25.5.2)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.2.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) is-plain-obj: 4.1.0 lodash.mergewith: 4.6.2 picocolors: 1.1.1 @@ -11032,12 +11053,12 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inquirer/external-editor@1.0.3(@types/node@25.5.2)': + '@inquirer/external-editor@1.0.3(@types/node@25.6.0)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@isaacs/cliui@8.0.2': dependencies: @@ -11086,6 +11107,38 @@ snapshots: typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 + '@isaacs/ts-node-temp-fork-for-pr-2009@10.9.7(@types/node@25.6.0)(typescript@5.5.4)': + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node14': 14.1.8 + '@tsconfig/node16': 16.1.8 + '@tsconfig/node18': 18.2.6 + '@tsconfig/node20': 20.1.9 + '@types/node': 25.6.0 + acorn: 8.16.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.5.4 + v8-compile-cache-lib: 3.0.1 + + '@isaacs/ts-node-temp-fork-for-pr-2009@10.9.7(@types/node@25.6.0)(typescript@5.9.3)': + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node14': 14.1.8 + '@tsconfig/node16': 16.1.8 + '@tsconfig/node18': 18.2.6 + '@tsconfig/node20': 20.1.9 + '@types/node': 25.6.0 + acorn: 8.16.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -11148,7 +11201,7 @@ snapshots: '@jest/fake-timers': 30.3.0 '@jest/types': 30.3.0 '@types/jsdom': 21.1.7 - '@types/node': 25.5.0 + '@types/node': 25.5.2 jest-mock: 30.3.0 jest-util: 30.3.0 jsdom: 26.1.0(patch_hash=4725214219761e272bac827780047d8a6effa8b02b2c8516a14e1363bb08948b) @@ -11159,7 +11212,7 @@ snapshots: '@jest/fake-timers': 30.3.0 '@jest/types': 30.3.0 '@types/jsdom': 21.1.7 - '@types/node': 25.5.0 + '@types/node': 25.5.2 jest-mock: 30.3.0 jest-util: 30.3.0 jsdom: 28.1.0 @@ -11168,7 +11221,7 @@ snapshots: dependencies: '@jest/fake-timers': 30.3.0 '@jest/types': 30.3.0 - '@types/node': 25.5.0 + '@types/node': 25.5.2 jest-mock: 30.3.0 '@jest/expect-utils@29.7.0': @@ -11745,11 +11798,21 @@ snapshots: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) function-loop: 4.0.0 + '@tapjs/after-each@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + function-loop: 4.0.0 + '@tapjs/after@3.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': dependencies: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) is-actual-promise: 1.0.2 + '@tapjs/after@3.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + is-actual-promise: 1.0.2 + '@tapjs/asserts@4.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(react-dom@16.4.0(react@18.3.1))(react@18.3.1)': dependencies: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) @@ -11761,20 +11824,45 @@ snapshots: - react - react-dom + '@tapjs/asserts@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(react-dom@16.4.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/stack': 4.0.0 + is-actual-promise: 1.0.2 + tcompare: 9.0.0(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + trivial-deferred: 2.0.0 + transitivePeerDependencies: + - react + - react-dom + '@tapjs/before-each@4.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': dependencies: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) function-loop: 4.0.0 + '@tapjs/before-each@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + function-loop: 4.0.0 + '@tapjs/before@4.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': dependencies: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) is-actual-promise: 1.0.2 + '@tapjs/before@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + is-actual-promise: 1.0.2 + '@tapjs/chdir@3.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': dependencies: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/chdir@3.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/config@5.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@tapjs/test@4.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': dependencies: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) @@ -11785,6 +11873,16 @@ snapshots: tap-yaml: 4.0.0 walk-up-path: 4.0.0 + '@tapjs/config@5.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@tapjs/test@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/test': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + chalk: 5.6.2 + jackspeak: 4.2.3 + polite-json: 5.0.0 + tap-yaml: 4.0.0 + walk-up-path: 4.0.0 + '@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)': dependencies: '@tapjs/processinfo': 3.1.9 @@ -11806,6 +11904,27 @@ snapshots: - react - react-dom + '@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@tapjs/processinfo': 3.1.9 + '@tapjs/stack': 4.0.0 + '@tapjs/test': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + async-hook-domain: 4.0.1 + diff: 5.2.2 + is-actual-promise: 1.0.2 + minipass: 7.1.3 + signal-exit: 4.1.0 + tap-parser: 18.0.0 + tap-yaml: 4.0.0 + tcompare: 9.0.0(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + trivial-deferred: 2.0.0 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - react + - react-dom + '@tapjs/error-serdes@4.0.0': dependencies: minipass: 7.1.3 @@ -11814,18 +11933,34 @@ snapshots: dependencies: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/filter@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/fixture@4.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': dependencies: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) mkdirp: 3.0.1 rimraf: 6.0.1 + '@tapjs/fixture@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + mkdirp: 3.0.1 + rimraf: 6.0.1 + '@tapjs/intercept@4.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': dependencies: '@tapjs/after': 3.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) '@tapjs/stack': 4.0.0 + '@tapjs/intercept@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/after': 3.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/stack': 4.0.0 + '@tapjs/mock@4.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': dependencies: '@tapjs/after': 3.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) @@ -11834,6 +11969,14 @@ snapshots: resolve-import: 2.4.0 walk-up-path: 4.0.0 + '@tapjs/mock@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/after': 3.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/stack': 4.0.0 + resolve-import: 2.4.0 + walk-up-path: 4.0.0 + '@tapjs/node-serialize@4.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': dependencies: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) @@ -11841,6 +11984,13 @@ snapshots: '@tapjs/stack': 4.0.0 tap-parser: 18.0.0 + '@tapjs/node-serialize@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/error-serdes': 4.0.0 + '@tapjs/stack': 4.0.0 + tap-parser: 18.0.0 + '@tapjs/processinfo@3.1.9': dependencies: node-options-to-argv: 1.0.0 @@ -11873,6 +12023,30 @@ snapshots: - react-dom - utf-8-validate + '@tapjs/reporter@4.0.1(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@tapjs/test@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(react-dom@16.4.0(react@18.3.1))': + dependencies: + '@tapjs/config': 5.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@tapjs/test@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/stack': 4.0.0 + chalk: 5.6.2 + ink: 5.2.1(react@18.3.1) + minipass: 7.1.3 + ms: 2.1.3 + patch-console: 2.0.0 + prismjs-terminal: 1.2.4 + react: 18.3.1 + string-length: 6.0.0 + tap-parser: 18.0.0 + tap-yaml: 4.0.0 + tcompare: 9.0.0(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + transitivePeerDependencies: + - '@tapjs/test' + - '@types/react' + - bufferutil + - react-devtools-core + - react-dom + - utf-8-validate + '@tapjs/run@4.0.1(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)': dependencies: '@tapjs/after': 3.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) @@ -11917,6 +12091,50 @@ snapshots: - supports-color - utf-8-validate + '@tapjs/run@4.0.1(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@tapjs/after': 3.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/before': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/config': 5.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@tapjs/test@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/processinfo': 3.1.9 + '@tapjs/reporter': 4.0.1(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@tapjs/test@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(react-dom@16.4.0(react@18.3.1)) + '@tapjs/spawn': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/stdin': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/test': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + c8: 10.1.3 + chalk: 5.6.2 + chokidar: 3.6.0 + foreground-child: 3.3.1 + glob: 11.1.0 + minipass: 7.1.3 + mkdirp: 3.0.1 + opener: 1.5.2 + pacote: 18.0.6 + path-scurry: 2.0.2 + resolve-import: 2.4.0 + rimraf: 6.0.1 + semver: 7.7.4 + signal-exit: 4.1.0 + tap-parser: 18.0.0 + tap-yaml: 4.0.0 + tcompare: 9.0.0(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + trivial-deferred: 2.0.0 + which: 4.0.0 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - '@types/react' + - bluebird + - bufferutil + - monocart-coverage-reports + - react + - react-devtools-core + - react-dom + - supports-color + - utf-8-validate + '@tapjs/snapshot@4.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(react-dom@16.4.0(react@18.3.1))(react@18.3.1)': dependencies: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) @@ -11927,16 +12145,34 @@ snapshots: - react - react-dom + '@tapjs/snapshot@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(react-dom@16.4.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + is-actual-promise: 1.0.2 + tcompare: 9.0.0(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + trivial-deferred: 2.0.0 + transitivePeerDependencies: + - react + - react-dom + '@tapjs/spawn@4.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': dependencies: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/spawn@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/stack@4.0.0': {} '@tapjs/stdin@4.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': dependencies: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/stdin@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/test@4.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)': dependencies: '@isaacs/ts-node-temp-fork-for-pr-2009': 10.9.7(@types/node@25.5.2)(typescript@5.5.4) @@ -11975,6 +12211,44 @@ snapshots: - react - react-dom + '@tapjs/test@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@isaacs/ts-node-temp-fork-for-pr-2009': 10.9.7(@types/node@25.6.0)(typescript@5.5.4) + '@tapjs/after': 3.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/after-each': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/asserts': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/before': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/before-each': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/chdir': 3.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/filter': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/fixture': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/intercept': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/mock': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/node-serialize': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/snapshot': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/spawn': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/stdin': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/typescript': 3.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(typescript@5.5.4) + '@tapjs/worker': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + glob: 11.1.0 + jackspeak: 4.2.3 + mkdirp: 3.0.1 + package-json-from-dist: 1.0.1 + resolve-import: 2.4.0 + rimraf: 6.0.1 + sync-content: 2.0.4 + tap-parser: 18.0.0 + tshy: 3.3.2 + typescript: 5.5.4 + walk-up-path: 4.0.0 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - react + - react-dom + '@tapjs/typescript@3.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.5.2)(typescript@5.5.4)': dependencies: '@isaacs/ts-node-temp-fork-for-pr-2009': 10.9.7(@types/node@25.5.2)(typescript@5.5.4) @@ -11995,10 +12269,34 @@ snapshots: - '@types/node' - typescript + '@tapjs/typescript@3.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(typescript@5.5.4)': + dependencies: + '@isaacs/ts-node-temp-fork-for-pr-2009': 10.9.7(@types/node@25.6.0)(typescript@5.5.4) + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - typescript + + '@tapjs/typescript@3.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(typescript@5.9.3)': + dependencies: + '@isaacs/ts-node-temp-fork-for-pr-2009': 10.9.7(@types/node@25.6.0)(typescript@5.9.3) + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - typescript + '@tapjs/worker@4.0.0(@tapjs/core@4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': dependencies: '@tapjs/core': 4.0.0(@types/node@25.5.2)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/worker@4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))': + dependencies: + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@testim/chrome-version@1.1.4': {} '@tootallnate/quickjs-emscripten@0.23.0': {} @@ -12134,7 +12432,7 @@ snapshots: '@types/jsdom@28.0.0': dependencies: - '@types/node': 12.20.55 + '@types/node': 25.5.2 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 undici-types: 7.22.0 @@ -12159,6 +12457,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + '@types/parse-json@4.0.2': {} '@types/qs@6.15.0': {} @@ -13786,9 +14088,9 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig-typescript-loader@6.2.0(@types/node@25.5.2)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.2.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 cosmiconfig: 9.0.1(typescript@5.9.3) jiti: 2.6.1 typescript: 5.9.3 @@ -16253,6 +16555,25 @@ snapshots: - supports-color - ts-node + jest-cli@30.3.0(@types/node@25.6.0): + dependencies: + '@jest/core': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.3.0(@types/node@25.6.0) + jest-util: 30.3.0 + jest-validate: 30.3.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jest-config@30.3.0(@types/node@25.5.0): dependencies: '@babel/core': 7.29.0 @@ -16315,6 +16636,37 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@30.3.0(@types/node@25.6.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.3.0 + '@jest/types': 30.3.0 + babel-jest: 30.3.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.3.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-runner: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + parse-json: 5.2.0 + pretty-format: 30.3.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 25.6.0 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -16598,7 +16950,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -16636,6 +16988,19 @@ snapshots: - supports-color - ts-node + jest@30.3.0(@types/node@25.6.0): + dependencies: + '@jest/core': 30.3.0 + '@jest/types': 30.3.0 + import-local: 3.2.0 + jest-cli: 30.3.0(@types/node@25.6.0) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jiti@2.6.1: {} jpeg-js@0.4.4: {} @@ -19126,6 +19491,43 @@ snapshots: - typescript - utf-8-validate + tap@21.0.1(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)(typescript@5.9.3): + dependencies: + '@tapjs/after': 3.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/after-each': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/asserts': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/before': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/before-each': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/chdir': 3.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/core': 4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/filter': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/fixture': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/intercept': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/mock': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/node-serialize': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/run': 4.0.1(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/snapshot': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/spawn': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/stdin': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + '@tapjs/test': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1) + '@tapjs/typescript': 3.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1))(@types/node@25.6.0)(typescript@5.9.3) + '@tapjs/worker': 4.0.0(@tapjs/core@4.0.0(@types/node@25.6.0)(react-dom@16.4.0(react@18.3.1))(react@18.3.1)) + resolve-import: 2.4.0 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - '@types/react' + - bluebird + - bufferutil + - monocart-coverage-reports + - react + - react-devtools-core + - react-dom + - supports-color + - typescript + - utf-8-validate + tapable@0.1.10: {} tapable@2.3.0: {} @@ -19505,6 +19907,8 @@ snapshots: undici-types@7.18.2: {} + undici-types@7.19.2: {} + undici-types@7.22.0: {} undici@7.22.0: {} From f6ff96333af7d8f250660b7dfcf9d210698fa7e3 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Wed, 15 Apr 2026 01:14:48 +0800 Subject: [PATCH 20/36] :wrench: chore(block): export interfaces and tool functions Signed-off-by: Alex Cui --- packages/block/src/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/block/src/index.ts b/packages/block/src/index.ts index 281aa3011..1f6853e88 100644 --- a/packages/block/src/index.ts +++ b/packages/block/src/index.ts @@ -253,3 +253,9 @@ export { FieldVariableGetter, FieldVerticalSeparator }; + +export {ICheckboxInFlyout, isCheckboxInFlyout} from './interfaces/i_checkbox_in_flyout'; +export {IDynamicDeletable, isDynamicDeletable} from './interfaces/i_dynamic_deletable'; +export {IInvisibleIcon, isInvisibleIcon} from './interfaces/i_invisible_icon'; +export {IScratchExtensionBlock, isScratchExtensionBlock} from './interfaces/i_scratch_extension'; +export {IShadowTemplate, isShadowTemplate} from './interfaces/i_shadow_template'; From 08c0321aa92907bb7996da72ba9b718334c00148 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Wed, 15 Apr 2026 16:31:24 +0800 Subject: [PATCH 21/36] :wrench: build(ext): polyfill events only Signed-off-by: Alex Cui --- packages/extension/webpack.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/extension/webpack.config.js b/packages/extension/webpack.config.js index 0826de82d..b9a887f97 100644 --- a/packages/extension/webpack.config.js +++ b/packages/extension/webpack.config.js @@ -31,7 +31,9 @@ const baseConfig = { ] }, plugins: [ - new NodePolyfillPlugin() + new NodePolyfillPlugin({ + includeAliases: ['events'] + }) ] }; From a82818d6bb91015bb9f1e90a7c077ca3eb79b4a2 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Wed, 15 Apr 2026 19:58:48 +0800 Subject: [PATCH 22/36] :sparkles: feat(ext): split ScratchBuiltinAdapter Signed-off-by: Alex Cui --- .../extension/src/adapter/scratch/adapter.ts | 65 +++++++------------ .../src/adapter/scratch/builtin-adapter.ts | 53 +++++++++++++++ .../scratch/types/scratch-extension.ts | 26 ++++++++ packages/extension/src/index.ts | 2 +- packages/vm/src/virtual-machine.js | 4 +- 5 files changed, 107 insertions(+), 43 deletions(-) create mode 100644 packages/extension/src/adapter/scratch/builtin-adapter.ts create mode 100644 packages/extension/src/adapter/scratch/types/scratch-extension.ts diff --git a/packages/extension/src/adapter/scratch/adapter.ts b/packages/extension/src/adapter/scratch/adapter.ts index 6910753e3..1366f9d0c 100644 --- a/packages/extension/src/adapter/scratch/adapter.ts +++ b/packages/extension/src/adapter/scratch/adapter.ts @@ -25,19 +25,6 @@ import TargetType from './types/target-type'; import {UpdateBlocksEvent, UpdatePrimitivesEvent} from '../../events'; import defineDynamicBlock from './define-dynamic-block'; -interface ScratchExtension { - /** - * Get metadata of the extension. - * @returns Metadata for this extension and its blocks. - */ - getInfo(): ExtensionMetadata; - - /** Other methods and properties. */ - [key: string]: unknown; -} - -type ScratchExtensionClass = new (runtime: any) => ScratchExtension; - /** * Information about an extension block argument. */ @@ -130,25 +117,20 @@ function defineStaticBlock(json: any) { /** * Adapter to load scratch extension. */ -export class ScratchExtensionAdapter implements IExtension { +export abstract class ScratchBaseAdapter implements IExtension { /** Extension manager. */ - private manager: ExtensionManager | null = null; + private manager!: ExtensionManager; /** Whether the extension is enabled. */ private enabled: boolean = false; - /** Instance of extension object. */ - private instance: ScratchExtension | null = null; - /** * @param manifest Manifest for extension library to display info. - * @param extensionModule Extension module that returns the extension class. * @param runtime Runtime object of virtual machine. */ constructor( - private manifest: ExtensionManifest, - private extensionModule: () => ScratchExtensionClass, - private runtime: any + protected manifest: ExtensionManifest, + protected runtime: any ) {} /** @@ -187,14 +169,13 @@ export class ScratchExtensionAdapter implements IExtension { /** * Enable the extension. + * Derived adapters should override this function to instantiate the extension. */ enable(): void { - const ExtensionClass = this.extensionModule(); - this.instance = new ExtensionClass(this.runtime); this.enabled = true; try { - const extensionInfo = this.prepareExtensionInfo(this.instance.getInfo()); + const extensionInfo = this.prepareExtensionInfo(this.getInfo()); const categoryInfo = this.buildCategoryInfo(extensionInfo); this.registerExtensionPrimitives(extensionInfo, categoryInfo); this.registerBlocks(categoryInfo); @@ -216,7 +197,7 @@ export class ScratchExtensionAdapter implements IExtension { * The method should only be called when extension is enabled. */ getToolboxContents(isStage: boolean): any { - const extensionInfo = this.prepareExtensionInfo(this.instance!.getInfo()); + const extensionInfo = this.prepareExtensionInfo(this.getInfo()); const categoryInfo = this.buildCategoryInfo(extensionInfo); return { id: this.getId(), @@ -224,14 +205,20 @@ export class ScratchExtensionAdapter implements IExtension { }; } - private callExtensionMethod(method: string, ...args: any[]): any { - if (this.instance && method in this.instance && typeof this.instance[method] === 'function') { - return this.instance[method](...args); - } + /** + * Call getInfo from extension instance. Will only be called after instantiated. + * Should be implemented by derived adapters. + */ + protected abstract getInfo(): ExtensionMetadata; - logger.warn(`Could not find extension block function called ${method}`); - return undefined; - } + /** + * Call method by name and given arguments. Will only be called after instantiated. + * Should be implemented by derived adapters. + * @param method Method name. + * @param args Arguments passed to method. + * @returns Result of calling the method, or undefined if no valid method is found. + */ + protected abstract callMethod(method: string, ...args: Args): R | undefined; private buildCategoryInfo(extensionInfo: ProcessedExtensionMetadata): CategoryInfo { const categoryInfo = { @@ -318,7 +305,7 @@ export class ScratchExtensionAdapter implements IExtension { } } - this.manager!.emitEvent(updatePrimitivesPayload); + this.manager.emitEvent(updatePrimitivesPayload); } private registerBlocks(categoryInfo: CategoryInfo): void { @@ -358,7 +345,7 @@ export class ScratchExtensionAdapter implements IExtension { payload.fields[fieldName] = fieldTypeInfo.fieldImplementation; } - this.manager!.emitEvent(payload); + this.manager.emitEvent(payload); } private buildToolboxXML(categoryInfo: CategoryInfo, isStage: boolean): string { @@ -493,10 +480,8 @@ export class ScratchExtensionAdapter implements IExtension { const editingTargetID = editingTarget ? editingTarget.id : null; const extensionMessageContext = this.runtime.makeMessageContextForTarget(editingTarget); - // TODO: Fix this to use dispatch.call when extensions are running in workers. - const menuFunc = this.instance![menuItemFunctionName] as (...args: any) => ExtensionMenuItems; - const menuItems = menuFunc.call(this.instance, editingTargetID).map<[string, string]>( - (item) => { + const menuItems = this.callMethod(menuItemFunctionName, editingTargetID)! + .map<[string, string]>((item) => { item = maybeFormatMessage(item, extensionMessageContext); switch (typeof item) { case 'object': @@ -555,7 +540,7 @@ export class ScratchExtensionAdapter implements IExtension { (args: Record) => args && args.mutation && args.mutation.blockInfo : () => blockInfo; const callBlockFunc = (args: Record, util: any, realBlockInfo: ExtensionBlockMetadata) => { - return this.callExtensionMethod(funcName, args, util, realBlockInfo); + return this.callMethod(funcName, args, util, realBlockInfo); }; (blockInfo as ProcessedExtensionBlockMetadata).func = (args: Record, util: any) => { diff --git a/packages/extension/src/adapter/scratch/builtin-adapter.ts b/packages/extension/src/adapter/scratch/builtin-adapter.ts new file mode 100644 index 000000000..d9eb80cf2 --- /dev/null +++ b/packages/extension/src/adapter/scratch/builtin-adapter.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2017 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ + +import logger from '../../utils/logger'; +import ExtensionManifest from '../../interfaces/extension-manifest'; +import {IExtension} from '../../interfaces/i_extension'; +import {ScratchBaseAdapter} from './adapter'; +import {ExtensionMetadata} from './types/extension-metadata'; +import {ScratchExtension, ScratchExtensionClass} from './types/scratch-extension'; + +export class ScratchBuiltinAdapter extends ScratchBaseAdapter { + /** Instance of extension object. */ + protected instance!: ScratchExtension; + + /** + * @param manifest Manifest for extension library to display info. + * @param module Function that returns a constructor of Scratch extension. + * @param runtime Runtime object of virtual machine. + */ + constructor( + manifest: ExtensionManifest, + protected module: () => ScratchExtensionClass, + runtime: any + ) { + super(manifest, runtime); + } + + override enable(): void { + const ExtensionClass = this.module(); + this.instance = new ExtensionClass(this.runtime); + + super.enable(); + } + + protected override getInfo(): ExtensionMetadata { + return this.instance.getInfo(); + } + + protected override callMethod(method: string, ...args: Args): R | undefined { + if ( + method in this.instance && + typeof this.instance[method] === 'function' + ) { + return this.instance[method](...args); + } + + logger.error(`Could not find function "${method}" in ${this.getId()}`); + return undefined; + } +} diff --git a/packages/extension/src/adapter/scratch/types/scratch-extension.ts b/packages/extension/src/adapter/scratch/types/scratch-extension.ts new file mode 100644 index 000000000..dc2714946 --- /dev/null +++ b/packages/extension/src/adapter/scratch/types/scratch-extension.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +import {ExtensionMetadata} from './extension-metadata'; + +/** + * Definition of Scratch extension class. + */ +export interface ScratchExtension { + /** + * Get metadata of the extension. + * @returns Metadata for this extension and its blocks. + */ + getInfo(): ExtensionMetadata; + + /** Other methods and properties. */ + [key: string]: unknown; +} + +/** + * Constructor for ScratchExtension. + */ +export type ScratchExtensionClass = new (runtime: any) => ScratchExtension; diff --git a/packages/extension/src/index.ts b/packages/extension/src/index.ts index e7d571b6a..bbf6d0be5 100644 --- a/packages/extension/src/index.ts +++ b/packages/extension/src/index.ts @@ -9,4 +9,4 @@ export * from './interfaces/i_extension'; export {ExtensionManager} from './extension-manager'; -export {ScratchExtensionAdapter} from './adapter/scratch/adapter'; +export {ScratchBuiltinAdapter} from './adapter/scratch/builtin-adapter'; diff --git a/packages/vm/src/virtual-machine.js b/packages/vm/src/virtual-machine.js index 2c251de68..eb903fcf0 100644 --- a/packages/vm/src/virtual-machine.js +++ b/packages/vm/src/virtual-machine.js @@ -7,7 +7,7 @@ if (typeof TextEncoder === 'undefined') { } const EventEmitter = require('events'); const JSZip = require('jszip'); -const {ScratchExtensionAdapter} = require('clipcc-extension'); +const {ScratchBuiltinAdapter} = require('clipcc-extension'); const Buffer = require('buffer').Buffer; const centralDispatch = require('./dispatch/central-dispatch'); @@ -1286,7 +1286,7 @@ class VirtualMachine extends EventEmitter { continue; } - this.extensionManager.loadExtension(new ScratchExtensionAdapter( + this.extensionManager.loadExtension(new ScratchBuiltinAdapter( content, builtinExtensions[extensionId], this.runtime )); } From cd23231a22d510c84529c23e927f38ff52770704 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Wed, 15 Apr 2026 20:54:23 +0800 Subject: [PATCH 23/36] :sparkles: feat(ext): make enable/disable async & add refreshInfo method Signed-off-by: Alex Cui --- .../extension/src/adapter/scratch/adapter.ts | 46 +++++++++++-------- .../src/adapter/scratch/builtin-adapter.ts | 22 +++++++-- packages/extension/src/extension-manager.ts | 21 +++++++-- .../extension/src/interfaces/i_extension.ts | 9 +++- 4 files changed, 67 insertions(+), 31 deletions(-) diff --git a/packages/extension/src/adapter/scratch/adapter.ts b/packages/extension/src/adapter/scratch/adapter.ts index 1366f9d0c..7e2f14647 100644 --- a/packages/extension/src/adapter/scratch/adapter.ts +++ b/packages/extension/src/adapter/scratch/adapter.ts @@ -124,6 +124,9 @@ export abstract class ScratchBaseAdapter implements IExtension { /** Whether the extension is enabled. */ private enabled: boolean = false; + /** Cache for CategoryInfo. */ + private cachedCategoryInfo: CategoryInfo | null = null; + /** * @param manifest Manifest for extension library to display info. * @param runtime Runtime object of virtual machine. @@ -171,46 +174,36 @@ export abstract class ScratchBaseAdapter implements IExtension { * Enable the extension. * Derived adapters should override this function to instantiate the extension. */ - enable(): void { + enable(): Promise { this.enabled = true; - - try { - const extensionInfo = this.prepareExtensionInfo(this.getInfo()); - const categoryInfo = this.buildCategoryInfo(extensionInfo); - this.registerExtensionPrimitives(extensionInfo, categoryInfo); - this.registerBlocks(categoryInfo); - } catch (e) { - logger.error(`Failed to register primitives for extension ${this.getId()}:`, e); - } + return this.refreshInfo(); } /** * Disable the extension. */ - disable(): void { + async disable(): Promise { // @todo support disable extension. this.enabled = false; } + /** + * Refresh and cache the category info. + */ + abstract refreshInfo(): Promise; + /** * Get toolbox content for Blockly. * The method should only be called when extension is enabled. */ getToolboxContents(isStage: boolean): any { - const extensionInfo = this.prepareExtensionInfo(this.getInfo()); - const categoryInfo = this.buildCategoryInfo(extensionInfo); + const categoryInfo = this.cachedCategoryInfo; return { id: this.getId(), - xml: this.buildToolboxXML(categoryInfo, isStage) + xml: categoryInfo ? this.buildToolboxXML(categoryInfo, isStage) : '' }; } - /** - * Call getInfo from extension instance. Will only be called after instantiated. - * Should be implemented by derived adapters. - */ - protected abstract getInfo(): ExtensionMetadata; - /** * Call method by name and given arguments. Will only be called after instantiated. * Should be implemented by derived adapters. @@ -220,6 +213,19 @@ export abstract class ScratchBaseAdapter implements IExtension { */ protected abstract callMethod(method: string, ...args: Args): R | undefined; + /** + * Refresh and cache the category info. Should be called after calling getInfo. + * An error might be thrown if info is invalid. + * @param info Object returned from getInfo. + */ + protected processInfo(info: ExtensionMetadata) { + const extensionInfo = this.prepareExtensionInfo(info); + const categoryInfo = this.buildCategoryInfo(extensionInfo); + this.cachedCategoryInfo = categoryInfo; + this.registerExtensionPrimitives(extensionInfo, categoryInfo); + this.registerBlocks(categoryInfo); + } + private buildCategoryInfo(extensionInfo: ProcessedExtensionMetadata): CategoryInfo { const categoryInfo = { id: extensionInfo.id, diff --git a/packages/extension/src/adapter/scratch/builtin-adapter.ts b/packages/extension/src/adapter/scratch/builtin-adapter.ts index d9eb80cf2..b42a7c57c 100644 --- a/packages/extension/src/adapter/scratch/builtin-adapter.ts +++ b/packages/extension/src/adapter/scratch/builtin-adapter.ts @@ -28,17 +28,29 @@ export class ScratchBuiltinAdapter extends ScratchBaseAdapter { super(manifest, runtime); } - override enable(): void { + override enable(): Promise { const ExtensionClass = this.module(); this.instance = new ExtensionClass(this.runtime); - - super.enable(); + return super.enable(); } - protected override getInfo(): ExtensionMetadata { - return this.instance.getInfo(); + /** + * Refresh and cache the category info. + */ + async refreshInfo(): Promise { + try { + this.processInfo(this.instance.getInfo()); + } catch (err) { + logger.error(`Failed to register extension ${this.getId()}:`, err); + } } + /** + * Call method by name and given arguments. Will only be called after instantiated. + * @param method Method name. + * @param args Arguments passed to method. + * @returns Result of calling the method, or undefined if no valid method is found. + */ protected override callMethod(method: string, ...args: Args): R | undefined { if ( method in this.instance && diff --git a/packages/extension/src/extension-manager.ts b/packages/extension/src/extension-manager.ts index d9ec64c7e..98ff66350 100644 --- a/packages/extension/src/extension-manager.ts +++ b/packages/extension/src/extension-manager.ts @@ -52,7 +52,7 @@ export class ExtensionManager { * @throws Will throw an error if extension is not found or has been enabled. * @param extensionId ID of the extension to enable. */ - enableExtension(extensionId: string): void { + enableExtension(extensionId: string): Promise { const extension = this.getExtensionById(extensionId); if (!extension) { throw new Error(`Extension ${extensionId} is not found.`); @@ -62,7 +62,7 @@ export class ExtensionManager { throw new Error(`Extension ${extensionId} is already enabled.`); } - extension.enable(); + return extension.enable(); } /** @@ -70,7 +70,7 @@ export class ExtensionManager { * @throws Will throw an error if extension is not found or not enabled. * @param extensionId ID of the extension to disable. */ - disableExtension(extensionId: string): void { + disableExtension(extensionId: string): Promise { const extension = this.getExtensionById(extensionId); if (!extension) { throw new Error(`Extension ${extensionId} is not found.`); @@ -80,7 +80,7 @@ export class ExtensionManager { throw new Error(`Extension ${extensionId} is not enabled.`); } - extension.disable(); + return extension.disable(); } /** @@ -128,6 +128,19 @@ export class ExtensionManager { return result; } + /** + * Regenerate blockinfo for any enabled extensions. + */ + async refreshInfo(): Promise { + const promises: Promise[] = []; + this.loadedExtensions.forEach((extension) => { + if (extension.isEnabled()) { + promises.push(extension.refreshInfo()); + } + }); + return Promise.all(promises); + } + /** * Add an event listener. * @param event Name of the event. diff --git a/packages/extension/src/interfaces/i_extension.ts b/packages/extension/src/interfaces/i_extension.ts index 0b87ac5d5..40b8bb637 100644 --- a/packages/extension/src/interfaces/i_extension.ts +++ b/packages/extension/src/interfaces/i_extension.ts @@ -40,12 +40,17 @@ export interface IExtension { /** * Enable the extension. */ - enable(): void; + enable(): Promise; /** * Disable the extension. */ - disable(): void; + disable(): Promise; + + /** + * Refresh and cache the category info. + */ + refreshInfo(): Promise; /** * Get toolbox content for Blockly. From 4e6f80f491276252cd1312dae974146c30bfa289 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Thu, 16 Apr 2026 00:48:02 +0800 Subject: [PATCH 24/36] :sparkles: feat(ext): basic support for Scratch web worker extension Signed-off-by: Alex Cui --- .../src/adapter/scratch/builtin-adapter.ts | 13 +- .../scratch/dispatch/central-dispatch.ts | 134 ++++++++++ .../scratch/dispatch/shared-dispatch.ts | 239 ++++++++++++++++++ .../scratch/dispatch/worker-dispatch.ts | 111 ++++++++ .../src/adapter/scratch/extension-worker.ts | 75 ++++++ .../src/adapter/scratch/worker-adapter.ts | 142 +++++++++++ packages/extension/src/index.ts | 1 + 7 files changed, 709 insertions(+), 6 deletions(-) create mode 100644 packages/extension/src/adapter/scratch/dispatch/central-dispatch.ts create mode 100644 packages/extension/src/adapter/scratch/dispatch/shared-dispatch.ts create mode 100644 packages/extension/src/adapter/scratch/dispatch/worker-dispatch.ts create mode 100644 packages/extension/src/adapter/scratch/extension-worker.ts create mode 100644 packages/extension/src/adapter/scratch/worker-adapter.ts diff --git a/packages/extension/src/adapter/scratch/builtin-adapter.ts b/packages/extension/src/adapter/scratch/builtin-adapter.ts index b42a7c57c..f9e879f66 100644 --- a/packages/extension/src/adapter/scratch/builtin-adapter.ts +++ b/packages/extension/src/adapter/scratch/builtin-adapter.ts @@ -1,16 +1,17 @@ /** * @license - * Copyright 2017 Massachusetts Institute of Technology - * SPDX-License-Identifier: BSD-3-Clause + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 */ import logger from '../../utils/logger'; -import ExtensionManifest from '../../interfaces/extension-manifest'; -import {IExtension} from '../../interfaces/i_extension'; import {ScratchBaseAdapter} from './adapter'; -import {ExtensionMetadata} from './types/extension-metadata'; -import {ScratchExtension, ScratchExtensionClass} from './types/scratch-extension'; +import type ExtensionManifest from '../../interfaces/extension-manifest'; +import type {ScratchExtension, ScratchExtensionClass} from './types/scratch-extension'; +/** + * Scratch extension adapter for builtin extensions provided by Scratch VM. + */ export class ScratchBuiltinAdapter extends ScratchBaseAdapter { /** Instance of extension object. */ protected instance!: ScratchExtension; diff --git a/packages/extension/src/adapter/scratch/dispatch/central-dispatch.ts b/packages/extension/src/adapter/scratch/dispatch/central-dispatch.ts new file mode 100644 index 000000000..5e230e665 --- /dev/null +++ b/packages/extension/src/adapter/scratch/dispatch/central-dispatch.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2017 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ + +import logger from '../../../utils/logger'; +import {DispatchCallMessage, SharedDispatch, WorkerLike} from './shared-dispatch'; + +/** + * This class serves as the central broker for message dispatch. It expects to operate on the main thread / Window and + * it must be informed of any Worker threads which will participate in the messaging system. From any context in the + * messaging system, the dispatcher's "call" method can call any method on any "service" provided in any participating + * context. The dispatch system will forward function arguments and return values across worker boundaries as needed. + */ +export class CentralDispatch extends SharedDispatch { + /** + * Map of channel name to worker or local service provider. + * If the entry is a Worker, the service is provided by an object on that worker. + * Otherwise, the service is provided locally and methods on the service will be called directly. + */ + private services: Record = {}; + + /** + * The constructor we will use to recognize workers. + */ + private workerClass = (typeof Worker === 'undefined' ? null : Worker); + + /** + * List of workers attached to this dispatcher. + */ + private workers: Worker[] = []; + + /** + * Synchronously call a particular method on a particular service provided locally. + * Calling this function on a remote service will fail. + * @param service The name of the service. + * @param method The name of the method. + * @param args The arguments to be copied to the method, if any. + * @returns The return value of the service method. + */ + callSync(service: string, method: string, ...args: any[]) { + const {provider, isRemote} = this.getServiceProvider(service); + if (provider) { + if (isRemote) { + throw new Error(`Cannot use 'callSync' on remote provider for service ${service}.`); + } + + return (provider as any)[method](...args); + } + throw new Error(`Provider not found for service: ${service}`); + } + + /** + * Synchronously set a local object as the global provider of the specified service. + * WARNING: Any method on the provider can be called from any worker within the dispatch system. + * @param service A globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'. + * @param provider A local object which provides this service. + */ + setServiceSync(service: string, provider: object) { + if (Object.prototype.hasOwnProperty.call(this.services, service)) { + logger.warn(`Central dispatch replacing existing service provider for ${service}`); + } + this.services[service] = provider; + } + + /** + * Set a local object as the global provider of the specified service. + * WARNING: Any method on the provider can be called from any worker within the dispatch system. + * @param service A globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'. + * @param provider A local object which provides this service. + * @returns A promise which will resolve once the service is registered. + */ + setService(service: string, provider: object): Promise { + /** Return a promise for consistency with {@link WorkerDispatch#setService} */ + try { + this.setServiceSync(service, provider); + return Promise.resolve(); + } catch (e) { + return Promise.reject(e); + } + } + + /** + * Add a worker to the message dispatch system. The worker must implement a compatible message dispatch framework. + * The dispatcher will immediately attempt to "handshake" with the worker. + * @param worker The worker to add into the dispatch system. + */ + addWorker(worker: Worker) { + if (this.workers.indexOf(worker) === -1) { + this.workers.push(worker); + worker.onmessage = this.onMessage.bind(this, worker); + this.remoteCall(worker, 'dispatch', 'handshake').catch(e => { + logger.error(`Could not handshake with worker: ${JSON.stringify(e)}`); + }); + } else { + logger.warn('Central dispatch ignoring attempt to add duplicate worker'); + } + } + + /** + * Fetch the service provider object for a particular service name. + * @param service The name of the service to look up. + * @returns The means to contact the service, if found. + */ + protected override getServiceProvider(service: string) { + const provider = this.services[service]; + const isRemote = Boolean(this.workerClass && provider instanceof this.workerClass); + return { + provider, + isRemote + }; + } + + /** + * Handle a call message sent to the dispatch service itself. + * @param worker The worker which sent the message. + * @param message The message to be handled. + * @returns A promise for the results of this operation, if appropriate. + */ + protected override onDispatchMessage(worker: WorkerLike, message: DispatchCallMessage) { + let promise; + switch (message.method) { + case 'setService': + promise = this.setService(message.args[0], worker); + break; + default: + logger.error(`Central dispatch received message for unknown method: ${message.method}`); + } + return promise; + } +} + +export default new CentralDispatch(); diff --git a/packages/extension/src/adapter/scratch/dispatch/shared-dispatch.ts b/packages/extension/src/adapter/scratch/dispatch/shared-dispatch.ts new file mode 100644 index 000000000..522902e36 --- /dev/null +++ b/packages/extension/src/adapter/scratch/dispatch/shared-dispatch.ts @@ -0,0 +1,239 @@ +/** + * @license + * Copyright 2017 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ + +import logger from '../../../utils/logger'; + +/** + * A message to the dispatch system representing a service method call. + */ +export interface DispatchCallMessage { + /** Send a response message with this response ID. */ + responseId: number; + /** The name of the service to be called. */ + service: string; + /** The name of the method to be called. */ + method: string; + /** The arguments to be passed to the method. */ + args: any[]; +} + +/** + * A message to the dispatch system representing the results of a call. + */ +export interface DispatchResponseMessage { + /** A copy of the response ID from the call which generated this response. */ + responseId: number; + /** If this is truthy, then it contains results from a failed call (such as an exception). */ + error?: any; + /** If error is not truthy, then this contains the return value of the call (if any). */ + result?: any; +} + +/** Any message to the dispatch system. */ +export type DispatchMessage = DispatchCallMessage | DispatchResponseMessage; + +function isDispatchCallMessage(obj: DispatchMessage): obj is DispatchCallMessage { + return 'service' in obj; +} + +export type Resolve = (value: any | PromiseLike) => void; +export type Reject = (reason?: any) => void; + +export type WorkerLike = Worker | { + postMessage(...args: any[]): void; +}; + +/** + * The SharedDispatch class is responsible for dispatch features shared by CentralDispatch and WorkerDispatch. + */ +export abstract class SharedDispatch { + /** + * List of callback registrations for promises waiting for a response from a call to a service on another + * worker. A callback registration is an array of [resolve,reject] Promise functions. + * Calls to local services don't enter this list. + */ + private callbacks: [Resolve, Reject][] = []; + + /** + * The next response ID to be used. + */ + private nextResponseId: number = 0; + + /** + * Call a particular method on a particular service, regardless of whether that service is provided locally or on + * a worker. If the service is provided by a worker, the `args` will be copied using the Structured Clone + * algorithm, except for any items which are also in the `transfer` list. Ownership of those items will be + * transferred to the worker, and they should not be used after this call. + * @example + * dispatcher.call('vm', 'setData', 'cat', 42); + * // this finds the worker for the 'vm' service, then on that worker calls: + * vm.setData('cat', 42); + * @param service The name of the service. + * @param method The name of the method. + * @param args The arguments to be copied to the method, if any. + * @returns A promise for the return value of the service method. + */ + call(service: string, method: string, ...args: any[]): Promise { + return this.transferCall(service, method, null, ...args); + } + + /** + * Call a particular method on a particular service, regardless of whether that service is provided locally or on + * a worker. If the service is provided by a worker, the `args` will be copied using the Structured Clone + * algorithm, except for any items which are also in the `transfer` list. Ownership of those items will be + * transferred to the worker, and they should not be used after this call. + * @example + * dispatcher.transferCall('vm', 'setData', [myArrayBuffer], 'cat', myArrayBuffer); + * // this finds the worker for the 'vm' service, transfers `myArrayBuffer` to it, then on that worker calls: + * vm.setData('cat', myArrayBuffer); + * @param service The name of the service. + * @param method The name of the method. + * @param transfer Objects to be transferred instead of copied. Must be present in `args` to be useful. + * @param args The arguments to be copied to the method, if any. + * @returns A promise for the return value of the service method. + */ + transferCall(service: string, method: string, transfer: object[] | null, ...args: any[]): Promise { + try { + const {provider, isRemote} = this.getServiceProvider(service); + if (provider) { + if (isRemote) { + return this.remoteTransferCall(provider as Worker, service, method, transfer, ...args); + } + + const result = (provider as any)[method](...args); + return Promise.resolve(result); + } + return Promise.reject(new Error(`Service not found: ${service}`)); + } catch (e) { + return Promise.reject(e); + } + } + + /** + * Check if a particular service lives on another worker. + * @param service The service to check. + * @returns True if the service is remote (calls must cross a Worker boundary), false otherwise. + */ + private isRemoteService(service: string): boolean { + return this.getServiceProvider(service).isRemote; + } + + /** + * Like `call`, but force the call to be posted through a particular communication channel. + * @param provider Send the call through this object's `postMessage` function. + * @param service The name of the service. + * @param method The name of the method. + * @param args The arguments to be copied to the method, if any. + * @returns A promise for the return value of the service method. + */ + protected remoteCall(provider: WorkerLike, service: string, method: string, ...args: any[]): Promise { + return this.remoteTransferCall(provider, service, method, null, ...args); + } + + /** + * Like `transferCall`, but force the call to be posted through a particular communication channel. + * @param provider Send the call through this object's `postMessage` function. + * @param service The name of the service. + * @param method The name of the method. + * @param transfer Objects to be transferred instead of copied. Must be present in `args` to be useful. + * @param args The arguments to be copied to the method, if any. + * @returns {Promise} - a promise for the return value of the service method. + */ + private remoteTransferCall(provider: WorkerLike, service: string, method: string, transfer: any[] | null, ...args: any[]) { + return new Promise((resolve, reject) => { + const responseId = this.storeCallbacks(resolve, reject); + + args = JSON.parse(JSON.stringify(args)); + + if (transfer) { + provider.postMessage({service, method, responseId, args}, transfer); + } else { + provider.postMessage({service, method, responseId, args}); + } + }); + } + + /** + * Store callback functions pending a response message. + * @param resolve Function to call if the service method returns. + * @param reject Function to call if the service method throws. + * @returns A unique response ID for this set of callbacks. + */ + protected storeCallbacks(resolve: Resolve, reject: Reject) { + const responseId = this.nextResponseId++; + this.callbacks[responseId] = [resolve, reject]; + return responseId; + } + + /** + * Deliver call response from a worker. This should only be called as the result of a message from a worker. + * @param responseId The response ID of the callback set to call. + * @param message The message containing the response value(s). + */ + protected deliverResponse(responseId: number, message: DispatchResponseMessage) { + try { + const [resolve, reject] = this.callbacks[responseId]; + delete this.callbacks[responseId]; + if (message.error) { + reject(message.error); + } else { + resolve(message.result); + } + } catch (e) { + logger.error(`Dispatch callback failed: ${JSON.stringify(e)}`); + } + } + + /** + * Handle a message event received from a connected worker. + * @param worker The worker which sent the message, or the global object if running in a worker. + * @param event The message event to be handled. + */ + protected onMessage(worker: WorkerLike, event: MessageEvent) { + const message = event.data; + let promise; + if (isDispatchCallMessage(message) && message.service) { + message.args = message.args || []; + if (message.service === 'dispatch') { + promise = this.onDispatchMessage(worker, message); + } else { + promise = this.call(message.service, message.method, ...message.args); + } + } else if (typeof message.responseId === 'undefined') { + logger.error(`Dispatch caught malformed message from a worker: ${JSON.stringify(event)}`); + } else { + this.deliverResponse(message.responseId, message); + } + if (promise) { + if (typeof message.responseId === 'undefined') { + logger.error(`Dispatch message missing required response ID: ${JSON.stringify(event)}`); + } else { + promise.then( + result => worker.postMessage({responseId: message.responseId, result}), + error => worker.postMessage({responseId: message.responseId, error}) + ); + } + } + } + + /** + * Fetch the service provider object for a particular service name. + * @param service The name of the service to look up. + * @returns The means to contact the service, if found. + */ + protected abstract getServiceProvider(service: string): { + provider: Worker | object; + isRemote: boolean; + }; + + /** + * Handle a call message sent to the dispatch service itself + * @param worker The worker which sent the message. + * @param message The message to be handled. + * @returns A promise for the results of this operation, if appropriate + */ + protected abstract onDispatchMessage(worker: WorkerLike, message: DispatchCallMessage): Promise | undefined; +} diff --git a/packages/extension/src/adapter/scratch/dispatch/worker-dispatch.ts b/packages/extension/src/adapter/scratch/dispatch/worker-dispatch.ts new file mode 100644 index 000000000..eef6f7d4d --- /dev/null +++ b/packages/extension/src/adapter/scratch/dispatch/worker-dispatch.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2017 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ + +import logger from '../../../utils/logger'; +import {DispatchCallMessage, SharedDispatch, WorkerLike} from './shared-dispatch'; + +/** + * This class provides a Worker with the means to participate in the message dispatch system managed by CentralDispatch. + * From any context in the messaging system, the dispatcher's "call" method can call any method on any "service" + * provided in any participating context. The dispatch system will forward function arguments and return values across + * worker boundaries as needed. + */ +export class WorkerDispatch extends SharedDispatch { + /** + * This promise will be resolved when we have successfully connected to central dispatch. + */ + private connectionPromise: Promise; + + /** + * Called when successfully connected. + */ + private onConnect!: (value: void | PromiseLike) => void; + + /** + * Map of service name to local service provider. + * If a service is not listed here, it is assumed to be provided by another context (another Worker or the main + * thread). + */ + private services: Record = {}; + + constructor() { + super(); + + this.connectionPromise = new Promise((resolve) => { + this.onConnect = resolve; + }); + + if (typeof self !== 'undefined') { + self.onmessage = this.onMessage.bind(this, self); + } + } + + /** + * @returns A promise which will resolve upon connection to central dispatch. If you need to make a call + * immediately on "startup" you can attach a 'then' to this promise. + * @example + * dispatch.waitForConnection.then(() => { + * dispatch.call('myService', 'hello'); + * }) + */ + get waitForConnection() { + return this.connectionPromise; + } + + /** + * Set a local object as the global provider of the specified service. + * WARNING: Any method on the provider can be called from any worker within the dispatch system. + * @param service A globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'. + * @param provider A local object which provides this service. + * @returns A promise which will resolve once the service is registered. + */ + setService(service: string, provider: object) { + if (Object.prototype.hasOwnProperty.call(this.services, service)) { + logger.warn(`Worker dispatch replacing existing service provider for ${service}`); + } + this.services[service] = provider; + return this.waitForConnection.then(() => this.remoteCall(self, 'dispatch', 'setService', service)); + } + + /** + * Fetch the service provider object for a particular service name. + * @param service The name of the service to look up. + * @returns The means to contact the service, if found. + */ + protected override getServiceProvider(service: string) { + // if we don't have a local service by this name, contact central dispatch by calling `postMessage` on self + const provider = this.services[service]; + return { + provider: provider || self, + isRemote: !provider + }; + } + + /** + * Handle a call message sent to the dispatch service itself. + * @param worker The worker which sent the message. + * @param message The message to be handled. + * @returns A promise for the results of this operation, if appropriate. + */ + protected override onDispatchMessage(worker: WorkerLike, message: DispatchCallMessage) { + let promise; + switch (message.method) { + case 'handshake': + promise = Promise.resolve(this.onConnect()); + break; + case 'terminate': + // Don't close until next tick, after sending confirmation back + setTimeout(() => self.close(), 0); + promise = Promise.resolve(); + break; + default: + logger.error(`Worker dispatch received message for unknown method: ${message.method}`); + } + return promise; + } +} + +export default new WorkerDispatch(); diff --git a/packages/extension/src/adapter/scratch/extension-worker.ts b/packages/extension/src/adapter/scratch/extension-worker.ts new file mode 100644 index 000000000..7e702e66e --- /dev/null +++ b/packages/extension/src/adapter/scratch/extension-worker.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2017 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ + +import dispatch from './dispatch/worker-dispatch'; + +import ArgumentType from './types/argument-type'; +import BlockType from './types/block-type'; +import TargetType from './types/target-type'; + +declare global { + var Scratch: { + ArgumentType: typeof ArgumentType; + BlockType: typeof BlockType; + TargetType: typeof TargetType; + extensions: { + register: (extensionObject: any) => Promise; + }; + }; +} + +class ExtensionWorker { + private nextExtensionId: number = 0; + private initialRegistrations: Promise[] | null = []; + private workerId!: number; + private extensions: any[] = []; + + constructor() { + dispatch.waitForConnection.then(() => { + dispatch.call('extensions', 'allocateWorker').then(x => { + const [id, extension] = x; + this.workerId = id; + + try { + importScripts(extension); + + const initialRegistrations = this.initialRegistrations; + this.initialRegistrations = null; + + Promise.all(initialRegistrations!) + .then(() => dispatch.call('extensions', 'onWorkerInit', id)); + } catch (e) { + dispatch.call('extensions', 'onWorkerInit', id, e); + } + }); + }); + } + + register(extensionObject: any) { + const extensionId = this.nextExtensionId++; + this.extensions.push(extensionObject); + const serviceName = `extension.${this.workerId}.${extensionId}`; + const promise = dispatch.setService(serviceName, extensionObject) + .then(() => dispatch.call('extensions', 'registerExtensionService', serviceName, this.workerId)); + if (this.initialRegistrations) { + this.initialRegistrations.push(promise); + } + return promise; + } +} + +global.Scratch = global.Scratch || {}; +global.Scratch.ArgumentType = ArgumentType; +global.Scratch.BlockType = BlockType; +global.Scratch.TargetType = TargetType; + +/** + * Expose only specific parts of the worker to extensions. + */ +const extensionWorker = new ExtensionWorker(); +global.Scratch.extensions = { + register: extensionWorker.register.bind(extensionWorker) +}; diff --git a/packages/extension/src/adapter/scratch/worker-adapter.ts b/packages/extension/src/adapter/scratch/worker-adapter.ts new file mode 100644 index 000000000..9a73864b1 --- /dev/null +++ b/packages/extension/src/adapter/scratch/worker-adapter.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +import {ScratchBaseAdapter} from './adapter'; +import logger from '../../utils/logger'; +import dispatch from './dispatch/central-dispatch'; +import type ExtensionManifest from '../../interfaces/extension-manifest'; +import type {Resolve, Reject} from './dispatch/shared-dispatch'; + +interface PendingExtensionWorker { + extensionURL: string; + resolve: Resolve; + reject: Reject; +} + +class ExtensionService { + /** + * The ID number to provide to the next extension worker. + */ + private nextExtensionWorker: number = 0; + + /** + * FIFO queue of extensions which have been requested but not yet loaded in a worker, + * along with promise resolution functions to call once the worker is ready or failed. + */ + private pendingExtensions: PendingExtensionWorker[] = []; + + /** + * Map of worker ID to workers which have been allocated but have not yet finished initialization. + */ + private pendingWorkers: PendingExtensionWorker[] = []; + + constructor(runtime: any) { + dispatch.setService('runtime', runtime).catch(err => { + logger.error(`Failed to register runtime service: ${JSON.stringify(err)}`); + }); + dispatch.setService('extensions', this).catch(err => { + logger.error(`Failed to register extension service: ${JSON.stringify(err)}`); + }); + } + + addPendingExtension(url: string, resolve: Resolve, reject: Reject) { + this.pendingExtensions.push({ + extensionURL: url, + resolve, + reject + }); + } + + allocateWorker() { + const id = this.nextExtensionWorker++; + const workerInfo = this.pendingExtensions.shift()!; + this.pendingWorkers[id] = workerInfo; + return [id, workerInfo.extensionURL]; + } + + /** + * Called by an extension worker to indicate that the worker has finished initialization. + * @param id The worker ID. + * @param err The error encountered during initialization, if any. + */ + onWorkerInit(id: number, err?: any) { + const workerInfo = this.pendingWorkers[id]; + if (err) { + workerInfo.reject(err); + } + } + + /** + * Collect extension metadata from the specified service and begin the extension registration process. + * @param serviceName The name of the service hosting the extension. + * @param id The worker ID. + */ + registerExtensionService(serviceName: string, id: number) { + const workerInfo = this.pendingWorkers[id]; + delete this.pendingWorkers[id]; + workerInfo.resolve(serviceName); + } +}; + +let extensionService: ExtensionService | null = null; + +/** + * Scratch extension adapter for web worker extensions. + */ +export class ScratchWorkerAdapter extends ScratchBaseAdapter { + /** The name of the service hosting the extension. */ + private serviceName!: string; + + /** + * @param manifest Manifest for extension library to display info. + * @param url URL to load the extension. + * @param runtime Runtime object of virtual machine. + */ + constructor( + manifest: ExtensionManifest, + private url: string, + runtime: any + ) { + super(manifest, runtime); + + if (!extensionService) { + extensionService = new ExtensionService(runtime); + } + } + + override enable(): Promise { + return new Promise((resolve, reject) => { + extensionService!.addPendingExtension(this.url, resolve, reject); + dispatch.addWorker(new Worker(new URL('./extension-worker.ts', import.meta.url))); + }).then((serviceName: string) => { + this.serviceName = serviceName; + return super.enable(); + }); + } + + /** + * Refresh and cache the category info. + */ + async refreshInfo(): Promise { + try { + const info = await dispatch.call(this.serviceName, 'getInfo'); + this.processInfo(info); + } catch (err) { + logger.error(`Failed to register extension ${this.getId()}:`, err); + } + } + + /** + * Call method by name and given arguments. Will only be called after instantiated. + * @param method Method name. + * @param args Arguments passed to method. + * @returns Result of calling the method, or undefined if no valid method is found. + */ + protected override callMethod(method: string, ...args: Args): R | undefined { + // Ignore the util and realBlockInfo param. + return dispatch.call(this.serviceName, method, args[0]) as R; + } +} diff --git a/packages/extension/src/index.ts b/packages/extension/src/index.ts index bbf6d0be5..0721b4459 100644 --- a/packages/extension/src/index.ts +++ b/packages/extension/src/index.ts @@ -10,3 +10,4 @@ export * from './interfaces/i_extension'; export {ExtensionManager} from './extension-manager'; export {ScratchBuiltinAdapter} from './adapter/scratch/builtin-adapter'; +export {ScratchWorkerAdapter} from './adapter/scratch/worker-adapter'; From 53459d0b29126ed29620f884847e29c5accc47d7 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Thu, 16 Apr 2026 00:49:11 +0800 Subject: [PATCH 25/36] :bug: fix(gui,ext): enableExtension should be handled in async way Signed-off-by: Alex Cui --- packages/gui/src/containers/extension-library.jsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/gui/src/containers/extension-library.jsx b/packages/gui/src/containers/extension-library.jsx index 8da2ec5b0..a8d644d64 100644 --- a/packages/gui/src/containers/extension-library.jsx +++ b/packages/gui/src/containers/extension-library.jsx @@ -35,10 +35,13 @@ class ExtensionLibrary extends React.PureComponent { url = prompt(this.props.intl.formatMessage(messages.extensionUrl)); } if (id && !item.disabled) { - if (!this.props.extensionManager.isExtensionEnabled(url)) { - this.props.extensionManager.enableExtension(url); + if (this.props.extensionManager.isExtensionEnabled(url)) { + this.props.onCategorySelected(id); + } else { + this.props.extensionManager.enableExtension(url).then(() => { + this.props.onCategorySelected(id); + }); } - this.props.onCategorySelected(id); } } render () { From 17af21a7c107b3ddd3155ed645d67309059ab541 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Thu, 16 Apr 2026 01:22:43 +0800 Subject: [PATCH 26/36] :wrench: build(vm): add extension package to vm Signed-off-by: Alex Cui --- package.json | 2 +- packages/vm/webpack.config.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 26588b694..13c37ffc4 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "start": "pnpm run gui start", "prepare": "husky", "build:dist": "cross-env NODE_ENV=production pnpm run build:full", - "build:full": "pnpm l10n build && pnpm audio build && pnpm storage build && pnpm svg-renderer build && pnpm render build && pnpm block build && pnpm vm build && pnpm paint build && node packages/gui/scripts/prepublish.mjs && pnpm gui build", + "build:full": "pnpm l10n build && pnpm audio build && pnpm storage build && pnpm svg-renderer build && pnpm render build && pnpm block build && pnpm extension build && pnpm vm build && pnpm paint build && node packages/gui/scripts/prepublish.mjs && pnpm gui build", "build": "pnpm block build && pnpm gui build", "test": "pnpm gui test:unit && pnpm block test && pnpm vm test", "performance": "pnpm vm performance", diff --git a/packages/vm/webpack.config.js b/packages/vm/webpack.config.js index a6adcae97..3a0f694af 100644 --- a/packages/vm/webpack.config.js +++ b/packages/vm/webpack.config.js @@ -60,7 +60,8 @@ const base = { plugins: [ new RuleInheritancePlugin({ packages: [ - path.resolve(__dirname, '../svg-renderer') + path.resolve(__dirname, '../svg-renderer'), + path.resolve(__dirname, '../extension') ] }), new NodePolyfillPlugin(), From 059f49df39ce8adbdb45ea289752f5458e45a3c1 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Thu, 16 Apr 2026 16:20:57 +0800 Subject: [PATCH 27/36] :test_tube: test(ext): add test for dispatchs Signed-off-by: Alex Cui --- packages/extension/jest.config.js | 21 +++ packages/extension/package.json | 7 + .../test/fixtures/dispatch-test-service.ts | 19 +++ .../test/fixtures/dispatch-worker.ts | 14 ++ packages/extension/test/tiny-worker.d.ts | 11 ++ .../test/unit/scratch/dispatch.test.ts | 63 ++++++++ packages/extension/tsconfig.test.json | 10 ++ packages/extension/webpack.config.js | 33 +++- pnpm-lock.yaml | 149 ++++++------------ 9 files changed, 226 insertions(+), 101 deletions(-) create mode 100644 packages/extension/jest.config.js create mode 100644 packages/extension/test/fixtures/dispatch-test-service.ts create mode 100644 packages/extension/test/fixtures/dispatch-worker.ts create mode 100644 packages/extension/test/tiny-worker.d.ts create mode 100644 packages/extension/test/unit/scratch/dispatch.test.ts create mode 100644 packages/extension/tsconfig.test.json diff --git a/packages/extension/jest.config.js b/packages/extension/jest.config.js new file mode 100644 index 000000000..1acdea716 --- /dev/null +++ b/packages/extension/jest.config.js @@ -0,0 +1,21 @@ +const {createDefaultPreset} = require('ts-jest'); + +const tsJestTransformCfg = createDefaultPreset({ + tsconfig: { + types: ['./test/tiny-worker.d.ts'] + } +}).transform; + +/** @type {import('jest').Config} */ +module.exports = { + collectCoverageFrom: [ + '/src/**/*.ts' + ], + transform: { + ...tsJestTransformCfg + }, + testMatch: [ + '/test/unit/**/*.test.[tj]s' + ], + testEnvironment: 'node' +}; diff --git a/packages/extension/package.json b/packages/extension/package.json index a6f88810b..b08db6454 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -16,17 +16,24 @@ }, "scripts": { "build": "rimraf dist && mkdirp dist && webpack --progress --color --bail", + "test:pre": "tsc test/fixtures/dispatch-worker.ts --outDir test/dist --outFile test/dist/dispatch-worker.js --module amd --moduleResolution node", + "test": "jest --verbose", + "coverage": "jest --silent --coverage", "lint": "eslint ." }, "devDependencies": { + "@jest/globals": "^30.3.0", "@types/node": "^25.6.0", "clipcc-block": "workspace:~", "eslint": "^10.0.2", + "jest": "catalog:", "lodash.defaultsdeep": "^4.6.1", "mkdirp": "3.0.1", "node-polyfill-webpack-plugin": "^3.0.0", "rimraf": "^6.1.3", "terser-webpack-plugin": "^5.3.16", + "tiny-worker": "2.3.0", + "ts-jest": "^29.4.6", "ts-loader": "^9.5.4", "typescript": "^5.9.3", "webpack": "^5.105.3", diff --git a/packages/extension/test/fixtures/dispatch-test-service.ts b/packages/extension/test/fixtures/dispatch-test-service.ts new file mode 100644 index 000000000..ad9d963d5 --- /dev/null +++ b/packages/extension/test/fixtures/dispatch-test-service.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +export class DispatchTestService { + returnFortyTwo() { + return 42; + } + + doubleArgument(x: number) { + return 2 * x; + } + + throwException() { + throw new Error('This is a test exception thrown by DispatchTest'); + } +} diff --git a/packages/extension/test/fixtures/dispatch-worker.ts b/packages/extension/test/fixtures/dispatch-worker.ts new file mode 100644 index 000000000..4981677ea --- /dev/null +++ b/packages/extension/test/fixtures/dispatch-worker.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +import dispatch from '../../src/adapter/scratch/dispatch/worker-dispatch'; +import {DispatchTestService} from './dispatch-test-service'; + +dispatch.setService('RemoteDispatchTest', new DispatchTestService()); + +dispatch.waitForConnection.then(() => { + dispatch.call('test', 'onWorkerReady'); +}); diff --git a/packages/extension/test/tiny-worker.d.ts b/packages/extension/test/tiny-worker.d.ts new file mode 100644 index 000000000..ff1199ccc --- /dev/null +++ b/packages/extension/test/tiny-worker.d.ts @@ -0,0 +1,11 @@ +declare module 'tiny-worker' { + class Worker { + constructor(...args: any[]); + addEventListener(event: any, fn: any): any; + postMessage(msg: any): any; + terminate(): void; + setRange(min: any, max: any): boolean; + } + + export default Worker; +} diff --git a/packages/extension/test/unit/scratch/dispatch.test.ts b/packages/extension/test/unit/scratch/dispatch.test.ts new file mode 100644 index 000000000..27eba3156 --- /dev/null +++ b/packages/extension/test/unit/scratch/dispatch.test.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +import {describe, expect, test, beforeAll} from '@jest/globals'; +import Worker from 'tiny-worker'; +import dispatch from '../../../src/adapter/scratch/dispatch/central-dispatch'; +import {DispatchTestService} from '../../fixtures/dispatch-test-service'; + +function runServiceTest(serviceName: string) { + const promises = [ + expect(dispatch.call(serviceName, 'returnFortyTwo')).resolves.toEqual(42), + expect(dispatch.call(serviceName, 'doubleArgument', 9)).resolves.toEqual(18), + expect(dispatch.call(serviceName, 'doubleArgument', 123)).resolves.toEqual(246), + expect(dispatch.call(serviceName, 'throwException')).rejects.toBeDefined() + ]; + return Promise.all(promises); +} + +describe('Scratch: Dispatch', () => { + beforeAll(() => { + dispatch['workerClass'] = Worker as any; // Inject worker type to get correct isRemote. + }); + + test('local', () => { + dispatch.setService('LocalDispatchTest', new DispatchTestService()); + return runServiceTest('LocalDispatchTest'); + }); + + test('remote', () => { + const worker = new Worker('test/dist/worker.js'); + dispatch.addWorker(worker as any); + + const waitForWorker = new Promise(resolve => { + dispatch.setService('test', { + onWorkerReady: () => { + dispatch.call('RemoteDispatchTest', 'returnFortyTwo').then((value: number) => { + console.log(value); + resolve(); + }).catch(err => { + console.log(err); + }); + } + }); + }); + + return waitForWorker + .then(() => runServiceTest('RemoteDispatchTest')) + .then(() => dispatch['remoteCall'](worker, 'dispatch', 'terminate')); + }); + + test('local, sync', () => { + dispatch.setServiceSync('SyncDispatchTest', new DispatchTestService()); + + expect(dispatch.callSync('SyncDispatchTest', 'returnFortyTwo')).toEqual(42); + expect(dispatch.callSync('SyncDispatchTest', 'doubleArgument', 9)).toEqual(18); + expect(dispatch.callSync('SyncDispatchTest', 'doubleArgument', 123)).toEqual(246); + expect(() => dispatch.callSync('SyncDispatchTest', 'throwException')) + .toThrow('This is a test exception thrown by DispatchTest'); + }) +}); diff --git a/packages/extension/tsconfig.test.json b/packages/extension/tsconfig.test.json new file mode 100644 index 000000000..b0dd13ee6 --- /dev/null +++ b/packages/extension/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./src", + "./test" + ], + "compilerOptions": { + "rootDir": "." + } +} diff --git a/packages/extension/webpack.config.js b/packages/extension/webpack.config.js index b9a887f97..c5ff50bf4 100644 --- a/packages/extension/webpack.config.js +++ b/packages/extension/webpack.config.js @@ -53,5 +53,36 @@ module.exports = [ libraryTarget: 'commonjs2', path: path.resolve(__dirname, 'dist', 'node') } - }) + }), + // Worker for test + { + mode: 'production', + entry: { + worker: './test/fixtures/dispatch-worker.ts' + }, + target: 'node', + output: { + libraryTarget: 'umd', + path: path.resolve(__dirname, 'test', 'dist'), + }, + resolve: { + extensions: ['.ts', '.js'] + }, + module: { + rules: [{ + test: /\.ts$/, + use: { + loader: 'ts-loader', + options: { + configFile: path.resolve(__dirname, 'tsconfig.test.json') + } + }, + exclude: /node_modules/, + include: [ + path.resolve(__dirname, 'src'), + path.resolve(__dirname, 'test') + ] + }] + } + } ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 232cdef3e..09de1be44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: specifier: ^4.10.2 version: 4.10.2 devDependencies: + '@jest/globals': + specifier: ^30.3.0 + version: 30.3.0 '@types/node': specifier: ^25.6.0 version: 25.6.0 @@ -202,6 +205,9 @@ importers: eslint: specifier: ^10.0.2 version: 10.1.0(jiti@2.6.1) + jest: + specifier: 'catalog:' + version: 30.3.0(@types/node@25.6.0) lodash.defaultsdeep: specifier: ^4.6.1 version: 4.6.1 @@ -217,6 +223,12 @@ importers: terser-webpack-plugin: specifier: ^5.3.16 version: 5.3.17(webpack@5.105.4) + tiny-worker: + specifier: 2.3.0 + version: 2.3.0 + ts-jest: + specifier: ^29.4.6 + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.6.0))(typescript@5.9.3) ts-loader: specifier: ^9.5.4 version: 9.5.4(typescript@5.9.3)(webpack@5.105.4) @@ -1081,13 +1093,13 @@ importers: version: link:../lint-config eslint-plugin-jest: specifier: 27.9.0 - version: 27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@8.57.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(jest@30.3.0(@types/node@25.5.2))(typescript@5.9.3) + version: 27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@8.57.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(jest@30.3.0(@types/node@25.6.0))(typescript@5.9.3) eslint-plugin-react: specifier: 7.37.5 version: 7.37.5(eslint@9.39.2(jiti@2.6.1)) jest: specifier: 'catalog:' - version: 30.3.0(@types/node@25.5.2) + version: 30.3.0(@types/node@25.6.0) json: specifier: ^9.0.6 version: 9.0.6 @@ -1099,10 +1111,10 @@ importers: version: 6.0.1 ts-jest: specifier: ^29.4.5 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.5.2))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.6.0))(typescript@5.9.3) ts-jest-mock-import-meta: specifier: 1.2.1 - version: 1.2.1(ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.5.2))(typescript@5.9.3)) + version: 1.2.1(ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.6.0))(typescript@5.9.3)) ts-loader: specifier: 9.5.4 version: 9.5.4(typescript@5.9.3)(webpack@5.105.4) @@ -11206,7 +11218,7 @@ snapshots: '@jest/console@30.3.0': dependencies: '@jest/types': 30.3.0 - '@types/node': 25.5.2 + '@types/node': 25.6.0 chalk: 4.1.2 jest-message-util: 30.3.0 jest-util: 30.3.0 @@ -11220,14 +11232,14 @@ snapshots: '@jest/test-result': 30.3.0 '@jest/transform': 30.3.0 '@jest/types': 30.3.0 - '@types/node': 25.5.2 + '@types/node': 25.6.0 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 4.4.0 exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.3.0 - jest-config: 30.3.0(@types/node@25.5.2) + jest-config: 30.3.0(@types/node@25.6.0) jest-haste-map: 30.3.0 jest-message-util: 30.3.0 jest-regex-util: 30.0.1 @@ -11297,7 +11309,7 @@ snapshots: dependencies: '@jest/types': 30.3.0 '@sinonjs/fake-timers': 15.1.1 - '@types/node': 25.5.2 + '@types/node': 25.6.0 jest-message-util: 30.3.0 jest-mock: 30.3.0 jest-util: 30.3.0 @@ -11315,7 +11327,7 @@ snapshots: '@jest/pattern@30.0.1': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 jest-regex-util: 30.0.1 '@jest/reporters@30.3.0': @@ -11326,7 +11338,7 @@ snapshots: '@jest/transform': 30.3.0 '@jest/types': 30.3.0 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 25.5.2 + '@types/node': 25.6.0 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit-x: 0.2.2 @@ -11411,7 +11423,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -11421,7 +11433,7 @@ snapshots: '@jest/schemas': 30.0.5 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -12401,7 +12413,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/bonjour@3.5.13': dependencies: @@ -12414,7 +12426,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/css-tree@2.3.11': {} @@ -12454,7 +12466,7 @@ snapshots: '@types/http-proxy@1.17.17': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/hull.js@1.0.4': {} @@ -12480,7 +12492,7 @@ snapshots: '@types/jsdom@21.1.7': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 @@ -12511,7 +12523,7 @@ snapshots: '@types/node-hid@1.3.4': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/node@12.20.55': {} @@ -12561,11 +12573,11 @@ snapshots: '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/send@1.2.1': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/serve-index@1.9.4': dependencies: @@ -12590,7 +12602,7 @@ snapshots: '@types/usb@1.5.4': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/w3c-web-usb@1.0.13': {} @@ -12610,7 +12622,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 optional: true '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@8.57.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': @@ -14984,13 +14996,13 @@ snapshots: '@typescript-eslint/experimental-utils': 1.13.0(eslint@9.39.2(jiti@2.6.1)) eslint: 9.39.2(jiti@2.6.1) - eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@8.57.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(jest@30.3.0(@types/node@25.5.2))(typescript@5.9.3): + eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@8.57.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(jest@30.3.0(@types/node@25.6.0))(typescript@5.9.3): dependencies: '@typescript-eslint/utils': 5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) optionalDependencies: '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@8.57.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - jest: 30.3.0(@types/node@25.5.2) + jest: 30.3.0(@types/node@25.6.0) transitivePeerDependencies: - supports-color - typescript @@ -16580,7 +16592,7 @@ snapshots: '@jest/expect': 30.3.0 '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 - '@types/node': 25.5.2 + '@types/node': 25.6.0 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -16619,25 +16631,6 @@ snapshots: - supports-color - ts-node - jest-cli@30.3.0(@types/node@25.5.2): - dependencies: - '@jest/core': 30.3.0 - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.3.0(@types/node@25.5.2) - jest-util: 30.3.0 - jest-validate: 30.3.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest-cli@30.3.0(@types/node@25.6.0): dependencies: '@jest/core': 30.3.0 @@ -16688,37 +16681,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.3.0(@types/node@25.5.2): - dependencies: - '@babel/core': 7.29.0 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.3.0 - '@jest/types': 30.3.0 - babel-jest: 30.3.0(@babel/core@7.29.0) - chalk: 4.1.2 - ci-info: 4.4.0 - deepmerge: 4.3.1 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-circus: 30.3.0 - jest-docblock: 30.2.0 - jest-environment-node: 30.3.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.3.0 - jest-runner: 30.3.0 - jest-util: 30.3.0 - jest-validate: 30.3.0 - parse-json: 5.2.0 - pretty-format: 30.3.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 25.5.2 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@30.3.0(@types/node@25.6.0): dependencies: '@babel/core': 7.29.0 @@ -16791,7 +16753,7 @@ snapshots: '@jest/environment': 30.3.0 '@jest/fake-timers': 30.3.0 '@jest/types': 30.3.0 - '@types/node': 25.5.2 + '@types/node': 25.6.0 jest-mock: 30.3.0 jest-util: 30.3.0 jest-validate: 30.3.0 @@ -16803,7 +16765,7 @@ snapshots: jest-haste-map@30.3.0: dependencies: '@jest/types': 30.3.0 - '@types/node': 25.5.2 + '@types/node': 25.6.0 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -16877,7 +16839,7 @@ snapshots: jest-mock@30.3.0: dependencies: '@jest/types': 30.3.0 - '@types/node': 25.5.2 + '@types/node': 25.6.0 jest-util: 30.3.0 jest-pnp-resolver@1.2.3(jest-resolve@30.3.0): @@ -16911,7 +16873,7 @@ snapshots: '@jest/test-result': 30.3.0 '@jest/transform': 30.3.0 '@jest/types': 30.3.0 - '@types/node': 25.5.2 + '@types/node': 25.6.0 chalk: 4.1.2 emittery: 0.13.1 exit-x: 0.2.2 @@ -16940,7 +16902,7 @@ snapshots: '@jest/test-result': 30.3.0 '@jest/transform': 30.3.0 '@jest/types': 30.3.0 - '@types/node': 25.5.2 + '@types/node': 25.6.0 chalk: 4.1.2 cjs-module-lexer: 2.2.0 collect-v8-coverage: 1.0.3 @@ -16987,7 +16949,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.5.2 + '@types/node': 25.6.0 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -16996,7 +16958,7 @@ snapshots: jest-util@30.3.0: dependencies: '@jest/types': 30.3.0 - '@types/node': 25.5.2 + '@types/node': 25.6.0 chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 @@ -17024,7 +16986,7 @@ snapshots: dependencies: '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 - '@types/node': 25.5.2 + '@types/node': 25.6.0 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -17039,7 +17001,7 @@ snapshots: jest-worker@30.3.0: dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@ungap/structured-clone': 1.3.0 jest-util: 30.3.0 merge-stream: 2.0.0 @@ -17058,19 +17020,6 @@ snapshots: - supports-color - ts-node - jest@30.3.0(@types/node@25.5.2): - dependencies: - '@jest/core': 30.3.0 - '@jest/types': 30.3.0 - import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@25.5.2) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest@30.3.0(@types/node@25.6.0): dependencies: '@jest/core': 30.3.0 @@ -19799,9 +19748,9 @@ snapshots: dependencies: typescript: 5.9.3 - ts-jest-mock-import-meta@1.2.1(ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.5.2))(typescript@5.9.3)): + ts-jest-mock-import-meta@1.2.1(ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.6.0))(typescript@5.9.3)): dependencies: - ts-jest: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.5.2))(typescript@5.9.3) + ts-jest: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.6.0))(typescript@5.9.3) ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.5.0))(typescript@5.9.3): dependencies: @@ -19823,12 +19772,12 @@ snapshots: babel-jest: 30.3.0(@babel/core@7.29.0) jest-util: 30.3.0 - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.5.2))(typescript@5.9.3): + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.6.0))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 30.3.0(@types/node@25.5.2) + jest: 30.3.0(@types/node@25.6.0) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 From b70a7d78684c6dd1e2c42d93576fc314d7eda5bd Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Thu, 16 Apr 2026 16:22:16 +0800 Subject: [PATCH 28/36] :wrench: chore(ext): rename worker output file name to extension-worker Signed-off-by: Alex Cui --- packages/extension/src/adapter/scratch/worker-adapter.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/adapter/scratch/worker-adapter.ts b/packages/extension/src/adapter/scratch/worker-adapter.ts index 9a73864b1..db9a82454 100644 --- a/packages/extension/src/adapter/scratch/worker-adapter.ts +++ b/packages/extension/src/adapter/scratch/worker-adapter.ts @@ -110,7 +110,10 @@ export class ScratchWorkerAdapter extends ScratchBaseAdapter { override enable(): Promise { return new Promise((resolve, reject) => { extensionService!.addPendingExtension(this.url, resolve, reject); - dispatch.addWorker(new Worker(new URL('./extension-worker.ts', import.meta.url))); + dispatch.addWorker(new Worker(new URL( + /* webpackChunkName: "extension-worker" */ './extension-worker.ts', + import.meta.url + ))); }).then((serviceName: string) => { this.serviceName = serviceName; return super.enable(); From 8e2b38e120cef60ef197bd4067edafba203f8d57 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Thu, 16 Apr 2026 16:48:43 +0800 Subject: [PATCH 29/36] :fire: chore(vm): remove/deprecate lagacy usage of extension manager Signed-off-by: Alex Cui --- packages/vm/src/dispatch/central-dispatch.js | 141 ------ packages/vm/src/dispatch/shared-dispatch.js | 230 --------- packages/vm/src/dispatch/worker-dispatch.js | 110 ----- packages/vm/src/engine/runtime.js | 5 + .../src/extension-support/define-messages.js | 18 - .../extension-support/extension-manager.js | 447 ------------------ .../src/extension-support/extension-worker.js | 57 --- packages/vm/src/virtual-machine.js | 4 - .../vm/test/fixtures/dispatch-test-service.js | 15 - .../fixtures/dispatch-test-worker-shim.js | 20 - .../vm/test/fixtures/dispatch-test-worker.js | 11 - packages/vm/test/unit/dispatch.js | 82 ---- 12 files changed, 5 insertions(+), 1135 deletions(-) delete mode 100644 packages/vm/src/dispatch/central-dispatch.js delete mode 100644 packages/vm/src/dispatch/shared-dispatch.js delete mode 100644 packages/vm/src/dispatch/worker-dispatch.js delete mode 100644 packages/vm/src/extension-support/define-messages.js delete mode 100644 packages/vm/src/extension-support/extension-manager.js delete mode 100644 packages/vm/src/extension-support/extension-worker.js delete mode 100644 packages/vm/test/fixtures/dispatch-test-service.js delete mode 100644 packages/vm/test/fixtures/dispatch-test-worker-shim.js delete mode 100644 packages/vm/test/fixtures/dispatch-test-worker.js delete mode 100644 packages/vm/test/unit/dispatch.js diff --git a/packages/vm/src/dispatch/central-dispatch.js b/packages/vm/src/dispatch/central-dispatch.js deleted file mode 100644 index 2bda2f951..000000000 --- a/packages/vm/src/dispatch/central-dispatch.js +++ /dev/null @@ -1,141 +0,0 @@ -const SharedDispatch = require('./shared-dispatch'); - -const log = require('../util/log'); - -/** - * This class serves as the central broker for message dispatch. It expects to operate on the main thread / Window and - * it must be informed of any Worker threads which will participate in the messaging system. From any context in the - * messaging system, the dispatcher's "call" method can call any method on any "service" provided in any participating - * context. The dispatch system will forward function arguments and return values across worker boundaries as needed. - * @see {WorkerDispatch} - */ -class CentralDispatch extends SharedDispatch { - constructor () { - super(); - - /** - * Map of channel name to worker or local service provider. - * If the entry is a Worker, the service is provided by an object on that worker. - * Otherwise, the service is provided locally and methods on the service will be called directly. - * @see {setService} - * @type {Record} - */ - this.services = {}; - - /** - * The constructor we will use to recognize workers. - * @type {Function} - */ - this.workerClass = (typeof Worker === 'undefined' ? null : Worker); - - /** - * List of workers attached to this dispatcher. - * @type {Array} - */ - this.workers = []; - } - - /** - * Synchronously call a particular method on a particular service provided locally. - * Calling this function on a remote service will fail. - * @param {string} service - the name of the service. - * @param {string} method - the name of the method. - * @param {*} [args] - the arguments to be copied to the method, if any. - * @returns {*} - the return value of the service method. - */ - callSync (service, method, ...args) { - const {provider, isRemote} = this._getServiceProvider(service); - if (provider) { - if (isRemote) { - throw new Error(`Cannot use 'callSync' on remote provider for service ${service}.`); - } - - return provider[method](...args); - } - throw new Error(`Provider not found for service: ${service}`); - } - - /** - * Synchronously set a local object as the global provider of the specified service. - * WARNING: Any method on the provider can be called from any worker within the dispatch system. - * @param {string} service - a globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'. - * @param {object} provider - a local object which provides this service. - */ - setServiceSync (service, provider) { - if (Object.prototype.hasOwnProperty.call(this.services, service)) { - log.warn(`Central dispatch replacing existing service provider for ${service}`); - } - this.services[service] = provider; - } - - /** - * Set a local object as the global provider of the specified service. - * WARNING: Any method on the provider can be called from any worker within the dispatch system. - * @param {string} service - a globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'. - * @param {object} provider - a local object which provides this service. - * @returns {Promise} - a promise which will resolve once the service is registered. - */ - setService (service, provider) { - /** Return a promise for consistency with {@link WorkerDispatch#setService} */ - try { - this.setServiceSync(service, provider); - return Promise.resolve(); - } catch (e) { - return Promise.reject(e); - } - } - - /** - * Add a worker to the message dispatch system. The worker must implement a compatible message dispatch framework. - * The dispatcher will immediately attempt to "handshake" with the worker. - * @param {Worker} worker - the worker to add into the dispatch system. - */ - addWorker (worker) { - if (this.workers.indexOf(worker) === -1) { - this.workers.push(worker); - worker.onmessage = this._onMessage.bind(this, worker); - this._remoteCall(worker, 'dispatch', 'handshake').catch(e => { - log.error(`Could not handshake with worker: ${JSON.stringify(e)}`); - }); - } else { - log.warn('Central dispatch ignoring attempt to add duplicate worker'); - } - } - - /** - * Fetch the service provider object for a particular service name. - * @override - * @param {string} service - the name of the service to look up - * @returns {{provider:(object|Worker), isRemote:boolean}} - the means to contact the service, if found - * @protected - */ - _getServiceProvider (service) { - const provider = this.services[service]; - return provider && { - provider, - isRemote: Boolean(this.workerClass && provider instanceof this.workerClass) - }; - } - - /** - * Handle a call message sent to the dispatch service itself - * @override - * @param {Worker} worker - the worker which sent the message. - * @param {DispatchCallMessage} message - the message to be handled. - * @returns {Promise|undefined} - a promise for the results of this operation, if appropriate - * @protected - */ - _onDispatchMessage (worker, message) { - let promise; - switch (message.method) { - case 'setService': - promise = this.setService(message.args[0], worker); - break; - default: - log.error(`Central dispatch received message for unknown method: ${message.method}`); - } - return promise; - } -} - -module.exports = new CentralDispatch(); diff --git a/packages/vm/src/dispatch/shared-dispatch.js b/packages/vm/src/dispatch/shared-dispatch.js deleted file mode 100644 index 65eb8a0c1..000000000 --- a/packages/vm/src/dispatch/shared-dispatch.js +++ /dev/null @@ -1,230 +0,0 @@ -const log = require('../util/log'); - -/** - * @typedef {object} DispatchCallMessage - a message to the dispatch system representing a service method call - * @property {*} responseId - send a response message with this response ID. See {@link DispatchResponseMessage} - * @property {string} service - the name of the service to be called - * @property {string} method - the name of the method to be called - * @property {Array|undefined} args - the arguments to be passed to the method - */ - -/** - * @typedef {object} DispatchResponseMessage - a message to the dispatch system representing the results of a call - * @property {*} responseId - a copy of the response ID from the call which generated this response - * @property {*|undefined} error - if this is truthy, then it contains results from a failed call (such as an exception) - * @property {*|undefined} result - if error is not truthy, then this contains the return value of the call (if any) - */ - -/** - * @typedef {DispatchCallMessage|DispatchResponseMessage} DispatchMessage - * Any message to the dispatch system. - */ - -/** - * The SharedDispatch class is responsible for dispatch features shared by - * {@link CentralDispatch} and {@link WorkerDispatch}. - */ -class SharedDispatch { - constructor () { - /** - * List of callback registrations for promises waiting for a response from a call to a service on another - * worker. A callback registration is an array of [resolve,reject] Promise functions. - * Calls to local services don't enter this list. - * @type {Array.} - */ - this.callbacks = []; - - /** - * The next response ID to be used. - * @type {int} - */ - this.nextResponseId = 0; - } - - /** - * Call a particular method on a particular service, regardless of whether that service is provided locally or on - * a worker. If the service is provided by a worker, the `args` will be copied using the Structured Clone - * algorithm, except for any items which are also in the `transfer` list. Ownership of those items will be - * transferred to the worker, and they should not be used after this call. - * @example - * dispatcher.call('vm', 'setData', 'cat', 42); - * // this finds the worker for the 'vm' service, then on that worker calls: - * vm.setData('cat', 42); - * @param {string} service - the name of the service. - * @param {string} method - the name of the method. - * @param {*} [args] - the arguments to be copied to the method, if any. - * @returns {Promise} - a promise for the return value of the service method. - */ - call (service, method, ...args) { - return this.transferCall(service, method, null, ...args); - } - - /** - * Call a particular method on a particular service, regardless of whether that service is provided locally or on - * a worker. If the service is provided by a worker, the `args` will be copied using the Structured Clone - * algorithm, except for any items which are also in the `transfer` list. Ownership of those items will be - * transferred to the worker, and they should not be used after this call. - * @example - * dispatcher.transferCall('vm', 'setData', [myArrayBuffer], 'cat', myArrayBuffer); - * // this finds the worker for the 'vm' service, transfers `myArrayBuffer` to it, then on that worker calls: - * vm.setData('cat', myArrayBuffer); - * @param {string} service - the name of the service. - * @param {string} method - the name of the method. - * @param {Array} [transfer] - objects to be transferred instead of copied. Must be present in `args` to be useful. - * @param {*} [args] - the arguments to be copied to the method, if any. - * @returns {Promise} - a promise for the return value of the service method. - */ - transferCall (service, method, transfer, ...args) { - try { - const {provider, isRemote} = this._getServiceProvider(service); - if (provider) { - if (isRemote) { - return this._remoteTransferCall(provider, service, method, transfer, ...args); - } - - const result = provider[method](...args); - return Promise.resolve(result); - } - return Promise.reject(new Error(`Service not found: ${service}`)); - } catch (e) { - return Promise.reject(e); - } - } - - /** - * Check if a particular service lives on another worker. - * @param {string} service - the service to check. - * @returns {boolean} - true if the service is remote (calls must cross a Worker boundary), false otherwise. - * @private - */ - _isRemoteService (service) { - return this._getServiceProvider(service).isRemote; - } - - /** - * Like {@link call}, but force the call to be posted through a particular communication channel. - * @param {object} provider - send the call through this object's `postMessage` function. - * @param {string} service - the name of the service. - * @param {string} method - the name of the method. - * @param {*} [args] - the arguments to be copied to the method, if any. - * @returns {Promise} - a promise for the return value of the service method. - */ - _remoteCall (provider, service, method, ...args) { - return this._remoteTransferCall(provider, service, method, null, ...args); - } - - /** - * Like {@link transferCall}, but force the call to be posted through a particular communication channel. - * @param {object} provider - send the call through this object's `postMessage` function. - * @param {string} service - the name of the service. - * @param {string} method - the name of the method. - * @param {Array} [transfer] - objects to be transferred instead of copied. Must be present in `args` to be useful. - * @param {*} [args] - the arguments to be copied to the method, if any. - * @returns {Promise} - a promise for the return value of the service method. - */ - _remoteTransferCall (provider, service, method, transfer, ...args) { - return new Promise((resolve, reject) => { - const responseId = this._storeCallbacks(resolve, reject); - - args = JSON.parse(JSON.stringify(args)); - - if (transfer) { - provider.postMessage({service, method, responseId, args}, transfer); - } else { - provider.postMessage({service, method, responseId, args}); - } - }); - } - - /** - * Store callback functions pending a response message. - * @param {Function} resolve - function to call if the service method returns. - * @param {Function} reject - function to call if the service method throws. - * @returns {*} - a unique response ID for this set of callbacks. See {@link _deliverResponse}. - * @protected - */ - _storeCallbacks (resolve, reject) { - const responseId = this.nextResponseId++; - this.callbacks[responseId] = [resolve, reject]; - return responseId; - } - - /** - * Deliver call response from a worker. This should only be called as the result of a message from a worker. - * @param {int} responseId - the response ID of the callback set to call. - * @param {DispatchResponseMessage} message - the message containing the response value(s). - * @protected - */ - _deliverResponse (responseId, message) { - try { - const [resolve, reject] = this.callbacks[responseId]; - delete this.callbacks[responseId]; - if (message.error) { - reject(message.error); - } else { - resolve(message.result); - } - } catch (e) { - log.error(`Dispatch callback failed: ${JSON.stringify(e)}`); - } - } - - /** - * Handle a message event received from a connected worker. - * @param {Worker} worker - the worker which sent the message, or the global object if running in a worker. - * @param {MessageEvent} event - the message event to be handled. - * @protected - */ - _onMessage (worker, event) { - /** @type {DispatchMessage} */ - const message = event.data; - message.args = message.args || []; - let promise; - if (message.service) { - if (message.service === 'dispatch') { - promise = this._onDispatchMessage(worker, message); - } else { - promise = this.call(message.service, message.method, ...message.args); - } - } else if (typeof message.responseId === 'undefined') { - log.error(`Dispatch caught malformed message from a worker: ${JSON.stringify(event)}`); - } else { - this._deliverResponse(message.responseId, message); - } - if (promise) { - if (typeof message.responseId === 'undefined') { - log.error(`Dispatch message missing required response ID: ${JSON.stringify(event)}`); - } else { - promise.then( - result => worker.postMessage({responseId: message.responseId, result}), - error => worker.postMessage({responseId: message.responseId, error}) - ); - } - } - } - - /** - * Fetch the service provider object for a particular service name. - * @abstract - * @param {string} service - the name of the service to look up - * @returns {{provider:(object|Worker), isRemote:boolean}} - the means to contact the service, if found - * @protected - */ - _getServiceProvider (service) { - throw new Error(`Could not get provider for ${service}: _getServiceProvider not implemented`); - } - - /** - * Handle a call message sent to the dispatch service itself - * @abstract - * @param {Worker} worker - the worker which sent the message. - * @param {DispatchCallMessage} message - the message to be handled. - * @returns {Promise|undefined} - a promise for the results of this operation, if appropriate - * @private - */ - _onDispatchMessage (worker, message) { - throw new Error(`Unimplemented dispatch message handler cannot handle ${message.method} method`); - } -} - -module.exports = SharedDispatch; diff --git a/packages/vm/src/dispatch/worker-dispatch.js b/packages/vm/src/dispatch/worker-dispatch.js deleted file mode 100644 index b93aac123..000000000 --- a/packages/vm/src/dispatch/worker-dispatch.js +++ /dev/null @@ -1,110 +0,0 @@ -const SharedDispatch = require('./shared-dispatch'); - -const log = require('../util/log'); - -/** - * This class provides a Worker with the means to participate in the message dispatch system managed by CentralDispatch. - * From any context in the messaging system, the dispatcher's "call" method can call any method on any "service" - * provided in any participating context. The dispatch system will forward function arguments and return values across - * worker boundaries as needed. - * @see {CentralDispatch} - */ -class WorkerDispatch extends SharedDispatch { - constructor () { - super(); - - /** - * This promise will be resolved when we have successfully connected to central dispatch. - * @type {Promise} - * @see {waitForConnection} - * @private - */ - this._connectionPromise = new Promise(resolve => { - this._onConnect = resolve; - }); - - /** - * Map of service name to local service provider. - * If a service is not listed here, it is assumed to be provided by another context (another Worker or the main - * thread). - * @see {setService} - * @type {object} - */ - this.services = {}; - - this._onMessage = this._onMessage.bind(this, self); - if (typeof self !== 'undefined') { - self.onmessage = this._onMessage; - } - } - - /** - * @returns {Promise} a promise which will resolve upon connection to central dispatch. If you need to make a call - * immediately on "startup" you can attach a 'then' to this promise. - * @example - * dispatch.waitForConnection.then(() => { - * dispatch.call('myService', 'hello'); - * }) - */ - get waitForConnection () { - return this._connectionPromise; - } - - /** - * Set a local object as the global provider of the specified service. - * WARNING: Any method on the provider can be called from any worker within the dispatch system. - * @param {string} service - a globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'. - * @param {object} provider - a local object which provides this service. - * @returns {Promise} - a promise which will resolve once the service is registered. - */ - setService (service, provider) { - if (Object.prototype.hasOwnProperty.call(this.services, service)) { - log.warn(`Worker dispatch replacing existing service provider for ${service}`); - } - this.services[service] = provider; - return this.waitForConnection.then(() => this._remoteCall(self, 'dispatch', 'setService', service)); - } - - /** - * Fetch the service provider object for a particular service name. - * @override - * @param {string} service - the name of the service to look up - * @returns {{provider:(object|Worker), isRemote:boolean}} - the means to contact the service, if found - * @protected - */ - _getServiceProvider (service) { - // if we don't have a local service by this name, contact central dispatch by calling `postMessage` on self - const provider = this.services[service]; - return { - provider: provider || self, - isRemote: !provider - }; - } - - /** - * Handle a call message sent to the dispatch service itself - * @override - * @param {Worker} worker - the worker which sent the message. - * @param {DispatchCallMessage} message - the message to be handled. - * @returns {Promise|undefined} - a promise for the results of this operation, if appropriate - * @protected - */ - _onDispatchMessage (worker, message) { - let promise; - switch (message.method) { - case 'handshake': - promise = this._onConnect(); - break; - case 'terminate': - // Don't close until next tick, after sending confirmation back - setTimeout(() => self.close(), 0); - promise = Promise.resolve(); - break; - default: - log.error(`Worker dispatch received message for unknown method: ${message.method}`); - } - return promise; - } -} - -module.exports = new WorkerDispatch(); diff --git a/packages/vm/src/engine/runtime.js b/packages/vm/src/engine/runtime.js index c2dc9c9e8..a6a4498c4 100644 --- a/packages/vm/src/engine/runtime.js +++ b/packages/vm/src/engine/runtime.js @@ -1089,6 +1089,7 @@ class Runtime extends EventEmitter { /** * Register the primitives provided by an extension. * @param {ExtensionMetadata} extensionInfo - information about the extension (id, blocks, etc.) + * @deprecated * @private */ _registerExtensionPrimitives (extensionInfo) { @@ -1132,6 +1133,7 @@ class Runtime extends EventEmitter { /** * Reregister the primitives for an extension * @param {ExtensionMetadata} extensionInfo - new info (results of running getInfo) for an extension + * @deprecated * @private */ _refreshExtensionPrimitives (extensionInfo) { @@ -1149,6 +1151,7 @@ class Runtime extends EventEmitter { * and store the results in the provided category object. * @param {CategoryInfo} categoryInfo - the category to be filled * @param {ExtensionMetadata} extensionInfo - the extension metadata to read + * @deprecated * @private */ _fillExtensionCategory (categoryInfo, extensionInfo) { @@ -1656,6 +1659,7 @@ class Runtime extends EventEmitter { /** * Get scratch-blocks XML for each extension category. * @param {Target|undefined} target - the active editing target, if any. + * @deprecated Use new clipcc-extension's manager, replaced by `ExtensionManager.getToolboxContents`. * @returns {Array} Scratch-blocks XML for each category of extension blocks. */ getBlocksXML (target) { @@ -1705,6 +1709,7 @@ class Runtime extends EventEmitter { /** * Get scratch-blocks JSON for each dynamic block. + * @deprecated * @returns {Array} The scratch-blocks JSON information for each dynamic block. */ getBlocksJSON () { diff --git a/packages/vm/src/extension-support/define-messages.js b/packages/vm/src/extension-support/define-messages.js deleted file mode 100644 index a0327fc8b..000000000 --- a/packages/vm/src/extension-support/define-messages.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @typedef {object} MessageDescriptor - * @property {string} id - the translator-friendly unique ID of this message. - * @property {string} default - the message text in the default language (English). - * @property {string} [description] - a description of this message to help translators understand the context. - */ - -/** - * This is a hook for extracting messages from extension source files. - * This function simply returns the message descriptor map object that's passed in. - * @param {Record} messages - the messages to be defined - * @returns {Record} - the input, unprocessed - */ -const defineMessages = function (messages) { - return messages; -}; - -module.exports = defineMessages; diff --git a/packages/vm/src/extension-support/extension-manager.js b/packages/vm/src/extension-support/extension-manager.js deleted file mode 100644 index dfc92c0f2..000000000 --- a/packages/vm/src/extension-support/extension-manager.js +++ /dev/null @@ -1,447 +0,0 @@ -const dispatch = require('../dispatch/central-dispatch'); -const log = require('../util/log'); -const maybeFormatMessage = require('../util/maybe-format-message'); - -const BlockType = require('./block-type'); - -// These extensions are currently built into the VM repository but should not be loaded at startup. -// TODO: move these out into a separate repository? -// TODO: change extension spec so that library info, including extension ID, can be collected through static methods - -/* eslint-disable global-require */ -const builtinExtensions = { - // This is an example that isn't loaded with the other core blocks, - // but serves as a reference for loading core blocks as extensions. - coreExample: () => require('../blocks/scratch3_core_example'), - // These are the non-core built-in extensions. - pen: () => require('../extensions/scratch3_pen'), - wedo2: () => require('../extensions/scratch3_wedo2'), - music: () => require('../extensions/scratch3_music'), - microbit: () => require('../extensions/scratch3_microbit'), - text2speech: () => require('../extensions/scratch3_text2speech'), - translate: () => require('../extensions/scratch3_translate'), - videoSensing: () => require('../extensions/scratch3_video_sensing'), - ev3: () => require('../extensions/scratch3_ev3'), - makeymakey: () => require('../extensions/scratch3_makeymakey'), - boost: () => require('../extensions/scratch3_boost'), - gdxfor: () => require('../extensions/scratch3_gdx_for') -}; -/* eslint-enable global-require */ - -/** - * @typedef {object} ArgumentInfo - Information about an extension block argument - * @property {ArgumentType} type - the type of value this argument can take - * @property {*|undefined} default - the default value of this argument (default: blank) - */ - -/** - * @typedef {object} ConvertedBlockInfo - Raw extension block data paired with processed data ready for scratch-blocks - * @property {ExtensionBlockMetadata} info - the raw block info - * @property {object} json - the scratch-blocks JSON definition for this block - * @property {string} xml - the scratch-blocks XML definition for this block - */ - -/** - * @typedef {object} CategoryInfo - Information about a block category - * @property {string} id - the unique ID of this category - * @property {string} name - the human-readable name of this category - * @property {string|undefined} blockIconURI - optional URI for the block icon image - * @property {string} color1 - the primary color for this category, in '#rrggbb' format - * @property {string} color2 - the secondary color for this category, in '#rrggbb' format - * @property {string} color3 - the tertiary color for this category, in '#rrggbb' format - * @property {Array.} blocks - the blocks, separators, etc. in this category - * @property {Array.} menus - the menus provided by this category - */ - -/** - * @typedef {object} PendingExtensionWorker - Information about an extension worker still initializing - * @property {string} extensionURL - the URL of the extension to be loaded by this worker - * @property {Function} resolve - function to call on successful worker startup - * @property {Function} reject - function to call on failed worker startup - */ - -class ExtensionManager { - constructor (runtime) { - /** - * The ID number to provide to the next extension worker. - * @type {int} - */ - this.nextExtensionWorker = 0; - - /** - * FIFO queue of extensions which have been requested but not yet loaded in a worker, - * along with promise resolution functions to call once the worker is ready or failed. - * - * @type {Array.} - */ - this.pendingExtensions = []; - - /** - * Map of worker ID to workers which have been allocated but have not yet finished initialization. - * @type {Array.} - */ - this.pendingWorkers = []; - - /** - * Map of loaded extension URLs/IDs (equivalent for built-in extensions) to service name. - * @type {Map.} - * @private - */ - this._loadedExtensions = new Map(); - - /** - * Keep a reference to the runtime so we can construct internal extension objects. - * TODO: remove this in favor of extensions accessing the runtime as a service. - * @type {Runtime} - */ - this.runtime = runtime; - - dispatch.setService('extensions', this).catch(e => { - log.error(`ExtensionManager was unable to register extension service: ${JSON.stringify(e)}`); - }); - } - - /** - * Check whether an extension is registered or is in the process of loading. This is intended to control loading or - * adding extensions so it may return `true` before the extension is ready to be used. Use the promise returned by - * `loadExtensionURL` if you need to wait until the extension is truly ready. - * @param {string} extensionID - the ID of the extension. - * @returns {boolean} - true if loaded, false otherwise. - */ - isExtensionLoaded (extensionID) { - return this._loadedExtensions.has(extensionID); - } - - /** - * Synchronously load an internal extension (core or non-core) by ID. This call will - * fail if the provided id is not does not match an internal extension. - * @param {string} extensionId - the ID of an internal extension - */ - loadExtensionIdSync (extensionId) { - if (!Object.prototype.hasOwnProperty.call(builtinExtensions, extensionId)) { - log.warn(`Could not find extension ${extensionId} in the built in extensions.`); - return; - } - - /** @todo dupe handling for non-builtin extensions. See commit 670e51d33580e8a2e852b3b038bb3afc282f81b9 */ - if (this.isExtensionLoaded(extensionId)) { - const message = `Rejecting attempt to load a second extension with ID ${extensionId}`; - log.warn(message); - return; - } - - const extension = builtinExtensions[extensionId](); - const extensionInstance = new extension(this.runtime); - const serviceName = this._registerInternalExtension(extensionInstance); - this._loadedExtensions.set(extensionId, serviceName); - } - - /** - * Load an extension by URL or internal extension ID - * @param {string} extensionURL - the URL for the extension to load OR the ID of an internal extension - * @returns {Promise} resolved once the extension is loaded and initialized or rejected on failure - */ - loadExtensionURL (extensionURL) { - if (Object.prototype.hasOwnProperty.call(builtinExtensions, extensionURL)) { - /** @todo dupe handling for non-builtin extensions. See commit 670e51d33580e8a2e852b3b038bb3afc282f81b9 */ - if (this.isExtensionLoaded(extensionURL)) { - const message = `Rejecting attempt to load a second extension with ID ${extensionURL}`; - log.warn(message); - return Promise.resolve(); - } - - const extension = builtinExtensions[extensionURL](); - const extensionInstance = new extension(this.runtime); - const serviceName = this._registerInternalExtension(extensionInstance); - this._loadedExtensions.set(extensionURL, serviceName); - return Promise.resolve(); - } - - return new Promise((resolve, reject) => { - /** - * If we `require` this at the global level it breaks non-webpack targets, including tests - * Also, webpack 5's implementation will break non-webpack targets since VM is not a ESModule. - * Before VM migration to ESM, we still need to use worker-loader to solve this problem. - */ - // eslint-disable-next-line global-require - const ExtensionWorker = require('codingclip-worker-loader?filename=extension-worker.js!./extension-worker'); - - this.pendingExtensions.push({extensionURL, resolve, reject}); - dispatch.addWorker(new ExtensionWorker()); - }); - } - - /** - * Regenerate blockinfo for any loaded extensions - * @returns {Promise} resolved once all the extensions have been reinitialized - */ - refreshBlocks () { - const allPromises = Array.from(this._loadedExtensions.values()).map(serviceName => - dispatch.call(serviceName, 'getInfo') - .then(info => { - info = this._prepareExtensionInfo(serviceName, info); - dispatch.call('runtime', '_refreshExtensionPrimitives', info); - }) - .catch(e => { - log.error(`Failed to refresh built-in extension primitives: ${JSON.stringify(e)}`); - }) - ); - return Promise.all(allPromises); - } - - allocateWorker () { - const id = this.nextExtensionWorker++; - const workerInfo = this.pendingExtensions.shift(); - this.pendingWorkers[id] = workerInfo; - return [id, workerInfo.extensionURL]; - } - - /** - * Synchronously collect extension metadata from the specified service and begin the extension registration process. - * @param {string} serviceName - the name of the service hosting the extension. - */ - registerExtensionServiceSync (serviceName) { - const info = dispatch.callSync(serviceName, 'getInfo'); - this._registerExtensionInfo(serviceName, info); - } - - /** - * Collect extension metadata from the specified service and begin the extension registration process. - * @param {string} serviceName - the name of the service hosting the extension. - */ - registerExtensionService (serviceName) { - dispatch.call(serviceName, 'getInfo').then(info => { - this._registerExtensionInfo(serviceName, info); - }); - } - - /** - * Called by an extension worker to indicate that the worker has finished initialization. - * @param {int} id - the worker ID. - * @param {*?} e - the error encountered during initialization, if any. - */ - onWorkerInit (id, e) { - const workerInfo = this.pendingWorkers[id]; - delete this.pendingWorkers[id]; - if (e) { - workerInfo.reject(e); - } else { - workerInfo.resolve(id); - } - } - - /** - * Register an internal (non-Worker) extension object - * @param {object} extensionObject - the extension object to register - * @returns {string} The name of the registered extension service - */ - _registerInternalExtension (extensionObject) { - const extensionInfo = extensionObject.getInfo(); - const fakeWorkerId = this.nextExtensionWorker++; - const serviceName = `extension_${fakeWorkerId}_${extensionInfo.id}`; - dispatch.setServiceSync(serviceName, extensionObject); - dispatch.callSync('extensions', 'registerExtensionServiceSync', serviceName); - return serviceName; - } - - /** - * Sanitize extension info then register its primitives with the VM. - * @param {string} serviceName - the name of the service hosting the extension - * @param {ExtensionInfo} extensionInfo - the extension's metadata - * @private - */ - _registerExtensionInfo (serviceName, extensionInfo) { - extensionInfo = this._prepareExtensionInfo(serviceName, extensionInfo); - dispatch.call('runtime', '_registerExtensionPrimitives', extensionInfo).catch(e => { - log.error(`Failed to register primitives for extension on service ${serviceName}:`, e); - }); - } - - /** - * Modify the provided text as necessary to ensure that it may be used as an attribute value in valid XML. - * @param {string} text - the text to be sanitized - * @returns {string} - the sanitized text - * @private - */ - _sanitizeID (text) { - return text.toString().replace(/[<"&]/, '_'); - } - - /** - * Apply minor cleanup and defaults for optional extension fields. - * TODO: make the ID unique in cases where two copies of the same extension are loaded. - * @param {string} serviceName - the name of the service hosting this extension block - * @param {ExtensionInfo} extensionInfo - the extension info to be sanitized - * @returns {ExtensionInfo} - a new extension info object with cleaned-up values - * @private - */ - _prepareExtensionInfo (serviceName, extensionInfo) { - extensionInfo = Object.assign({}, extensionInfo); - if (!/^[a-z0-9]+$/i.test(extensionInfo.id)) { - throw new Error('Invalid extension id'); - } - extensionInfo.name = extensionInfo.name || extensionInfo.id; - extensionInfo.blocks = extensionInfo.blocks || []; - extensionInfo.targetTypes = extensionInfo.targetTypes || []; - extensionInfo.blocks = extensionInfo.blocks.reduce((results, blockInfo) => { - try { - let result; - switch (blockInfo) { - case '---': // separator - result = '---'; - break; - default: // an ExtensionBlockMetadata object - result = this._prepareBlockInfo(serviceName, blockInfo); - break; - } - results.push(result); - } catch (e) { - // TODO: more meaningful error reporting - log.error(`Error processing block: ${e.message}, Block:\n${JSON.stringify(blockInfo)}`); - } - return results; - }, []); - extensionInfo.menus = extensionInfo.menus || {}; - extensionInfo.menus = this._prepareMenuInfo(serviceName, extensionInfo.menus); - return extensionInfo; - } - - /** - * Prepare extension menus. e.g. setup binding for dynamic menu functions. - * @param {string} serviceName - the name of the service hosting this extension block - * @param {Array.} menus - the menu defined by the extension. - * @returns {Array.} - a menuInfo object with all preprocessing done. - * @private - */ - _prepareMenuInfo (serviceName, menus) { - const menuNames = Object.getOwnPropertyNames(menus); - for (let i = 0; i < menuNames.length; i++) { - const menuName = menuNames[i]; - let menuInfo = menus[menuName]; - - // If the menu description is in short form (items only) then normalize it to general form: an object with - // its items listed in an `items` property. - if (!menuInfo.items) { - menuInfo = { - items: menuInfo - }; - menus[menuName] = menuInfo; - } - // If `items` is a string, it should be the name of a function in the extension object. Calling the - // function should return an array of items to populate the menu when it is opened. - if (typeof menuInfo.items === 'string') { - const menuItemFunctionName = menuInfo.items; - const serviceObject = dispatch.services[serviceName]; - // Bind the function here so we can pass a simple item generation function to Scratch Blocks later. - menuInfo.items = this._getExtensionMenuItems.bind(this, serviceObject, menuItemFunctionName); - } - } - return menus; - } - - /** - * Fetch the items for a particular extension menu, providing the target ID for context. - * @param {object} extensionObject - the extension object providing the menu. - * @param {string} menuItemFunctionName - the name of the menu function to call. - * @returns {Array} menu items ready for scratch-blocks. - * @private - */ - _getExtensionMenuItems (extensionObject, menuItemFunctionName) { - // Fetch the items appropriate for the target currently being edited. This assumes that menus only - // collect items when opened by the user while editing a particular target. - const editingTarget = this.runtime.getEditingTarget() || this.runtime.getTargetForStage(); - const editingTargetID = editingTarget ? editingTarget.id : null; - const extensionMessageContext = this.runtime.makeMessageContextForTarget(editingTarget); - - // TODO: Fix this to use dispatch.call when extensions are running in workers. - const menuFunc = extensionObject[menuItemFunctionName]; - const menuItems = menuFunc.call(extensionObject, editingTargetID).map( - item => { - item = maybeFormatMessage(item, extensionMessageContext); - switch (typeof item) { - case 'object': - return [ - maybeFormatMessage(item.text, extensionMessageContext), - item.value - ]; - case 'string': - return [item, item]; - default: - return item; - } - }); - - if (!menuItems || menuItems.length < 1) { - throw new Error(`Extension menu returned no items: ${menuItemFunctionName}`); - } - return menuItems; - } - - /** - * Apply defaults for optional block fields. - * @param {string} serviceName - the name of the service hosting this extension block - * @param {ExtensionBlockMetadata} blockInfo - the block info from the extension - * @returns {ExtensionBlockMetadata} - a new block info object which has values for all relevant optional fields. - * @private - */ - _prepareBlockInfo (serviceName, blockInfo) { - blockInfo = Object.assign({}, { - blockType: BlockType.COMMAND, - terminal: false, - blockAllThreads: false, - arguments: {} - }, blockInfo); - blockInfo.opcode = blockInfo.opcode && this._sanitizeID(blockInfo.opcode); - blockInfo.text = blockInfo.text || blockInfo.opcode; - - switch (blockInfo.blockType) { - case BlockType.EVENT: - if (blockInfo.func) { - log.warn(`Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}`); - } - break; - case BlockType.BUTTON: - if (blockInfo.opcode) { - log.warn(`Ignoring opcode "${blockInfo.opcode}" for button with text: ${blockInfo.text}`); - } - break; - default: { - if (!blockInfo.opcode) { - throw new Error('Missing opcode for block'); - } - - const funcName = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode; - - const getBlockInfo = blockInfo.isDynamic ? - args => args && args.mutation && args.mutation.blockInfo : - () => blockInfo; - const callBlockFunc = (() => { - if (dispatch._isRemoteService(serviceName)) { - return (args, util, realBlockInfo) => - dispatch.call(serviceName, funcName, args, util, realBlockInfo); - } - - // avoid promise latency if we can call direct - const serviceObject = dispatch.services[serviceName]; - if (!serviceObject[funcName]) { - // The function might show up later as a dynamic property of the service object - log.warn(`Could not find extension block function called ${funcName}`); - } - return (args, util, realBlockInfo) => - serviceObject[funcName](args, util, realBlockInfo); - })(); - - blockInfo.func = (args, util) => { - const realBlockInfo = getBlockInfo(args); - // TODO: filter args using the keys of realBlockInfo.arguments? maybe only if sandboxed? - return callBlockFunc(args, util, realBlockInfo); - }; - break; - } - } - - return blockInfo; - } -} - -module.exports = ExtensionManager; diff --git a/packages/vm/src/extension-support/extension-worker.js b/packages/vm/src/extension-support/extension-worker.js deleted file mode 100644 index 6c639f4de..000000000 --- a/packages/vm/src/extension-support/extension-worker.js +++ /dev/null @@ -1,57 +0,0 @@ -const ArgumentType = require('../extension-support/argument-type'); -const BlockType = require('../extension-support/block-type'); -const dispatch = require('../dispatch/worker-dispatch'); -const TargetType = require('./target-type'); - -class ExtensionWorker { - constructor () { - this.nextExtensionId = 0; - - this.initialRegistrations = []; - - dispatch.waitForConnection.then(() => { - dispatch.call('extensions', 'allocateWorker').then(x => { - const [id, extension] = x; - this.workerId = id; - - try { - importScripts(extension); - - const initialRegistrations = this.initialRegistrations; - this.initialRegistrations = null; - - Promise.all(initialRegistrations).then(() => dispatch.call('extensions', 'onWorkerInit', id)); - } catch (e) { - dispatch.call('extensions', 'onWorkerInit', id, e); - } - }); - }); - - this.extensions = []; - } - - register (extensionObject) { - const extensionId = this.nextExtensionId++; - this.extensions.push(extensionObject); - const serviceName = `extension.${this.workerId}.${extensionId}`; - const promise = dispatch.setService(serviceName, extensionObject) - .then(() => dispatch.call('extensions', 'registerExtensionService', serviceName)); - if (this.initialRegistrations) { - this.initialRegistrations.push(promise); - } - return promise; - } -} - -global.Scratch = global.Scratch || {}; -global.Scratch.ArgumentType = ArgumentType; -global.Scratch.BlockType = BlockType; -global.Scratch.TargetType = TargetType; - -/** - * Expose only specific parts of the worker to extensions. - */ -const extensionWorker = new ExtensionWorker(); -global.Scratch.extensions = { - register: extensionWorker.register.bind(extensionWorker) -}; diff --git a/packages/vm/src/virtual-machine.js b/packages/vm/src/virtual-machine.js index eb903fcf0..a54441bb6 100644 --- a/packages/vm/src/virtual-machine.js +++ b/packages/vm/src/virtual-machine.js @@ -10,7 +10,6 @@ const JSZip = require('jszip'); const {ScratchBuiltinAdapter} = require('clipcc-extension'); const Buffer = require('buffer').Buffer; -const centralDispatch = require('./dispatch/central-dispatch'); const log = require('./util/log'); const MathUtil = require('./util/math-util'); const Runtime = require('./engine/runtime'); @@ -62,9 +61,6 @@ class VirtualMachine extends EventEmitter { * @type {!Runtime} */ this.runtime = new Runtime(); - centralDispatch.setService('runtime', this.runtime).catch(e => { - log.error(`Failed to register runtime service: ${JSON.stringify(e)}`); - }); /** * The "currently editing"/selected target ID for the VM. diff --git a/packages/vm/test/fixtures/dispatch-test-service.js b/packages/vm/test/fixtures/dispatch-test-service.js deleted file mode 100644 index 3e01e68f5..000000000 --- a/packages/vm/test/fixtures/dispatch-test-service.js +++ /dev/null @@ -1,15 +0,0 @@ -class DispatchTestService { - returnFortyTwo () { - return 42; - } - - doubleArgument (x) { - return 2 * x; - } - - throwException () { - throw new Error('This is a test exception thrown by DispatchTest'); - } -} - -module.exports = DispatchTestService; diff --git a/packages/vm/test/fixtures/dispatch-test-worker-shim.js b/packages/vm/test/fixtures/dispatch-test-worker-shim.js deleted file mode 100644 index cff7ab632..000000000 --- a/packages/vm/test/fixtures/dispatch-test-worker-shim.js +++ /dev/null @@ -1,20 +0,0 @@ -const Module = require('module'); - -const callsite = require('callsite'); -const path = require('path'); - -const oldRequire = Module.prototype.require; -Module.prototype.require = function (target) { - if (!target.startsWith('./') && !target.startsWith('../') && !path.isAbsolute(target)) { - // eslint-disable-next-line prefer-rest-params - return oldRequire.apply(this, arguments); - } - - const stack = callsite(); - const callerFile = stack[2].getFileName(); - const callerDir = path.dirname(callerFile); - target = path.resolve(callerDir, target); - return oldRequire.call(this, target); -}; - -oldRequire(path.resolve(__dirname, 'dispatch-test-worker')); diff --git a/packages/vm/test/fixtures/dispatch-test-worker.js b/packages/vm/test/fixtures/dispatch-test-worker.js deleted file mode 100644 index 2c57fbb3e..000000000 --- a/packages/vm/test/fixtures/dispatch-test-worker.js +++ /dev/null @@ -1,11 +0,0 @@ -const dispatch = require('../../src/dispatch/worker-dispatch'); -const DispatchTestService = require('./dispatch-test-service'); -const log = require('../../src/util/log'); - -dispatch.setService('RemoteDispatchTest', new DispatchTestService()); - -dispatch.waitForConnection.then(() => { - dispatch.call('test', 'onWorkerReady').catch(e => { - log(`Test worker failed to call onWorkerReady: ${JSON.stringify(e)}`); - }); -}); diff --git a/packages/vm/test/unit/dispatch.js b/packages/vm/test/unit/dispatch.js deleted file mode 100644 index dfd00155e..000000000 --- a/packages/vm/test/unit/dispatch.js +++ /dev/null @@ -1,82 +0,0 @@ -const DispatchTestService = require('../fixtures/dispatch-test-service'); -const Worker = require('tiny-worker'); - -const dispatch = require('../../src/dispatch/central-dispatch'); -const path = require('path'); -const test = require('tap').test; - - -// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead. -dispatch.workerClass = Worker; - -const runServiceTest = function (serviceName, t) { - const promises = []; - - promises.push(dispatch.call(serviceName, 'returnFortyTwo') - .then( - x => t.equal(x, 42), - e => t.fail(e) - )); - - promises.push(dispatch.call(serviceName, 'doubleArgument', 9) - .then( - x => t.equal(x, 18), - e => t.fail(e) - )); - - promises.push(dispatch.call(serviceName, 'doubleArgument', 123) - .then( - x => t.equal(x, 246), - e => t.fail(e) - )); - - // I tried using `t.rejects` here but ran into https://github.com/tapjs/node-tap/issues/384 - promises.push(dispatch.call(serviceName, 'throwException') - .then( - () => t.fail('exception was not propagated as expected'), - () => t.pass('exception was propagated as expected') - )); - - return Promise.all(promises); -}; - -test('local', t => { - dispatch.setService('LocalDispatchTest', new DispatchTestService()) - .catch(e => t.fail(e)); - - return runServiceTest('LocalDispatchTest', t); -}); - -test('remote', t => { - const fixturesDir = path.resolve(__dirname, '../fixtures'); - const shimPath = path.resolve(fixturesDir, 'dispatch-test-worker-shim.js'); - const worker = new Worker(shimPath, null, {cwd: fixturesDir}); - dispatch.addWorker(worker); - - const waitForWorker = new Promise(resolve => { - dispatch.setService('test', {onWorkerReady: resolve}) - .catch(e => t.fail(e)); - }); - - return waitForWorker - .then(() => runServiceTest('RemoteDispatchTest', t), e => t.fail(e)) - .then(() => dispatch._remoteCall(worker, 'dispatch', 'terminate'), e => t.fail(e)); -}); - -test('local, sync', t => { - dispatch.setServiceSync('SyncDispatchTest', new DispatchTestService()); - - const a = dispatch.callSync('SyncDispatchTest', 'returnFortyTwo'); - t.equal(a, 42); - - const b = dispatch.callSync('SyncDispatchTest', 'doubleArgument', 9); - t.equal(b, 18); - - const c = dispatch.callSync('SyncDispatchTest', 'doubleArgument', 123); - t.equal(c, 246); - - t.throws(() => dispatch.callSync('SyncDispatchTest', 'throwException'), - new Error('This is a test exception thrown by DispatchTest')); - - t.end(); -}); From 71c3484161e603379f1070a3fe1296f613f2bec9 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Thu, 16 Apr 2026 17:40:06 +0800 Subject: [PATCH 30/36] :wrench: build(ext): fix commonjs2 output Signed-off-by: Alex Cui --- packages/extension/webpack.config.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/extension/webpack.config.js b/packages/extension/webpack.config.js index c5ff50bf4..459ce4f2f 100644 --- a/packages/extension/webpack.config.js +++ b/packages/extension/webpack.config.js @@ -10,7 +10,6 @@ const baseConfig = { 'clipcc-extension': './src/index.ts' }, output: { - library: 'Extension', filename: '[name].js' }, resolve: { @@ -42,7 +41,10 @@ module.exports = [ defaultsDeep({}, baseConfig, { target: 'web', output: { - libraryTarget: 'umd', + library: { + name: 'Extension', + type: 'umd' + }, path: path.resolve(__dirname, 'dist', 'web') } }), @@ -50,7 +52,9 @@ module.exports = [ defaultsDeep({}, baseConfig, { target: 'node', output: { - libraryTarget: 'commonjs2', + library: { + type: 'commonjs2' + }, path: path.resolve(__dirname, 'dist', 'node') } }), From 7ad46135de71a05c1b9339f46c00251570decc19 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Thu, 16 Apr 2026 19:20:49 +0800 Subject: [PATCH 31/36] :bug: fix(vm): api changes for extension manager Signed-off-by: Alex Cui --- packages/vm/src/virtual-machine.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/vm/src/virtual-machine.js b/packages/vm/src/virtual-machine.js index a54441bb6..752f47670 100644 --- a/packages/vm/src/virtual-machine.js +++ b/packages/vm/src/virtual-machine.js @@ -629,8 +629,8 @@ class VirtualMachine extends EventEmitter { const extensionPromises = []; extensions.extensionIDs.forEach(extensionID => { - if (!this.extensionManager.isExtensionLoaded(extensionID)) { - this.extensionManager.enableExtension(extensionID); + if (!this.extensionManager.isExtensionEnabled(extensionID)) { + extensionPromises.push(this.extensionManager.enableExtension(extensionID)); } }); @@ -1449,10 +1449,7 @@ class VirtualMachine extends EventEmitter { // Create an array promises for extensions to load const extensionPromises = Array.from(extensionIDs, - id => { - this.extensionManager.enableExtension(id); - return Promise.resolve(); - } + id => this.extensionManager.enableExtension(id) ); return Promise.all(extensionPromises).then(() => { From b51b6a3602ecf8bcb70552dd1ed405cfea738818 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Thu, 16 Apr 2026 19:22:41 +0800 Subject: [PATCH 32/36] :test_tube: test(vm): fix vm tests related to new extension manager Signed-off-by: Alex Cui --- .../test/fixtures/attach-extension-manager.js | 52 +++++++++++++ .../vm/test/integration/internal-extension.js | 73 +++++++++++-------- .../vm/test/integration/load-extensions.js | 15 ++-- packages/vm/test/integration/monitors_sb3.js | 2 + packages/vm/test/integration/pen.js | 7 +- .../sb2-import-extension-monitors.js | 13 +++- packages/vm/test/integration/sound.js | 7 +- packages/vm/test/unit/virtual-machine.js | 5 +- 8 files changed, 121 insertions(+), 53 deletions(-) create mode 100644 packages/vm/test/fixtures/attach-extension-manager.js diff --git a/packages/vm/test/fixtures/attach-extension-manager.js b/packages/vm/test/fixtures/attach-extension-manager.js new file mode 100644 index 000000000..c10051dfa --- /dev/null +++ b/packages/vm/test/fixtures/attach-extension-manager.js @@ -0,0 +1,52 @@ +const {ExtensionManager} = require('clipcc-extension'); + +const extensionManifest = [ + { + name: 'Music', + extensionId: 'music' + }, + { + name: 'Pen', + extensionId: 'pen' + }, + { + name: 'Video Sensing', + extensionId: 'videoSensing' + }, + { + name: 'Text to Speech', + extensionId: 'text2speech' + }, + { + name: 'Translate', + extensionId: 'translate' + }, + { + name: 'Makey Makey', + extensionId: 'makeymakey' + }, + { + name: 'micro:bit', + extensionId: 'microbit' + }, + { + name: 'LEGO MINDSTORMS EV3', + extensionId: 'ev3' + }, + { + name: 'LEGO BOOST', + extensionId: 'boost' + }, + { + name: 'LEGO Education WeDo 2.0', + extensionId: 'wedo2' + }, + { + name: 'Go Direct Force & Acceleration', + extensionId: 'gdxfor' + } +]; + +module.exports = function (vm) { + vm.attachExtensionManager(new ExtensionManager(), extensionManifest); +}; diff --git a/packages/vm/test/integration/internal-extension.js b/packages/vm/test/integration/internal-extension.js index 5c95b6da2..30831ce7c 100644 --- a/packages/vm/test/integration/internal-extension.js +++ b/packages/vm/test/integration/internal-extension.js @@ -1,17 +1,13 @@ const test = require('tap').test; -const Worker = require('tiny-worker'); +const {ExtensionManager, ScratchBuiltinAdapter} = require('clipcc-extension'); const BlockType = require('../../src/extension-support/block-type'); -const dispatch = require('../../src/dispatch/central-dispatch'); const VirtualMachine = require('../../src/virtual-machine'); const Sprite = require('../../src/sprites/sprite'); const RenderedTarget = require('../../src/sprites/rendered-target'); -// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead. -dispatch.workerClass = Worker; - class TestInternalExtension { constructor () { this.status = {}; @@ -51,14 +47,20 @@ class TestInternalExtension { } } -test('internal extension', t => { +test('internal extension', async t => { const vm = new VirtualMachine(); - - const extension = new TestInternalExtension(); - t.ok(extension.status.constructorCalled); - - t.notOk(extension.status.getInfoCalled); - vm.extensionManager._registerInternalExtension(extension); + vm.attachExtensionManager(new ExtensionManager(), []); + + const extensionAdapter = new ScratchBuiltinAdapter({ + extensionId: 'testInternalExtension', + name: 'Test Internal Extension' + }, () => TestInternalExtension, vm.runtime); + vm.extensionManager.loadExtension(extensionAdapter); + + t.notOk(extensionAdapter.instance); + await vm.extensionManager.enableExtension('testInternalExtension'); + const extension = extensionAdapter.instance; + t.ok(extension); t.ok(extension.status.getInfoCalled); const func = vm.runtime.getOpcodeFunction('testInternalExtension_go'); @@ -81,39 +83,46 @@ test('internal extension', t => { }; t.same(goBlockInfo, expectedBlockInfo); + const info = extensionAdapter.cachedCategoryInfo; + // There should be 2 menus - one is an array, one is the function to call. - t.equal(vm.runtime._blockInfo[0].menus.length, 2); + t.equal(info.menus.length, 2); // First menu has 3 items. - t.equal( - vm.runtime._blockInfo[0].menus[0].json.args0[0].options.length, 3); + t.equal(info.menus[0].json.args0[0].options.length, 3); // Second menu is a dynamic menu and therefore should be a function. - t.type( - vm.runtime._blockInfo[0].menus[1].json.args0[0].options, 'function'); + t.type(info.menus[1].json.args0[0].options, 'function'); t.end(); }); -test('load sync', t => { +test('load coreExample', async t => { const vm = new VirtualMachine(); - vm.extensionManager.loadExtensionIdSync('coreExample'); + vm.attachExtensionManager(new ExtensionManager(), [{ + extensionId: 'coreExample', + name: 'CoreEx' + }]); t.ok(vm.extensionManager.isExtensionLoaded('coreExample')); - t.equal(vm.runtime._blockInfo.length, 1); + await vm.extensionManager.enableExtension('coreExample'); + vm.extensionManager.isExtensionEnabled('coreExample'); + + const info = vm.extensionManager.getExtensionById('coreExample').cachedCategoryInfo; + t.ok(info); // blocks should be an array of two items: a button pseudo-block and a reporter block. - t.equal(vm.runtime._blockInfo[0].blocks.length, 3); - t.type(vm.runtime._blockInfo[0].blocks[0].info, 'object'); - t.type(vm.runtime._blockInfo[0].blocks[0].info.func, 'MAKE_A_VARIABLE'); - t.equal(vm.runtime._blockInfo[0].blocks[0].info.blockType, 'button'); - t.type(vm.runtime._blockInfo[0].blocks[1].info, 'object'); - t.equal(vm.runtime._blockInfo[0].blocks[1].info.opcode, 'exampleOpcode'); - t.equal(vm.runtime._blockInfo[0].blocks[1].info.blockType, 'reporter'); - t.type(vm.runtime._blockInfo[0].blocks[2].info, 'object'); - t.equal(vm.runtime._blockInfo[0].blocks[2].info.opcode, 'exampleWithInlineImage'); - t.equal(vm.runtime._blockInfo[0].blocks[2].info.blockType, 'command'); + t.equal(info.blocks.length, 3); + t.type(info.blocks[0].info, 'object'); + t.type(info.blocks[0].info.func, 'MAKE_A_VARIABLE'); + t.equal(info.blocks[0].info.blockType, 'button'); + t.type(info.blocks[1].info, 'object'); + t.equal(info.blocks[1].info.opcode, 'exampleOpcode'); + t.equal(info.blocks[1].info.blockType, 'reporter'); + t.type(info.blocks[2].info, 'object'); + t.equal(info.blocks[2].info.opcode, 'exampleWithInlineImage'); + t.equal(info.blocks[2].info.blockType, 'command'); // Test the opcode function - t.equal(vm.runtime._blockInfo[0].blocks[1].info.func(), 'no stage yet'); + t.equal(info.blocks[1].info.func(), 'no stage yet'); const sprite = new Sprite(null, vm.runtime); sprite.name = 'Stage'; @@ -121,7 +130,7 @@ test('load sync', t => { stage.isStage = true; vm.runtime.targets = [stage]; - t.equal(vm.runtime._blockInfo[0].blocks[1].info.func(), 'Stage'); + t.equal(info.blocks[1].info.func(), 'Stage'); t.end(); }); diff --git a/packages/vm/test/integration/load-extensions.js b/packages/vm/test/integration/load-extensions.js index dfddb8deb..fdd018834 100644 --- a/packages/vm/test/integration/load-extensions.js +++ b/packages/vm/test/integration/load-extensions.js @@ -2,8 +2,8 @@ const path = require('path'); const tap = require('tap'); const {test} = tap; const fs = require('fs'); +const attachExtensionManager = require('../fixtures/attach-extension-manager'); const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer; -const dispatch = require('../../src/dispatch/central-dispatch'); const VirtualMachine = require('../../src/index'); /** @@ -13,12 +13,15 @@ const VirtualMachine = require('../../src/index'); const stopVideoLoop = vm => { // TODO: provide a general way to tell extensions to shut down // Ideally we'd just dispose of the extension's Worker... - const serviceName = vm.extensionManager._loadedExtensions.get('videoSensing'); - dispatch.call(serviceName, '_stopLoop'); + if (vm.extensionManager.isExtensionEnabled('videoSensing')) { + const extensionAdapter = vm.extensionManager.getExtensionById('videoSensing'); + extensionAdapter.instance._stopLoop(); + } }; test('Load external extensions', async t => { const vm = new VirtualMachine(); + attachExtensionManager(vm); const testFiles = fs.readdirSync('./test/fixtures/load-extensions/confirm-load/'); // Test each example extension file @@ -30,7 +33,7 @@ test('Load external extensions', async t => { await t.test('Confirm expected extension is installed in example sb2 and sb3 projects', extTest => { vm.loadProject(project) .then(() => { - extTest.ok(vm.extensionManager.isExtensionLoaded(ext)); + extTest.ok(vm.extensionManager.isExtensionEnabled(ext)); extTest.end(); }); }); @@ -43,6 +46,8 @@ test('Load external extensions', async t => { test('Load video sensing extension and video properties', async t => { const vm = new VirtualMachine(); + attachExtensionManager(vm); + // An array of test projects and their expected video state values const testProjects = [ { @@ -66,7 +71,7 @@ test('Load video sensing extension and video properties', async t => { const stage = vm.runtime.getTargetForStage(); - t.ok(vm.extensionManager.isExtensionLoaded('videoSensing')); + t.ok(vm.extensionManager.isExtensionEnabled('videoSensing')); // Check that the stage target has the video state values we expect // based on the test project files, then check that the video io device diff --git a/packages/vm/test/integration/monitors_sb3.js b/packages/vm/test/integration/monitors_sb3.js index f688d79c6..addd1e4a2 100644 --- a/packages/vm/test/integration/monitors_sb3.js +++ b/packages/vm/test/integration/monitors_sb3.js @@ -1,5 +1,6 @@ const path = require('path'); const test = require('tap').test; +const attachExtensionManager = require('../fixtures/attach-extension-manager'); const makeTestStorage = require('../fixtures/make-test-storage'); const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer; const VirtualMachine = require('../../src/index'); @@ -11,6 +12,7 @@ const project = readFileToBuffer(projectUri); test('importing sb3 project with monitors', t => { const vm = new VirtualMachine(); vm.attachStorage(makeTestStorage()); + attachExtensionManager(vm); // Evaluate playground data and exit vm.on('playgroundData', e => { diff --git a/packages/vm/test/integration/pen.js b/packages/vm/test/integration/pen.js index 9e0b3d6ca..3bb487b1b 100644 --- a/packages/vm/test/integration/pen.js +++ b/packages/vm/test/integration/pen.js @@ -1,23 +1,20 @@ -const Worker = require('tiny-worker'); const path = require('path'); const test = require('tap').test; const Scratch3PenBlocks = require('../../src/extensions/scratch3_pen/index.js'); const VirtualMachine = require('../../src/index'); -const dispatch = require('../../src/dispatch/central-dispatch'); const makeTestStorage = require('../fixtures/make-test-storage'); +const attachExtensionManager = require('../fixtures/attach-extension-manager.js'); const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer; const uri = path.resolve(__dirname, '../fixtures/pen.sb2'); const project = readFileToBuffer(uri); -// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead. -dispatch.workerClass = Worker; - test('pen', t => { const vm = new VirtualMachine(); vm.attachStorage(makeTestStorage()); + attachExtensionManager(vm); // Evaluate playground data and exit vm.on('playgroundData', () => { diff --git a/packages/vm/test/integration/sb2-import-extension-monitors.js b/packages/vm/test/integration/sb2-import-extension-monitors.js index 75dad3444..c2d467306 100644 --- a/packages/vm/test/integration/sb2-import-extension-monitors.js +++ b/packages/vm/test/integration/sb2-import-extension-monitors.js @@ -1,6 +1,7 @@ const path = require('path'); const tap = require('tap'); const test = tap.test; +const attachExtensionManager = require('../fixtures/attach-extension-manager'); const makeTestStorage = require('../fixtures/make-test-storage'); const {readFileToBuffer, extractProjectJson} = require('../fixtures/readProjectFile'); const VirtualMachine = require('../../src/index'); @@ -28,6 +29,7 @@ const visibleTempoMonitorProject = readFileToBuffer(visibleTempoMonitorProjectUr test('loading sb2 project with invisible video monitor should not load monitor or extension', t => { const vm = new VirtualMachine(); vm.attachStorage(makeTestStorage()); + attachExtensionManager(vm); // Start VM, load project, and run vm.start(); @@ -35,7 +37,7 @@ test('loading sb2 project with invisible video monitor should not load monitor o vm.setCompatibilityMode(false); vm.setTurboMode(false); vm.loadProject(invisibleVideoMonitorProject).then(() => { - t.equal(vm.extensionManager.isExtensionLoaded('videoSensing'), false); + t.equal(vm.extensionManager.isExtensionEnabled('videoSensing'), false); t.equal(vm.runtime._monitorState.size, 0); vm.quit(); t.end(); @@ -45,6 +47,7 @@ test('loading sb2 project with invisible video monitor should not load monitor o test('loading sb2 project with visible video monitor should not load extension', t => { const vm = new VirtualMachine(); vm.attachStorage(makeTestStorage()); + attachExtensionManager(vm); // Start VM, load project, and run vm.start(); @@ -52,7 +55,7 @@ test('loading sb2 project with visible video monitor should not load extension', vm.setCompatibilityMode(false); vm.setTurboMode(false); vm.loadProject(visibleVideoMonitorProject).then(() => { - t.equal(vm.extensionManager.isExtensionLoaded('videoSensing'), false); + t.equal(vm.extensionManager.isExtensionEnabled('videoSensing'), false); t.equal(vm.runtime._monitorState.size, 0); vm.quit(); t.end(); @@ -77,6 +80,7 @@ test('sb2 project with video sensing blocks and monitor should load extension bu test('sb2 project with invisible music monitor should not load monitor or extension', t => { const vm = new VirtualMachine(); vm.attachStorage(makeTestStorage()); + attachExtensionManager(vm); // Start VM, load project, and run vm.start(); @@ -84,7 +88,7 @@ test('sb2 project with invisible music monitor should not load monitor or extens vm.setCompatibilityMode(false); vm.setTurboMode(false); vm.loadProject(invisibleTempoMonitorProject).then(() => { - t.equal(vm.extensionManager.isExtensionLoaded('music'), false); + t.equal(vm.extensionManager.isExtensionEnabled('music'), false); t.equal(vm.runtime._monitorState.size, 0); vm.quit(); t.end(); @@ -94,6 +98,7 @@ test('sb2 project with invisible music monitor should not load monitor or extens test('sb2 project with visible music monitor should load monitor and extension', t => { const vm = new VirtualMachine(); vm.attachStorage(makeTestStorage()); + attachExtensionManager(vm); // Start VM, load project, and run vm.start(); @@ -101,7 +106,7 @@ test('sb2 project with visible music monitor should load monitor and extension', vm.setCompatibilityMode(false); vm.setTurboMode(false); vm.loadProject(visibleTempoMonitorProject).then(() => { - t.equal(vm.extensionManager.isExtensionLoaded('music'), true); + t.equal(vm.extensionManager.isExtensionEnabled('music'), true); t.equal(vm.runtime._monitorState.size, 1); t.equal(vm.runtime._monitorState.has('music_getTempo'), true); t.equal(vm.runtime._monitorState.get('music_getTempo').visible, true); diff --git a/packages/vm/test/integration/sound.js b/packages/vm/test/integration/sound.js index 6d27cb511..6c5e318a3 100644 --- a/packages/vm/test/integration/sound.js +++ b/packages/vm/test/integration/sound.js @@ -1,20 +1,17 @@ -const Worker = require('tiny-worker'); const path = require('path'); const test = require('tap').test; +const attachExtensionManager = require('../fixtures/attach-extension-manager'); const makeTestStorage = require('../fixtures/make-test-storage'); const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer; const VirtualMachine = require('../../src/index'); -const dispatch = require('../../src/dispatch/central-dispatch'); const uri = path.resolve(__dirname, '../fixtures/sound.sb2'); const project = readFileToBuffer(uri); -// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead. -dispatch.workerClass = Worker; - test('sound', t => { const vm = new VirtualMachine(); vm.attachStorage(makeTestStorage()); + attachExtensionManager(vm); // Evaluate playground data and exit vm.on('playgroundData', e => { diff --git a/packages/vm/test/unit/virtual-machine.js b/packages/vm/test/unit/virtual-machine.js index b4b1c8ad4..747246624 100644 --- a/packages/vm/test/unit/virtual-machine.js +++ b/packages/vm/test/unit/virtual-machine.js @@ -982,8 +982,9 @@ test('shareBlocksToTarget loads extensions that have not yet been loaded', t => // Stub the extension manager const loadedIds = []; vm.extensionManager = { - isExtensionLoaded: id => id === 'loaded', - loadExtensionURL: id => new Promise(resolve => { + isExtensionLoaded: () => true, + isExtensionEnabled: id => id === 'loaded', + enableExtension: id => new Promise(resolve => { loadedIds.push(id); resolve(); }) From c5d4211023d50bb613025ac6312839ae3ec3ebce Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Thu, 16 Apr 2026 20:04:34 +0800 Subject: [PATCH 33/36] :test_tube: test(vm): fix wrong import of dispatch in saythink-and-wait Signed-off-by: Alex Cui --- packages/vm/test/integration/saythink-and-wait.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/vm/test/integration/saythink-and-wait.js b/packages/vm/test/integration/saythink-and-wait.js index e902d3a98..e3df4f80d 100644 --- a/packages/vm/test/integration/saythink-and-wait.js +++ b/packages/vm/test/integration/saythink-and-wait.js @@ -1,17 +1,12 @@ -const Worker = require('tiny-worker'); const path = require('path'); const test = require('tap').test; const makeTestStorage = require('../fixtures/make-test-storage'); const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer; const VirtualMachine = require('../../src/index'); -const dispatch = require('../../src/dispatch/central-dispatch'); const uri = path.resolve(__dirname, '../fixtures/saythink-and-wait.sb2'); const project = readFileToBuffer(uri); -// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead. -dispatch.workerClass = Worker; - test('say/think and wait', t => { const vm = new VirtualMachine(); vm.attachStorage(makeTestStorage()); From ad9023c77bb391e17c6ac50419621f3f407dce41 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Thu, 16 Apr 2026 20:05:13 +0800 Subject: [PATCH 34/36] :bug: fix(vm): should check enabled instead of loaded in shareBlocksToTarget Signed-off-by: Alex Cui --- packages/vm/src/virtual-machine.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vm/src/virtual-machine.js b/packages/vm/src/virtual-machine.js index 752f47670..eafc0d507 100644 --- a/packages/vm/src/virtual-machine.js +++ b/packages/vm/src/virtual-machine.js @@ -1444,7 +1444,7 @@ class VirtualMachine extends EventEmitter { const extensionIDs = new Set(copiedBlocks .map(b => sb3.getExtensionIdForOpcode(b.opcode)) .filter(id => !!id) // Remove ids that do not exist - .filter(id => !this.extensionManager.isExtensionLoaded(id)) // and remove loaded extensions + .filter(id => !this.extensionManager.isExtensionEnabled(id)) // and remove loaded extensions ); // Create an array promises for extensions to load From b13da5658bd6b1cda6eb5d72c082b199db4870e1 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Sat, 18 Apr 2026 18:33:11 +0800 Subject: [PATCH 35/36] :test_tube: test(gui): fix vm-manager-hoc test on vm.attachExtensionManager Signed-off-by: Alex Cui --- packages/gui/test/unit/util/vm-manager-hoc.test.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/gui/test/unit/util/vm-manager-hoc.test.jsx b/packages/gui/test/unit/util/vm-manager-hoc.test.jsx index b92065ced..1483388f6 100644 --- a/packages/gui/test/unit/util/vm-manager-hoc.test.jsx +++ b/packages/gui/test/unit/util/vm-manager-hoc.test.jsx @@ -33,6 +33,7 @@ describe('VMManagerHOC', () => { }); vm = new VM(); vm.attachAudioEngine = jest.fn(); + vm.attachExtensionManager = jest.fn(); vm.setCompatibilityMode = jest.fn(); vm.setLocale = jest.fn(); vm.start = jest.fn(); @@ -49,6 +50,7 @@ describe('VMManagerHOC', () => { /> ); expect(vm.attachAudioEngine.mock.calls.length).toBe(1); + expect(vm.attachExtensionManager.mock.calls.length).toBe(1); expect(vm.setCompatibilityMode.mock.calls.length).toBe(1); expect(vm.setLocale.mock.calls.length).toBe(1); expect(vm.initialized).toBe(true); @@ -68,6 +70,7 @@ describe('VMManagerHOC', () => { /> ); expect(vm.attachAudioEngine.mock.calls.length).toBe(1); + expect(vm.attachExtensionManager.mock.calls.length).toBe(1); expect(vm.setCompatibilityMode.mock.calls.length).toBe(1); expect(vm.setLocale.mock.calls.length).toBe(1); expect(vm.initialized).toBe(true); @@ -87,6 +90,7 @@ describe('VMManagerHOC', () => { /> ); expect(vm.attachAudioEngine.mock.calls.length).toBe(0); + expect(vm.attachExtensionManager.mock.calls.length).toBe(0); expect(vm.setCompatibilityMode.mock.calls.length).toBe(0); expect(vm.setLocale.mock.calls.length).toBe(0); expect(vm.initialized).toBe(true); From 961b78894caef8aacc6718bb47cdcb656e47dfbd Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Sat, 18 Apr 2026 19:08:57 +0800 Subject: [PATCH 36/36] :test_tube: test(ext): add unit test for extension-manager Signed-off-by: Alex Cui --- packages/extension/jest.config.js | 5 +- .../extension/test/fixtures/fake-adapter.ts | 55 +++++++++++++++++++ .../test/unit/extension-manager.test.ts | 53 ++++++++++++++++++ .../test/unit/scratch/dispatch.test.ts | 11 +--- 4 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 packages/extension/test/fixtures/fake-adapter.ts create mode 100644 packages/extension/test/unit/extension-manager.test.ts diff --git a/packages/extension/jest.config.js b/packages/extension/jest.config.js index 1acdea716..e70d1eca0 100644 --- a/packages/extension/jest.config.js +++ b/packages/extension/jest.config.js @@ -2,7 +2,10 @@ const {createDefaultPreset} = require('ts-jest'); const tsJestTransformCfg = createDefaultPreset({ tsconfig: { - types: ['./test/tiny-worker.d.ts'] + types: [ + 'node', + './test/tiny-worker.d.ts' + ] } }).transform; diff --git a/packages/extension/test/fixtures/fake-adapter.ts b/packages/extension/test/fixtures/fake-adapter.ts new file mode 100644 index 000000000..5fddf3dc4 --- /dev/null +++ b/packages/extension/test/fixtures/fake-adapter.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +import type {ExtensionManager} from '../../src/extension-manager'; +import type ExtensionManifest from '../../src/interfaces/extension-manifest'; +import type {IExtension} from '../../src/interfaces/i_extension'; + +export class FakeAdapter implements IExtension { + protected manager: ExtensionManager | null = null; + protected enabled: boolean = false; + + constructor( + protected manifest: ExtensionManifest + ) {} + + attachManager(manager: ExtensionManager): void { + this.manager = manager; + } + + getId(): string { + return this.manifest.extensionId; + } + + getManifest(): ExtensionManifest { + return this.manifest; + } + + isEnabled(): boolean { + return this.enabled; + } + + enable(): Promise { + this.enabled = true; + return Promise.resolve(); + } + + disable(): Promise { + this.enabled = false; + return Promise.resolve(); + } + + refreshInfo(): Promise { + return Promise.resolve(); + } + + getToolboxContents(isStage: boolean) { + return { + id: this.getId(), + xml: '' + }; + } +} diff --git a/packages/extension/test/unit/extension-manager.test.ts b/packages/extension/test/unit/extension-manager.test.ts new file mode 100644 index 000000000..5d92d5b1b --- /dev/null +++ b/packages/extension/test/unit/extension-manager.test.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +import {describe, expect, test, beforeAll} from '@jest/globals'; +import {FakeAdapter} from '../fixtures/fake-adapter'; +import type ExtensionManifest from '../../src/interfaces/extension-manifest'; +import {ExtensionManager} from '../../src/extension-manager'; + +const FAKE_MANIFEST: ExtensionManifest = { + extensionId: 'fake.extension', + name: 'Fake Extension', + iconURL: '', + insetIconURL: '', + description: '', + featured: true +}; + +describe('ExtensionManager', () => { + test('Load & Enable Extension', async () => { + const manager = new ExtensionManager(); + + const extension = new FakeAdapter(FAKE_MANIFEST); + const extensionId = extension.getId(); + expect(manager.isExtensionLoaded(extensionId)).toBeFalsy(); + + // Load extension. + manager.loadExtension(extension); + expect(manager.isExtensionLoaded(extensionId)).toBeTruthy(); + expect(manager.isExtensionEnabled(extensionId)).toBeFalsy(); + + // Enable extension. + await manager.enableExtension(extensionId); + expect(manager.isExtensionLoaded(extensionId)).toBeTruthy(); + expect(manager.isExtensionEnabled(extensionId)).toBeTruthy(); + + // Get manifest. + expect(manager.getManifest()).toStrictEqual([FAKE_MANIFEST]); + + // Get toolbox contents. + expect(manager.getToolboxContents(false)).toStrictEqual([{ + id: extensionId, + xml: '' + }]); + + // Disable extension. + await manager.disableExtension(extensionId); + expect(manager.isExtensionLoaded(extensionId)).toBeTruthy(); + expect(manager.isExtensionEnabled(extensionId)).toBeFalsy(); + }); +}); diff --git a/packages/extension/test/unit/scratch/dispatch.test.ts b/packages/extension/test/unit/scratch/dispatch.test.ts index 27eba3156..3a015253f 100644 --- a/packages/extension/test/unit/scratch/dispatch.test.ts +++ b/packages/extension/test/unit/scratch/dispatch.test.ts @@ -35,20 +35,13 @@ describe('Scratch: Dispatch', () => { const waitForWorker = new Promise(resolve => { dispatch.setService('test', { - onWorkerReady: () => { - dispatch.call('RemoteDispatchTest', 'returnFortyTwo').then((value: number) => { - console.log(value); - resolve(); - }).catch(err => { - console.log(err); - }); - } + onWorkerReady: resolve }); }); return waitForWorker .then(() => runServiceTest('RemoteDispatchTest')) - .then(() => dispatch['remoteCall'](worker, 'dispatch', 'terminate')); + .then(() => dispatch['remoteCall'](worker as any, 'dispatch', 'terminate')); }); test('local, sync', () => {