diff --git a/package.json b/package.json index 91e79372c..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", @@ -33,7 +33,8 @@ "storage": "pnpm --filter clipcc-storage", "paint": "pnpm --filter clipcc-paint", "parser": "pnpm --filter clipcc-parser", - "audio": "pnpm --filter clipcc-audio" + "audio": "pnpm --filter clipcc-audio", + "extension": "pnpm --filter clipcc-extension" }, "devDependencies": { "@changesets/changelog-git": "^0.2.1", diff --git a/packages/block/src/index.ts b/packages/block/src/index.ts index ffc4675fc..c38e13cb8 100644 --- a/packages/block/src/index.ts +++ b/packages/block/src/index.ts @@ -233,6 +233,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 type * as proceduresSerializer from './serialization/procedures'; export {reportValue} from './report_value'; @@ -252,3 +254,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'; 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..a612ad981 --- /dev/null +++ b/packages/extension/LICENSE @@ -0,0 +1,373 @@ +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/jest.config.js b/packages/extension/jest.config.js new file mode 100644 index 000000000..e70d1eca0 --- /dev/null +++ b/packages/extension/jest.config.js @@ -0,0 +1,24 @@ +const {createDefaultPreset} = require('ts-jest'); + +const tsJestTransformCfg = createDefaultPreset({ + tsconfig: { + types: [ + 'node', + './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 new file mode 100644 index 000000000..b08db6454 --- /dev/null +++ b/packages/extension/package.json @@ -0,0 +1,47 @@ +{ + "name": "clipcc-extension", + "version": "3.2.0", + "description": "Extension manager and interfaces for ClipCC", + "author": "Clip Team", + "license": "MPL-2.0", + "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", + "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", + "webpack-cli": "^6.0.1" + }, + "dependencies": { + "eslint-config-clipcc": "workspace:*", + "format-message": "^6.2.4", + "tslog": "^4.10.2" + } +} diff --git a/packages/extension/src/adapter/scratch/adapter.ts b/packages/extension/src/adapter/scratch/adapter.ts new file mode 100644 index 000000000..7e2f14647 --- /dev/null +++ b/packages/extension/src/adapter/scratch/adapter.ts @@ -0,0 +1,563 @@ +/** + * @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 ProcessedExtensionBlockMetadata, + type ProcessedExtensionMenuMetadata, + type ProcessedExtensionMetadata, + type ExtensionBlockMetadata, + type ExtensionMenuItems, + type ExtensionMenuMetadata, + type ExtensionMetadata +} from './types/extension-metadata'; +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'; +import {UpdateBlocksEvent, UpdatePrimitivesEvent} from '../../events'; +import defineDynamicBlock from './define-dynamic-block'; + +/** + * 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: any; + /** 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. + */ +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[]; + + 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. + * @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; +} + +/** + * 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. + */ +export abstract class ScratchBaseAdapter implements IExtension { + /** Extension manager. */ + private manager!: ExtensionManager; + + /** 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. + */ + constructor( + protected manifest: ExtensionManifest, + protected 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. + */ + getId(): string { + 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. + */ + isEnabled(): boolean { + return this.enabled; + } + + /** + * Enable the extension. + * Derived adapters should override this function to instantiate the extension. + */ + enable(): Promise { + this.enabled = true; + return this.refreshInfo(); + } + + /** + * Disable the extension. + */ + 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 categoryInfo = this.cachedCategoryInfo; + return { + id: this.getId(), + xml: categoryInfo ? this.buildToolboxXML(categoryInfo, isStage) : '' + }; + } + + /** + * 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; + + /** + * 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, + 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: [] as ConvertedBlockInfo[], + menus: [] as object[], + customFieldTypes: {} as Record, + menuInfo: {} as Record + } satisfies CategoryInfo; + + // 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: ProcessedExtensionMetadata, 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 payload: UpdateBlocksEvent = { + type: 'UPDATE_BLOCKS', + blocks: Object.create(null), + fields: Object.create(null) + }; + + 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}`; + payload.blocks[extendedOpcode] = defineDynamicBlock(categoryInfo, blockInfo, extendedOpcode); + } else if (blockInfo.json) { + // Static blocks. + payload.blocks[blockInfo.json.type] = defineStaticBlock(blockInfo.json); + } + // otherwise it's a non-block entry such as '---' + }); + } + + 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); + } + + 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 + + /** + * 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): ProcessedExtensionMetadata { + const info = Object.assign({}, extensionInfo) as unknown as ProcessedExtensionMetadata; + if (!/^[a-z0-9]+$/i.test(info.id)) { + throw new Error('Invalid extension id'); + } + info.name = extensionInfo.name || extensionInfo.id; + info.targetTypes = extensionInfo.targetTypes || []; + info.blocks = extensionInfo.blocks.reduce((results, blockInfo) => { + try { + let result: '---' | ProcessedExtensionBlockMetadata; + switch (blockInfo) { + case '---': // separator + result = '---'; + break; + default: // an ExtensionBlockMetadata object + result = this.prepareBlockInfo(blockInfo); + 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; + }, []); + info.menus = this.prepareMenuInfo(extensionInfo.menus || {}); + return info; + } + + /** + * 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 as unknown as ProcessedExtensionMenuMetadata).items = this.getExtensionMenuItems.bind(this, menuItemFunctionName); + } + } + return menus as unknown as Record; + } + + /** + * 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); + + const menuItems = this.callMethod(menuItemFunctionName, 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): ProcessedExtensionBlockMetadata { + 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.callMethod(funcName, args, util, realBlockInfo); + }; + + (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); + }; + break; + } + } + + return blockInfo as ProcessedExtensionBlockMetadata; + } +} 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..f9e879f66 --- /dev/null +++ b/packages/extension/src/adapter/scratch/builtin-adapter.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +import logger from '../../utils/logger'; +import {ScratchBaseAdapter} from './adapter'; +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; + + /** + * @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(): Promise { + const ExtensionClass = this.module(); + this.instance = new ExtensionClass(this.runtime); + return super.enable(); + } + + /** + * 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 && + 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/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/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/vm/src/extension-support/extension-worker.js b/packages/extension/src/adapter/scratch/extension-worker.ts similarity index 58% rename from packages/vm/src/extension-support/extension-worker.js rename to packages/extension/src/adapter/scratch/extension-worker.ts index 6c639f4de..7e702e66e 100644 --- a/packages/vm/src/extension-support/extension-worker.js +++ b/packages/extension/src/adapter/scratch/extension-worker.ts @@ -1,14 +1,33 @@ -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'); +/** + * @license + * Copyright 2017 Massachusetts Institute of Technology + * SPDX-License-Identifier: BSD-3-Clause + */ -class ExtensionWorker { - constructor () { - this.nextExtensionId = 0; +import dispatch from './dispatch/worker-dispatch'; + +import ArgumentType from './types/argument-type'; +import BlockType from './types/block-type'; +import TargetType from './types/target-type'; - this.initialRegistrations = []; +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; @@ -20,22 +39,21 @@ class ExtensionWorker { const initialRegistrations = this.initialRegistrations; this.initialRegistrations = null; - Promise.all(initialRegistrations).then(() => dispatch.call('extensions', 'onWorkerInit', id)); + Promise.all(initialRegistrations!) + .then(() => dispatch.call('extensions', 'onWorkerInit', id)); } catch (e) { dispatch.call('extensions', 'onWorkerInit', id, e); } }); }); - - this.extensions = []; } - register (extensionObject) { + 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)); + .then(() => dispatch.call('extensions', 'registerExtensionService', serviceName, this.workerId)); if (this.initialRegistrations) { this.initialRegistrations.push(promise); } 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..e39967a4e --- /dev/null +++ b/packages/extension/src/adapter/scratch/types/extension-metadata.ts @@ -0,0 +1,154 @@ +/** + * @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'; +import type {BlockFunction} from '../../../interfaces/common'; +import {Modify} from '../../../utils/type-traits'; + +/** + * 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; + /** 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; + 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. */ + 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. */ + 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; + /** True if creating a block factory / constructor. */ + isDynamic?: boolean; + /** Optional list of target types for which this block should appear. */ + filter?: TargetType[]; +} + +export type ProcessedExtensionBlockMetadata = Modify; + +/** + * 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 = { + acceptReporters?: boolean; + items: ExtensionDynamicMenu | ExtensionMenuItems; +} | ExtensionDynamicMenu | ExtensionMenuItems; + + +export type ProcessedExtensionMenuMetadata = { + acceptReporters?: boolean; + items: (() => [string, string][]); +} + +/** + * 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; +} + +/** + * 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; +} + +/** + * Metadata needed for custom field types. + */ +export interface ExtensionCustomFieldMetadata { + output: string; + outputShape: number; + implementation: object; +} 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/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/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/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; 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..db9a82454 --- /dev/null +++ b/packages/extension/src/adapter/scratch/worker-adapter.ts @@ -0,0 +1,145 @@ +/** + * @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( + /* webpackChunkName: "extension-worker" */ './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/events.ts b/packages/extension/src/events.ts new file mode 100644 index 000000000..b04458660 --- /dev/null +++ b/packages/extension/src/events.ts @@ -0,0 +1,28 @@ +/** + * @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'; + /** Map of block definitions. */ + blocks: Record; + /** Map of custom field implementations. */ + fields: Record; +} diff --git a/packages/extension/src/extension-manager.ts b/packages/extension/src/extension-manager.ts new file mode 100644 index 000000000..98ff66350 --- /dev/null +++ b/packages/extension/src/extension-manager.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +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. + */ +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.`); + } + + extension.attachManager(this); + 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): Promise { + 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.`); + } + + return extension.enable(); + } + + /** + * 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): Promise { + 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.`); + } + + return extension.disable(); + } + + /** + * 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. + */ + private getExtensionById(extensionId: string): IExtension | null { + return this.loadedExtensions.get(extensionId) ?? null; + } + + /** + * Get toolbox contents for Blockly. + * @param isStage True if current target is stage. + * @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; + } + + /** + * 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; + } + + /** + * 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. + * @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); + } + + /** + * Emit a event. + * @param event Payload of event. + */ + emitEvent(event: T): void { + this.eventEmitter.emit(event.type, event); + } +} diff --git a/packages/extension/src/index.ts b/packages/extension/src/index.ts new file mode 100644 index 000000000..0721b4459 --- /dev/null +++ b/packages/extension/src/index.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +export * from './events'; +export * from './interfaces/i_extension'; + +export {ExtensionManager} from './extension-manager'; + +export {ScratchBuiltinAdapter} from './adapter/scratch/builtin-adapter'; +export {ScratchWorkerAdapter} from './adapter/scratch/worker-adapter'; 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/extension-manifest.ts b/packages/extension/src/interfaces/extension-manifest.ts new file mode 100644 index 000000000..4cc9196a8 --- /dev/null +++ b/packages/extension/src/interfaces/extension-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/interfaces/i_extension.ts b/packages/extension/src/interfaces/i_extension.ts new file mode 100644 index 000000000..40b8bb637 --- /dev/null +++ b/packages/extension/src/interfaces/i_extension.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: MPL-2.0 + */ + +import type {ExtensionManager} from '../extension-manager'; +import type ExtensionManifest from './extension-manifest'; + +/** + * 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. + */ + 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. + */ + isEnabled(): boolean; + + /** + * Enable the extension. + */ + enable(): Promise; + + /** + * Disable the extension. + */ + disable(): Promise; + + /** + * Refresh and cache the category info. + */ + refreshInfo(): Promise; + + /** + * 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; +} 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/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] +}; 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/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/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/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 new file mode 100644 index 000000000..3a015253f --- /dev/null +++ b/packages/extension/test/unit/scratch/dispatch.test.ts @@ -0,0 +1,56 @@ +/** + * @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: resolve + }); + }); + + return waitForWorker + .then(() => runServiceTest('RemoteDispatchTest')) + .then(() => dispatch['remoteCall'](worker as any, '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.json b/packages/extension/tsconfig.json new file mode 100644 index 000000000..4a1e77dd9 --- /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": "es2022", /* Specify what module code is generated. */ + "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": ["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. */ + // "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/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 new file mode 100644 index 000000000..459ce4f2f --- /dev/null +++ b/packages/extension/webpack.config.js @@ -0,0 +1,92 @@ +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', + devtool: 'cheap-module-source-map', + entry: { + 'clipcc-extension': './src/index.ts' + }, + output: { + filename: '[name].js' + }, + resolve: { + extensions: ['.ts', '.js'] + }, + module: { + rules: [{ + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/ + }] + }, + optimization: { + minimizer: [ + new TerserPlugin({ + include: /\.min\.js$/ + }) + ] + }, + plugins: [ + new NodePolyfillPlugin({ + includeAliases: ['events'] + }) + ] +}; + +module.exports = [ + // Web-compatible + defaultsDeep({}, baseConfig, { + target: 'web', + output: { + library: { + name: 'Extension', + type: 'umd' + }, + path: path.resolve(__dirname, 'dist', 'web') + } + }), + // Node-compatible + defaultsDeep({}, baseConfig, { + target: 'node', + output: { + library: { + type: '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/packages/gui/package.json b/packages/gui/package.json index 17c6764e7..2ad81ded4 100644 --- a/packages/gui/package.json +++ b/packages/gui/package.json @@ -38,6 +38,7 @@ "classnames": "2.5.1", "clipcc-audio": "workspace:~", "clipcc-block": "workspace:~", + "clipcc-extension": "workspace:~", "clipcc-l10n": "workspace:~", "clipcc-paint": "workspace:~", "clipcc-render": "workspace:~", diff --git a/packages/gui/src/containers/blocks.jsx b/packages/gui/src/containers/blocks.jsx index f82f9a682..e05acc587 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, {setRecordSoundCallback} from '../lib/blocks'; import VM from 'clipcc-vm'; +import {ExtensionManager} from 'clipcc-extension'; import log from '../lib/log'; import Prompt from './prompt.jsx'; @@ -66,13 +67,12 @@ class Blocks extends React.Component { 'handlePromptCallback', 'handlePromptClose', 'handleCustomProceduresClose', + 'handleExtensionUpdateBlocks', 'onScriptGlowOn', 'onScriptGlowOff', 'onBlockGlowOn', 'onBlockGlowOff', 'handleMonitorsUpdate', - 'handleExtensionAdded', - 'handleBlocksInfoUpdate', 'onTargetsUpdate', 'onVisualReport', 'onWorkspaceUpdate', @@ -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 @@ -140,6 +135,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 @@ -213,6 +211,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(); } @@ -301,8 +302,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); } @@ -321,8 +320,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); } @@ -394,7 +391,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, @@ -467,51 +464,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) { @@ -590,6 +542,24 @@ class Blocks extends React.Component { if (!target) return; this.props.vm.setEditingTarget(target.id); } + + /** + * Event handler for updating block definitions. + * @param {import('clipcc-extension').UpdateBlocksEvent} event Event payload. + */ + handleExtensionUpdateBlocks (event) { + this.ScratchBlocks.common.defineBlocks(event.blocks); + + // @todo support for custom field type + + // Update the toolbox. + const toolbox = this.getToolbox(); + if (toolbox) { + this.props.updateToolboxState(toolbox); + } + this.requestToolboxUpdate(); + } + render () { /* eslint-disable no-unused-vars */ const { @@ -614,6 +584,7 @@ class Blocks extends React.Component { toolbox, updateMetrics: updateMetricsProp, workspaceMetrics, + extensionManager, ...props } = this.props; /* eslint-enable no-unused-vars */ @@ -640,7 +611,7 @@ class Blocks extends React.Component { ) : null} {extensionLibraryVisible ? ( @@ -694,7 +665,8 @@ Blocks.propTypes = { workspaceMetrics: PropTypes.shape({ // eslint-disable-next-line react/forbid-prop-types targets: PropTypes.objectOf(PropTypes.object) - }) + }), + extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired }; Blocks.defaultOptions = { @@ -744,7 +716,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 20bc7c47f..a8d644d64 100644 --- a/packages/gui/src/containers/extension-library.jsx +++ b/packages/gui/src/containers/extension-library.jsx @@ -1,11 +1,9 @@ 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'; - import LibraryComponent from '../components/library/library.jsx'; import extensionIcon from '../components/action-menu/icon--sprite.svg'; @@ -37,17 +35,17 @@ class ExtensionLibrary extends React.PureComponent { url = prompt(this.props.intl.formatMessage(messages.extensionUrl)); } if (id && !item.disabled) { - if (this.props.vm.extensionManager.isExtensionLoaded(url)) { + if (this.props.extensionManager.isExtensionEnabled(url)) { this.props.onCategorySelected(id); } else { - this.props.vm.extensionManager.loadExtensionURL(url).then(() => { + this.props.extensionManager.enableExtension(url).then(() => { this.props.onCategorySelected(id); }); } } } render () { - const extensionLibraryThumbnailData = extensionLibraryContent.map(extension => ({ + const extensionLibraryThumbnailData = this.props.extensionManager.getManifest().map(extension => ({ rawURL: extension.iconURL || extensionIcon, ...extension })); @@ -70,7 +68,7 @@ ExtensionLibrary.propTypes = { onCategorySelected: PropTypes.func, onRequestClose: PropTypes.func, visible: PropTypes.bool, - vm: PropTypes.instanceOf(VM).isRequired + 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 376904f6e..7aa43b11f 100644 --- a/packages/gui/src/lib/vm-manager-hoc.jsx +++ b/packages/gui/src/lib/vm-manager-hoc.jsx @@ -5,6 +5,9 @@ import {connect} from 'react-redux'; 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 { @@ -31,6 +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, extensionLibraryContent); this.props.vm.setCompatibilityMode(true); this.props.vm.initialized = true; this.props.vm.setLocale(this.props.locale, this.props.messages); @@ -138,6 +142,7 @@ const vmManagerHOC = function (WrappedComponent) { onLoadedProject: onLoadedProjectProp, onSetProjectUnchanged, projectData, + extensionManager, framerate, infiniteCloning, edgelessStage, @@ -185,7 +190,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 => { @@ -208,7 +214,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.ts b/packages/gui/src/reducers/gui.ts index 93f184d3a..f8efc34e8 100644 --- a/packages/gui/src/reducers/gui.ts +++ b/packages/gui/src/reducers/gui.ts @@ -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 }; export type GuiState = typeof guiInitialState; @@ -146,7 +148,8 @@ const guiReducer = combineReducers({ toolbox: toolboxReducer, vm: vmReducer, vmStatus: vmStatusReducer, - workspaceMetrics: workspaceMetricsReducer + workspaceMetrics: workspaceMetricsReducer, + extensionManager: extensionManagerReducer }); export { 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); diff --git a/packages/gui/webpack.config.js b/packages/gui/webpack.config.js index 87fc87a98..5ab7ca763 100644 --- a/packages/gui/webpack.config.js +++ b/packages/gui/webpack.config.js @@ -108,7 +108,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(), diff --git a/packages/vm/package.json b/packages/vm/package.json index f916e73a9..50eaf608d 100644 --- a/packages/vm/package.json +++ b/packages/vm/package.json @@ -67,6 +67,7 @@ "callsite": "1.0.0", "clipcc-audio": "workspace:~", "clipcc-block": "workspace:~", + "clipcc-extension": "workspace:~", "clipcc-l10n": "workspace:~", "clipcc-render": "workspace:~", "clipcc-storage": "workspace:~", 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/virtual-machine.js b/packages/vm/src/virtual-machine.js index 3e71ae2a1..eafc0d507 100644 --- a/packages/vm/src/virtual-machine.js +++ b/packages/vm/src/virtual-machine.js @@ -7,10 +7,9 @@ if (typeof TextEncoder === 'undefined') { } const EventEmitter = require('events'); const JSZip = require('jszip'); +const {ScratchBuiltinAdapter} = 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 +27,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 +34,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 */ /** @@ -75,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. @@ -151,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); @@ -187,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); } /** @@ -652,9 +629,8 @@ class VirtualMachine extends EventEmitter { const extensionPromises = []; extensions.extensionIDs.forEach(extensionID => { - if (!this.extensionManager.isExtensionLoaded(extensionID)) { - const extensionURL = extensions.extensionURLs.get(extensionID) || extensionID; - extensionPromises.push(this.extensionManager.loadExtensionURL(extensionURL)); + if (!this.extensionManager.isExtensionEnabled(extensionID)) { + extensionPromises.push(this.extensionManager.enableExtension(extensionID)); } }); @@ -1262,6 +1238,78 @@ class VirtualMachine extends EventEmitter { this.runtime.attachStorage(storage); } + /** + * 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, extensionLibraryContent) { + 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 content of extensionLibraryContent) { + const extensionId = content.extensionId; + + if (this.extensionManager.isExtensionLoaded(extensionId)) { + log.error(`Duplicated builtin extension: ${extensionId}`); + continue; + } + + if (!Object.hasOwnProperty.call(builtinExtensions, extensionId)) { + log.error(`Unexpected builtin extension: ${extensionId}`); + continue; + } + + this.extensionManager.loadExtension(new ScratchBuiltinAdapter( + content, 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 @@ -1273,7 +1321,7 @@ class VirtualMachine extends EventEmitter { if (locale !== formatMessage.setup().locale) { formatMessage.setup({locale: locale, translations: {[locale]: messages}}); } - return this.extensionManager.refreshBlocks(); + return Promise.resolve(); } /** @@ -1396,12 +1444,12 @@ 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 const extensionPromises = Array.from(extensionIDs, - id => this.extensionManager.loadExtensionURL(id) + id => this.extensionManager.enableExtension(id) ); return Promise.all(extensionPromises).then(() => { 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/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/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/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()); 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/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(); -}); 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(); }) diff --git a/packages/vm/webpack.config.js b/packages/vm/webpack.config.js index c5c9e2f89..ac38fee65 100644 --- a/packages/vm/webpack.config.js +++ b/packages/vm/webpack.config.js @@ -52,7 +52,8 @@ const base = { plugins: [ new RuleInheritancePlugin({ packages: [ - path.resolve(__dirname, '../svg-renderer') + path.resolve(__dirname, '../svg-renderer'), + path.resolve(__dirname, '../extension') ] }), new NodePolyfillPlugin(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec6fb2ede..71eda06c6 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 @@ -181,6 +181,67 @@ 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: + '@jest/globals': + specifier: ^30.3.0 + version: 30.3.0 + '@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) + jest: + specifier: 'catalog:' + version: 30.3.0(@types/node@25.6.0) + 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) + 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) + 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': @@ -216,6 +277,9 @@ importers: clipcc-block: specifier: workspace:~ version: link:../block + clipcc-extension: + specifier: workspace:~ + version: link:../extension clipcc-l10n: specifier: workspace:~ version: link:../l10n @@ -480,7 +544,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 @@ -750,7 +814,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 @@ -871,7 +935,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: @@ -953,7 +1017,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) @@ -1032,13 +1096,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 @@ -1050,10 +1114,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)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1150,7 +1214,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) @@ -1257,6 +1321,9 @@ importers: clipcc-block: specifier: workspace:~ version: link:../block + clipcc-extension: + specifier: workspace:~ + version: link:../extension clipcc-l10n: specifier: workspace:~ version: link:../l10n @@ -2299,14 +2366,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} @@ -2323,10 +2402,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} @@ -3043,6 +3130,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==} @@ -3121,6 +3211,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==} @@ -5207,6 +5300,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'} @@ -5231,6 +5328,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} @@ -9087,6 +9194,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'} @@ -9192,6 +9303,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==} @@ -10566,7 +10680,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 @@ -10582,7 +10696,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 @@ -10680,11 +10794,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 @@ -10728,14 +10842,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 @@ -10895,6 +11009,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) @@ -10910,14 +11029,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 @@ -10938,11 +11073,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': @@ -10977,12 +11119,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: @@ -11031,6 +11173,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 @@ -11044,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 @@ -11058,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 @@ -11093,7 +11267,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) @@ -11104,7 +11278,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 @@ -11113,7 +11287,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': @@ -11135,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 @@ -11153,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': @@ -11164,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 @@ -11249,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 @@ -11259,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 @@ -11690,11 +11864,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) @@ -11706,20 +11890,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) @@ -11730,6 +11939,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 @@ -11751,6 +11970,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 @@ -11759,18 +11999,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)) @@ -11779,6 +12035,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) @@ -11786,6 +12050,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 @@ -11818,6 +12089,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)) @@ -11862,6 +12157,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) @@ -11872,16 +12211,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) @@ -11920,6 +12277,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) @@ -11940,10 +12335,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': {} @@ -11994,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: @@ -12007,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': {} @@ -12021,6 +12440,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': @@ -12045,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': {} @@ -12071,13 +12492,13 @@ 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 '@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 @@ -12102,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': {} @@ -12114,6 +12535,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/prop-types@15.5.9': {} @@ -12148,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: @@ -12177,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': {} @@ -12197,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)': @@ -13758,9 +14183,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 @@ -14571,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 @@ -14660,6 +15085,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 @@ -14674,6 +15106,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 @@ -16123,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 @@ -16162,7 +16631,7 @@ snapshots: - supports-color - ts-node - jest-cli@30.3.0(@types/node@25.5.2): + jest-cli@30.3.0(@types/node@25.6.0): dependencies: '@jest/core': 30.3.0 '@jest/test-result': 30.3.0 @@ -16170,7 +16639,7 @@ snapshots: 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-config: 30.3.0(@types/node@25.6.0) jest-util: 30.3.0 jest-validate: 30.3.0 yargs: 17.7.2 @@ -16212,7 +16681,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.3.0(@types/node@25.5.2): + jest-config@30.3.0(@types/node@25.6.0): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -16238,7 +16707,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -16284,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 @@ -16296,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 @@ -16370,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): @@ -16404,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 @@ -16433,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 @@ -16480,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 @@ -16489,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 @@ -16517,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 @@ -16526,13 +16995,13 @@ 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 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 @@ -16551,12 +17020,12 @@ snapshots: - supports-color - ts-node - jest@30.3.0(@types/node@25.5.2): + 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.5.2) + jest-cli: 30.3.0(@types/node@25.6.0) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -19054,6 +19523,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: {} @@ -19242,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: @@ -19266,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 @@ -19323,6 +19829,8 @@ snapshots: tslib@2.8.1: {} + tslog@4.10.2: {} + tsutils@3.21.0(typescript@5.9.3): dependencies: tslib: 1.14.1 @@ -19431,6 +19939,8 @@ snapshots: undici-types@7.18.2: {} + undici-types@7.19.2: {} + undici-types@7.22.0: {} undici@7.22.0: {}