diff --git a/.eslintrc.json b/.eslintrc.json index 727b058..d9a14f0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -14,7 +14,7 @@ }, "rules": { "semi": "error", - "indent": ["error", 4], + "indent": ["error", 4, {"SwitchCase": 1}], "dot-notation": "error", "block-scoped-var": "error", "capitalized-comments": "warn", @@ -30,7 +30,6 @@ "func-call-spacing": "error", "dot-location": ["error", "property"], "no-whitespace-before-property": "error", - "space-before-function-paren": "error", "space-unary-ops": ["error", { "words": true, "nonwords": false diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..5855764 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "semi": true, + "tabWidth": 4, + "singleQuote": true, + "trailingComma": "none", + "arrowParens": "always", + "bracketSameLine": true, + "bracketSpacing": true, + "printWidth": 100 +} diff --git a/README-ja_JP.md b/README-ja_JP.md new file mode 100644 index 0000000..6b331de --- /dev/null +++ b/README-ja_JP.md @@ -0,0 +1,55 @@ +
+ +logo + +# Chibi (チビ) + +#### とあるユニバーサル Scratch 拡張機能ローダーです。 + +
+ +--- + +Chibi は Tampermonkey/GreasyMonkey に対応するユーザースクリプトであり、「どのエディターでも拡張機能が使える」という仕様が搭載されています(理論的に)。 + +# ✨ 仕様 + +- [X] 標準 Scratch 拡張機能をロードできる +- [X] Unsandboxed 拡張機能をロードできる +- [X] どこでも Turbowarp 専用機能を使える (一部) +- [ ] 拡張機能をインストールしなくでもサードパーティー拡張機能を搭載したプロジェクトを起動できる +- [X] 直接的にエディターから拡張機能をロードできる + +# 🌈 プラットフォーム + +- [X] Scratch +- [X] CodingClip +- [X] Cocrea +- [X] Aerfaying +- [X] Co-Create World +- [X] XMW +- [X] CodeLab +- [X] 40code +- [X] TurboWarp + +# 🔥 使い方 + +1. Tampermonkey / Greasymonkey をインストールします。 +2. [リリースページ](https://github.com/SimonShiki/chibi/releases)を開き、バージョンを選択してインストールします。 +3. 'ブロック定義'のカテゴリーで、'Open Frontend' が現れます。クリックすると拡張機能をサイドロードできます。(都合により、およそ5秒のラグがあります) + +> うん…エディターによって、ボタンが正常に現れない場合もあります。その時は DevTools で拡張機能をロードできます。 + +1. 'Ctrl + Shift + J' (Windows) / 'Cmd + Opt + J' (MacOS) で DevTools を開きます。 +2. コンソールで ``chibi.openFrontend()`` や ``chibi.loader.load([extensionURL], [load mode, like 'unsandboxed'])'``を実行します。 +3. これで完成。 + +# 🥰 拡張機能の投稿 + +Chibi のダッシュボードには拡張機能のギャラリーがあります。どうぞ自由に好きな拡張機能を使ってください。 + +もし良かったら [moth](https://github.com/SimonShiki/moth) で自分の一番好きな拡張機能を投稿してください。 + +# ⚓ ライセンス + +AGPL-3.0 です。詳しくは[こちら](./LICENSE)。 diff --git a/README.md b/README.md index 534fd3b..2085f7b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[简体中文](./README-zh_CN.md) +[简体中文](./README-zh_CN.md) [日本語](./README-ja_JP.md)
diff --git a/package.json b/package.json index bb7cc13..d7e6dcf 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "build": "NODE_ENV=production webpack --color --bail", "lint": "eslint ./src/ --ext .js,.ts,tsx,jsx", + "format": "prettier -w --config .prettierrc.json ./src", "typecheck": "tsc --watch --noEmit" }, "devDependencies": { @@ -23,6 +24,7 @@ "eslint": "^8.49.0", "html-webpack-plugin": "^5.5.3", "mini-svg-data-uri": "^1.4.4", + "prettier": "^3.0.3", "webpack": "^5.88.2", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1", diff --git a/src/frontend/index.ts b/src/frontend/index.ts index 7edf158..75349ca 100644 --- a/src/frontend/index.ts +++ b/src/frontend/index.ts @@ -10,7 +10,7 @@ interface MothDispatchedLoad { info: { url: string; sandboxed: boolean; - } + }; } interface MothDispatchedAllocate { @@ -19,7 +19,11 @@ interface MothDispatchedAllocate { type MothDispatched = MothDispatchedAllocate | MothDispatchedLoad; -function getExtensionInfo () { +/** + * Get all extensions. + * @returns Extensions. + */ +function getExtensionInfo() { const processedExtInfo: MothExtensionInfo[] = []; for (const [extId, ext] of window.chibi.loader.loadedScratchExtension.entries()) { processedExtInfo.push({ @@ -29,39 +33,65 @@ function getExtensionInfo () { } return processedExtInfo; } - -async function messageHandler (event: MessageEvent) { +/** + * Handle messages from the frontend (popup window). + * @param event Event from the frontend. + */ +async function messageHandler(event: MessageEvent) { if (event.origin !== 'https://chibi.codingclip.cc') return; if (!('type' in event.data)) return; switch ((event.data as MothDispatched).type) { - case 'allocate': - console.log('handshake with frontend'); - dashboardWindow?.postMessage({ - type: 'handshake', - clientInfo: { - version: Number(window.chibi.version), - url: window.location.host - } - }, '*'); - dashboardWindow?.postMessage({ - type: 'extension', - extensions: getExtensionInfo() - }, '*'); - break; - case 'load': - await window.chibi.loader.load(event.data.info.url, event.data.info.sandboxed ? 'sandboxed' : 'unsandboxed'); - dashboardWindow?.postMessage({ - type: 'extension', - extensions: getExtensionInfo() - }, '*'); - break; + // Handshake: send current extension info in order to prepare frontend. + case 'allocate': + console.log('handshake with frontend'); + dashboardWindow?.postMessage( + { + type: 'handshake', + clientInfo: { + version: Number(window.chibi.version), + url: window.location.host + } + }, + '*' + ); + dashboardWindow?.postMessage( + { + type: 'extension', + extensions: getExtensionInfo() + }, + '*' + ); + break; + case 'load': + // Load an extension. + await window.chibi.loader.load( + event.data.info.url, + event.data.info.sandboxed ? 'sandboxed' : 'unsandboxed' + ); + dashboardWindow?.postMessage( + { + type: 'extension', + extensions: getExtensionInfo() + }, + '*' + ); + break; } } +// Here we add a listener to process the message window.addEventListener('message', messageHandler); -function openFrontend () { - dashboardWindow = window.open('https://chibi.codingclip.cc/#manage'); +/** + * Open the popup (?) window. + * @param open window.open function (compatible with ccw). + */ +function openFrontend(open: typeof window.open) { + dashboardWindow = open( + 'https://chibi.codingclip.cc/#manage', + 'Chibi', + 'popup=yes,status=no,location=no,toolbar=no,menubar=no' + ); } export default openFrontend; diff --git a/src/global.d.ts b/src/global.d.ts index 1de4eab..bfea3c7 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -8,10 +8,13 @@ declare interface Window { vm?: VM; blockly?: ScratchBlocks | null; loader?: ChibiLoader; - registeredExtension: Record; - openFrontend (): void; - } + registeredExtension: Record< + string, + { + url: string; + env: string; + } + >; + openFrontend(): void; + }; } diff --git a/src/index.ts b/src/index.ts index caaa41c..ac7b295 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,14 @@ import { trap, inject } from './injector/inject'; import { log } from './util/log'; - +const open = window.open; // @ts-expect-error defined in webpack define plugin log(`Chibi ${__CHIBI_VERSION__}`); -await trap(); +// Try injecting chibi into the page. +await trap(open); if (typeof window.chibi.vm !== 'undefined') { + // Alright we got the virtual machine, start the injection. inject(window.chibi.vm); } else { + // This is not a Scratch page, stop injecting. log(`Cannot find vm in this page, stop injecting.`); } diff --git a/src/injector/inject.ts b/src/injector/inject.ts index 0bc8555..2302879 100644 --- a/src/injector/inject.ts +++ b/src/injector/inject.ts @@ -1,57 +1,81 @@ /// -import {log, warn, error} from '../util/log'; +import { log, warn, error } from '../util/log'; import { ChibiLoader } from '../loader/loader'; import openFrontend from '../frontend'; import type VM from 'scratch-vm'; import type Blockly from 'scratch-blocks'; +import * as l10n from '../l10n/l10n.json'; +import formatMessage from 'format-message'; interface ChibiCompatibleWorkspace extends Blockly.Workspace { - registerButtonCallback (key: string, callback: Function): void; + registerButtonCallback(key: string, callback: Function): void; } interface ChibiCompatibleVM extends VM { ccExtensionManager?: { - info: Record; - getExtensionLoadOrder (extensions: string[]): unknown; + info: Record< + string, + { + api: number; + } + >; + getExtensionLoadOrder(extensions: string[]): unknown; }; setLocale?: (locale: string, ...args: unknown[]) => unknown; + getLocale?: () => string; } const MAX_LISTENING_MS = 30 * 1000; - -function getBlocklyInstance () { - const elem = document.querySelector('[class^="gui_blocks-wrapper"]'); - if (!elem) return null; - const internalKey = Object.keys(elem).find( - (key) => key.startsWith('__reactInternalInstance$') || - key.startsWith('__reactFiber$') - ); - if (!internalKey) return; - // @ts-expect-error - const internal = elem[internalKey]; - let childable = internal; - try { - while (((childable = childable.child), !childable || !childable.stateNode || !childable.stateNode.ScratchBlocks)) {} - } catch (e: unknown) { - return; +/** + * Get Blockly instance. + * @param vm Virtual machine instance. For some reasons we cannot use VM here. + * @returns Blockly instance. + */ +function getBlocklyInstance(vm: any): any | undefined { + // hijack Function.prototype.apply to get React element instance. + function hijack(fn: (...args: unknown[]) => void): any { + const _orig = Function.prototype.apply; + Function.prototype.apply = function (thisArg: any) { + return thisArg; + }; + const result = fn(); + Function.prototype.apply = _orig; + return result; } - return childable?.stateNode.ScratchBlocks; + const events = vm._events?.EXTENSION_ADDED; + if (events) { + if (events instanceof Function) { + // it is a function, just hijack it. + const result = hijack(events); + if (result && result.ScratchBlocks) return result.ScratchBlocks; + } else { + // it is an array, hijack every listeners. + for (const value of events) { + const result = hijack(value); + if (result && result.ScratchBlocks) return result.ScratchBlocks; + } + } + } + return undefined; // Method failed. } -export function trap () { +/** + * Trap to get Virtual Machine instance. + * @param open window.open function (compatible with ccw). + * @return Callback promise. After that you could use window.chibi.vm to get the virtual machine. + */ +export function trap(open: typeof window.open): Promise { window.chibi = { // @ts-expect-error defined in webpack define plugin version: __CHIBI_VERSION__, registeredExtension: {}, - openFrontend: openFrontend + openFrontend: openFrontend.bind(null, open) }; log('Listening bind function...'); const oldBind = Function.prototype.bind; - return new Promise(resolve => { + return new Promise((resolve) => { const timeoutId = setTimeout(() => { log('Cannot find vm instance, stop listening.'); Function.prototype.bind = oldBind; @@ -63,8 +87,8 @@ export function trap () { return oldBind.apply(this, args); } else if ( args[0] && - Object.prototype.hasOwnProperty.call(args[0], "editingTarget") && - Object.prototype.hasOwnProperty.call(args[0], "runtime") + Object.prototype.hasOwnProperty.call(args[0], 'editingTarget') && + Object.prototype.hasOwnProperty.call(args[0], 'runtime') ) { log('VM detected!'); window.chibi.vm = args[0]; @@ -78,15 +102,51 @@ export function trap () { }); } -export function inject (vm: ChibiCompatibleVM) { - const loader = window.chibi.loader = new ChibiLoader(vm); +/** + * Inject into the original virtual machine. + * @param vm {ChibiCompatibleVM} Original virtual machine instance. + */ +export function inject(vm: ChibiCompatibleVM) { + const loader = (window.chibi.loader = new ChibiLoader(vm)); const originalLoadFunc = vm.extensionManager.loadExtensionURL; - vm.extensionManager.loadExtensionURL = async function (extensionURL: string, ...args: unknown[]) { + const getLocale = vm.getLocale; + let format = formatMessage.namespace(); + format.setup({ + locale: getLocale ? getLocale.call(vm) : 'en', + missingTranslation: 'ignore', + generateId: (defaultMessage: string) => `${defaultMessage}`, + translations: l10n + }); + vm.extensionManager.loadExtensionURL = async function ( + extensionURL: string, + ...args: unknown[] + ) { if (extensionURL in window.chibi.registeredExtension) { const { url, env } = window.chibi.registeredExtension[extensionURL]; try { - if (confirm(`🤨 Project is trying to sideloading ${extensionURL} from ${url}${env ? ` in ${env} mode` : ''}. Do you want to load?`)) { - await loader.load(url, (env ? env : (confirm('🤨 Do you want to load it in the sandbox?') ? 'sandboxed' : 'unsandboxed')) as 'unsandboxed' | 'sandboxed'); + const res = env + ? confirm( + format('chibi.tryLoadInEnv', { + extensionURL, + url, + env + }) + ) + : confirm( + format('chibi.tryLoadInEnv', { + extensionURL, + url + }) + ); + if (res) { + await loader.load( + url, + (env + ? env + : confirm(format('chibi.loadInSandbox')) + ? 'sandboxed' + : 'unsandboxed') as 'unsandboxed' | 'sandboxed' + ); const extensionId = loader.getIdByUrl(url); // @ts-expect-error internal hack vm.extensionManager._loadedExtensions.set(extensionId, 'Chibi'); @@ -95,7 +155,7 @@ export function inject (vm: ChibiCompatibleVM) { return originalLoadFunc.call(this, extensionURL, ...args); } } catch (e: unknown) { - error('Error occurred while sideloading extension. To avoid interrupting the loading process, we chose to ignore this error.', e); + error(format('chibi.errorIgnored'), e); } } else { // @ts-expect-error internal hack @@ -121,15 +181,17 @@ export function inject (vm: ChibiCompatibleVM) { obj.extensionEnvs = Object.assign({}, obj.extensionEnvs, envs); return JSON.stringify(obj); }; - + const originalDrserializeFunc = vm.deserializeProject; vm.deserializeProject = function (projectJSON: Record, ...args: unknown[]) { if (typeof projectJSON.extensionURLs === 'object') { for (const id in projectJSON.extensionURLs) { window.chibi.registeredExtension[id] = { url: projectJSON.extensionURLs[id], - env: typeof projectJSON.extensionEnvs === 'object' ? - projectJSON.extensionEnvs[id] : 'sandboxed' + env: + typeof projectJSON.extensionEnvs === 'object' + ? projectJSON.extensionEnvs[id] + : 'sandboxed' }; } } @@ -139,28 +201,43 @@ export function inject (vm: ChibiCompatibleVM) { const originSetLocaleFunc = vm.setLocale; vm.setLocale = function (locale: string, ...args: unknown[]) { + format.setup({ + locale, + missingTranslation: 'ignore', + generateId: (defaultMessage: string) => `${defaultMessage}`, + translations: l10n + }); // @ts-expect-error internal hack const result = originSetLocaleFunc.call(this, locale, ...args); // @ts-expect-error lazy to extend VM interface vm.emit('LOCALE_CHANGED', locale); return result; }; - + // TODO: compiler support const originalArgReporterBooleanFunc = vm.runtime._primitives['argument_reporter_boolean']; - vm.runtime._primitives['argument_reporter_boolean'] = function (args: Record, ...otherArgs: unknown[]) { + vm.runtime._primitives['argument_reporter_boolean'] = function ( + args: Record, + ...otherArgs: unknown[] + ) { const chibiFlag = args.VALUE; switch (chibiFlag) { - case '🧐 Chibi Installed?': - return true; - default: - return originalArgReporterBooleanFunc.call(this, args, ...otherArgs); + case '🧐 Chibi Installed?': + warn("'🧐 Chibi Installed?' is deprecated, use '🧐 Chibi?' instead."); + return true; + case '🧐 Chibi?': + return true; + default: + return originalArgReporterBooleanFunc.call(this, args, ...otherArgs); } - } + }; // Hack for ClipCC 3.2- versions if (typeof vm.ccExtensionManager === 'object') { const originalGetOrderFunc = vm.ccExtensionManager.getExtensionLoadOrder; - vm.ccExtensionManager.getExtensionLoadOrder = function (extensions: string[], ...args: unknown[]) { + vm.ccExtensionManager.getExtensionLoadOrder = function ( + extensions: string[], + ...args: unknown[] + ) { for (const extensionId of extensions) { if ( !vm.ccExtensionManager!.info.hasOwnProperty(extensionId) && @@ -178,10 +255,12 @@ export function inject (vm: ChibiCompatibleVM) { // Blockly stuffs setTimeout(() => { - const blockly = window.chibi.blockly = getBlocklyInstance(); + const blockly = (window.chibi.blockly = getBlocklyInstance(vm)); + // deprecated: this method will be removed in the future. if (!blockly) { warn('Cannot find real blockly instance, try alternative method...'); - const originalProcedureCallback = window.Blockly?.getMainWorkspace().toolboxCategoryCallbacks_.PROCEDURE; + const originalProcedureCallback = + window.Blockly?.getMainWorkspace().toolboxCategoryCallbacks_.PROCEDURE; if (!originalProcedureCallback) { error('alternative method failed, stop injecting'); return; @@ -201,7 +280,7 @@ export function inject (vm: ChibiCompatibleVM) { // Add dashboard button const dashboardButton = document.createElement('button'); - dashboardButton.setAttribute('text', 'Open Frontend'); + dashboardButton.setAttribute('text', format('chibi.openFrontend')); dashboardButton.setAttribute('callbackKey', 'CHIBI_FRONTEND'); workspace.registerButtonCallback('CHIBI_FRONTEND', () => { window.chibi.openFrontend(); @@ -210,12 +289,14 @@ export function inject (vm: ChibiCompatibleVM) { // Add load from url button const sideloadButton = document.createElement('button'); - sideloadButton.setAttribute('text', 'Sideload from URL'); + sideloadButton.setAttribute('text', format('chibi.sideload')); sideloadButton.setAttribute('callbackKey', 'CHIBI_SIDELOAD_FROM_URL'); workspace.registerButtonCallback('CHIBI_SIDELOAD_FROM_URL', () => { - const url = prompt('Enter URL'); + const url = prompt(format('chibi.enterURL')); if (!url) return; - const mode = confirm('Running in sandbox?') ? 'sandboxed' : 'unsandboxed'; + const mode = confirm(format('chibi.loadInSandbox')) + ? 'sandboxed' + : 'unsandboxed'; window.chibi.loader.load(url, mode); }); xmlList.push(sideloadButton); @@ -225,7 +306,7 @@ export function inject (vm: ChibiCompatibleVM) { mutation.setAttribute('chibi', 'installed'); const field = document.createElement('field'); field.setAttribute('name', 'VALUE'); - field.innerHTML = '🧐 Chibi Installed?'; + field.innerHTML = '🧐 Chibi?'; const block = document.createElement('block'); block.setAttribute('type', 'argument_reporter_boolean'); block.setAttribute('gap', '16'); @@ -238,8 +319,7 @@ export function inject (vm: ChibiCompatibleVM) { workspace.getToolbox().refreshSelection(); workspace.toolboxRefreshEnabled_ = true; return; - }; - + } const originalAddCreateButton_ = blockly.Procedures.addCreateButton_; blockly.Procedures.addCreateButton_ = function ( workspace: ChibiCompatibleWorkspace, @@ -257,7 +337,7 @@ export function inject (vm: ChibiCompatibleVM) { // Add dashboard button const dashboardButton = document.createElement('button'); - dashboardButton.setAttribute('text', 'Open Frontend'); + dashboardButton.setAttribute('text', format('chibi.openFrontend')); dashboardButton.setAttribute('callbackKey', 'CHIBI_FRONTEND'); workspace.registerButtonCallback('CHIBI_FRONTEND', () => { window.chibi.openFrontend(); @@ -266,12 +346,12 @@ export function inject (vm: ChibiCompatibleVM) { // Add load from url button const sideloadButton = document.createElement('button'); - sideloadButton.setAttribute('text', 'Sideload from URL'); + sideloadButton.setAttribute('text', format('chibi.sideload')); sideloadButton.setAttribute('callbackKey', 'CHIBI_SIDELOAD_FROM_URL'); workspace.registerButtonCallback('CHIBI_SIDELOAD_FROM_URL', () => { - const url = prompt('Enter URL'); + const url = prompt(format('chibi.enterURL')); if (!url) return; - const mode = confirm('Running in sandbox?') ? 'sandboxed' : 'unsandboxed'; + const mode = confirm(format('chibi.loadInSandbox')) ? 'sandboxed' : 'unsandboxed'; window.chibi.loader.load(url, mode); }); xmlList.push(sideloadButton); @@ -281,7 +361,7 @@ export function inject (vm: ChibiCompatibleVM) { mutation.setAttribute('chibi', 'installed'); const field = document.createElement('field'); field.setAttribute('name', 'VALUE'); - field.innerHTML = '🧐 Chibi Installed?'; + field.innerHTML = '🧐 Chibi?'; const block = document.createElement('block'); block.setAttribute('type', 'argument_reporter_boolean'); block.setAttribute('gap', '16'); diff --git a/src/l10n/l10n.json b/src/l10n/l10n.json new file mode 100644 index 0000000..8388f31 --- /dev/null +++ b/src/l10n/l10n.json @@ -0,0 +1,29 @@ +{ + "zh-cn": { + "chibi.openFrontend": "打开面板", + "chibi.sideload": "从 URL 侧载扩展", + "chibi.errorIgnored": "在加载扩展扩展时出现错误。为了避免加载进程的中断,此错误已被忽略。", + "chibi.tryLoad": "🤨 项目正从 [url] 加载扩展 [extensionURL]。要加载么?", + "chibi.tryLoadInEnv": "🤨 项目正以 [env] 模式从 [url] 加载扩展 [extensionURL]。要加载么?", + "chibi.loadInSandbox": "🤨 要在沙箱模式中加载扩展么?", + "chibi.enterURL": "🌐 输入" + }, + "en": { + "chibi.openFrontend": "Open Frontend", + "chibi.sideload": "Sideload from URL", + "chibi.errorIgnored": "Error occurred while sideloading extension. To avoid interrupting the loading process, we chose to ignore this error.", + "chibi.tryLoad": "🤨 Project is trying to sideloading [extensionURL] from [url]. Do you want to load?", + "chibi.tryLoadInEnv": "🤨 Project is trying to sideloading [extensionURL] from [url] in [env] mode. Do you want to load?", + "chibi.loadInSandbox": "🤨 Do you want to load it in the sandbox?", + "chibi.enterURL": "🌐 Enter URL" + }, + "ja": { + "chibi.openFrontend": "ダッシュボードを開く", + "chibi.sideload": "URL から拡張機能を導入", + "chibi.errorIgnored": "拡張機能のサイドロード中でエラーが発生しました。ロードの中断を防ぐために、このエラーは無視しました。", + "chibi.tryLoad": "🤨 プロジェクトは [url] から [extensionURL] をサイドロードしています。ロードしますか?", + "chibi.tryLoadInEnv": "🤨 プロジェクトは [env] モードで、[url] から [extensionURL] をサイドロードしています。ロードしますか?", + "chibi.loadInSandbox": "🤨 サンドボックス環境でロードしますか?", + "chibi.enterURL": "🌐 URL を入力してください。" + } +} diff --git a/src/loader/dispatch/central-dispatch.ts b/src/loader/dispatch/central-dispatch.ts index 261203e..db3b74b 100644 --- a/src/loader/dispatch/central-dispatch.ts +++ b/src/loader/dispatch/central-dispatch.ts @@ -12,14 +12,14 @@ class _CentralDispatch extends SharedDispatch { * The constructor we will use to recognize workers. * @type {Worker | null} */ - workerClass: typeof Worker | null = (typeof Worker === 'undefined' ? null : Worker); + workerClass: typeof Worker | null = typeof Worker === 'undefined' ? null : Worker; /** * List of workers attached to this dispatcher. * @type {Array} */ workers: Worker[] = []; _onMessage!: (worker: Worker, event: MessageEvent) => void; - constructor () { + constructor() { super(); /** * Map of channel name to worker or local service provider. @@ -38,8 +38,8 @@ class _CentralDispatch extends SharedDispatch { * @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: unknown[]) { - const {provider, isRemote} = this._getServiceProvider(service); + callSync(service: string, method: string, ...args: unknown[]) { + const { provider, isRemote } = this._getServiceProvider(service); if (provider) { if (isRemote) { throw new Error(`Cannot use 'callSync' on remote provider for service ${service}.`); @@ -54,7 +54,7 @@ class _CentralDispatch extends SharedDispatch { * @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: string, provider: any) { + setServiceSync(service: string, provider: any) { if (this.services.hasOwnProperty(service)) { console.warn(`Central dispatch replacing existing service provider for ${service}`); } @@ -67,7 +67,7 @@ class _CentralDispatch extends SharedDispatch { * @param {object} provider - a local object which provides this service. * @returns {Promise} - a promise which will resolve once the service is registered. */ - setService (service: string, provider: any) { + setService(service: string, provider: any) { /** Return a promise for consistency with {@link WorkerDispatch#setService} */ try { this.setServiceSync(service, provider); @@ -81,11 +81,11 @@ class _CentralDispatch extends SharedDispatch { * The dispatcher will immediately attempt to "handshake" with the worker. * @param {Worker} worker - the worker to add into the dispatch system. */ - addWorker (worker: Worker) { + 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 => { + this._remoteCall(worker, 'dispatch', 'handshake').catch((e) => { console.error(`Could not handshake with worker: ${e}`); }); } else { @@ -99,12 +99,16 @@ class _CentralDispatch extends SharedDispatch { * @returns {{provider:(object|Worker), isRemote:boolean}} - the means to contact the service, if found * @protected */ - _getServiceProvider (service: string) { + _getServiceProvider(service: string) { const provider = this.services[service]; - return provider && { - provider, - isRemote: Boolean((this.workerClass && provider instanceof this.workerClass) || provider.isRemote) - }; + return ( + provider && { + provider, + isRemote: Boolean( + (this.workerClass && provider instanceof this.workerClass) || provider.isRemote + ) + } + ); } /** * Handle a call message sent to the dispatch service itself @@ -114,18 +118,20 @@ class _CentralDispatch extends SharedDispatch { * @returns {Promise|undefined} - a promise for the results of this operation, if appropriate * @protected */ - _onDispatchMessage (worker: Worker, message: DispatchCallMessage) { + _onDispatchMessage(worker: Worker, message: DispatchCallMessage) { let promise; switch (message.method) { - case 'setService': - if (!message.args) { - console.error('setService received empty argument'); + case 'setService': + if (!message.args) { + console.error('setService received empty argument'); + break; + } + promise = this.setService(String(message.args[0]), worker); break; - } - promise = this.setService(String(message.args[0]), worker); - break; - default: - console.error(`Central dispatch received message for unknown method: ${message.method}`); + default: + console.error( + `Central dispatch received message for unknown method: ${message.method}` + ); } return promise; } diff --git a/src/loader/dispatch/shared-dispatch.ts b/src/loader/dispatch/shared-dispatch.ts index e13d2fa..305734f 100644 --- a/src/loader/dispatch/shared-dispatch.ts +++ b/src/loader/dispatch/shared-dispatch.ts @@ -73,7 +73,7 @@ class SharedDispatch { * @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: string, method: string, ...args: unknown[]) { + call(service: string, method: string, ...args: unknown[]) { return this.transferCall(service, method, null, ...args); } /** @@ -91,10 +91,10 @@ class SharedDispatch { * @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: string, method: string, transfer: unknown, ...args: unknown[]) { + transferCall(service: string, method: string, transfer: unknown, ...args: unknown[]) { try { // @ts-expect-error TS(2339): It's not implemented. - const {provider, isRemote} = this._getServiceProvider(service); + const { provider, isRemote } = this._getServiceProvider(service); if (provider) { if (isRemote) { return this._remoteTransferCall(provider, service, method, transfer, ...args); @@ -113,7 +113,7 @@ class SharedDispatch { * @returns {boolean} - true if the service is remote (calls must cross a Worker boundary), false otherwise. * @private */ - _isRemoteService (service: string): boolean { + _isRemoteService(service: string): boolean { // @ts-expect-error TS(2339): It's not implemented. return this._getServiceProvider(service).isRemote; } @@ -125,7 +125,12 @@ class SharedDispatch { * @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: any, service: string, method: string, ...args: unknown[]): Promise { + _remoteCall( + provider: any, + service: string, + method: string, + ...args: unknown[] + ): Promise { return this._remoteTransferCall(provider, service, method, null, ...args); } /** @@ -137,16 +142,22 @@ class SharedDispatch { * @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: Worker, service: string, method: string, transfer: unknown, ...args: unknown[]) { + _remoteTransferCall( + provider: Worker, + service: string, + method: string, + transfer: unknown, + ...args: unknown[] + ) { return new Promise((resolve, reject) => { const responseId = this._storeCallbacks(resolve, reject); if (args) { args = this._purifyObject(args) as unknown[]; } if (transfer) { - provider.postMessage({service, method, responseId, args}, transfer); + provider.postMessage({ service, method, responseId, args }, transfer); } else { - provider.postMessage({service, method, responseId, args}); + provider.postMessage({ service, method, responseId, args }); } }); } @@ -157,7 +168,7 @@ class SharedDispatch { * @returns {*} - a unique response ID for this set of callbacks. See {@link _deliverResponse}. * @protected */ - _storeCallbacks (resolve: (value: unknown) => void, reject: (value: unknown) => void) { + _storeCallbacks(resolve: (value: unknown) => void, reject: (value: unknown) => void) { const responseId = this.nextResponseId++; this.callbacks[responseId] = [resolve, reject]; return responseId; @@ -168,7 +179,7 @@ class SharedDispatch { * @param {DispatchResponseMessage} message - the message containing the response value(s). * @protected */ - _deliverResponse (responseId: number, message: DispatchResponseMessage) { + _deliverResponse(responseId: number, message: DispatchResponseMessage) { try { const [resolve, reject] = this.callbacks[responseId]; delete this.callbacks[responseId]; @@ -187,7 +198,7 @@ class SharedDispatch { * @param {MessageEvent} event - the message event to be handled. * @protected */ - _onMessage (worker: Worker, event: MessageEvent) { + _onMessage(worker: Worker, event: MessageEvent) { /** @type {DispatchMessage} */ const message = event.data; message.args = message.args || []; @@ -199,15 +210,30 @@ class SharedDispatch { promise = this.call(message.service, message.method, ...message.args); } } else if (typeof message.responseId === 'undefined') { - console.error(`Dispatch caught malformed message from a worker: ${JSON.stringify(event)}`); + console.error( + `Dispatch caught malformed message from a worker: ${JSON.stringify(event)}` + ); } else { this._deliverResponse(message.responseId, message); } if (promise) { if (typeof message.responseId === 'undefined') { - console.error(`Dispatch message missing required response ID: ${JSON.stringify(event)}`); + console.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: `${error}`})); + promise.then( + (result) => + worker.postMessage({ + responseId: message.responseId, + result + }), + (error) => + worker.postMessage({ + responseId: message.responseId, + error: `${error}` + }) + ); } } } @@ -218,8 +244,10 @@ class SharedDispatch { * @returns {{provider:(object|Worker), isRemote:boolean}} - the means to contact the service, if found * @protected */ - _getServiceProvider (service: string) { - throw new Error(`Could not get provider for ${service}: _getServiceProvider not implemented`); + _getServiceProvider(service: string) { + throw new Error( + `Could not get provider for ${service}: _getServiceProvider not implemented` + ); } /** * Handle a call message sent to the dispatch service itself @@ -229,8 +257,10 @@ class SharedDispatch { * @returns {Promise|undefined} - a promise for the results of this operation, if appropriate * @private */ - _onDispatchMessage (worker: Worker, message: DispatchCallMessage) { - throw new Error(`Unimplemented dispatch message handler cannot handle ${message.method} method`); + _onDispatchMessage(worker: Worker, message: DispatchCallMessage) { + throw new Error( + `Unimplemented dispatch message handler cannot handle ${message.method} method` + ); } /** @@ -238,18 +268,18 @@ class SharedDispatch { * @param {obj} object - The Object that need to be purified. * @returns {object} - purified object. */ - _purifyObject (obj: unknown, visited = new Set(), depth = 1): unknown { - if (typeof obj === "function" || typeof obj === "symbol") { + _purifyObject(obj: unknown, visited = new Set(), depth = 1): unknown { + if (typeof obj === 'function' || typeof obj === 'symbol') { return undefined; } - if (obj !== null && typeof obj === "object") { + if (obj !== null && typeof obj === 'object') { if (visited.has(obj)) return undefined; visited.add(obj); if (Array.isArray(obj)) { return obj.map((item) => this._purifyObject(item, visited, depth + 1)); - } + } const result: Record = {}; for (const key in obj) { // @ts-expect-error @@ -257,12 +287,9 @@ class SharedDispatch { result[key] = this._purifyObject(value, visited, depth + 1); } return result; - } return obj; } } -export { - SharedDispatch -}; +export { SharedDispatch }; diff --git a/src/loader/dispatch/worker-dispatch.ts b/src/loader/dispatch/worker-dispatch.ts index c4bc11a..a246a95 100644 --- a/src/loader/dispatch/worker-dispatch.ts +++ b/src/loader/dispatch/worker-dispatch.ts @@ -1,7 +1,4 @@ -import { - DispatchCallMessage, - SharedDispatch -} from './shared-dispatch'; +import { DispatchCallMessage, SharedDispatch } from './shared-dispatch'; /** * This class provides a Worker with the means to participate in the message dispatch system managed by CentralDispatch. @@ -29,10 +26,10 @@ class _WorkerDispatch extends SharedDispatch { _onConnect!: (value?: unknown) => void; // @ts-expect-error _onMessage!: (worker: window & globalThis, event: MessageEvent) => void; - constructor () { + constructor() { super(); - this._connectionPromise = new Promise(resolve => { + this._connectionPromise = new Promise((resolve) => { this._onConnect = resolve; }); @@ -51,7 +48,7 @@ class _WorkerDispatch extends SharedDispatch { * dispatch.call('myService', 'hello'); * }) */ - get waitForConnection () { + get waitForConnection() { return this._connectionPromise; } @@ -62,12 +59,14 @@ class _WorkerDispatch extends SharedDispatch { * @param {object} provider - a local object which provides this service. * @returns {Promise} - a promise which will resolve once the service is registered. */ - setService (service: string, provider: unknown) { + setService(service: string, provider: unknown) { if (this.services.hasOwnProperty(service)) { console.warn(`Worker dispatch replacing existing service provider for ${service}`); } this.services[service] = provider; - return this.waitForConnection.then(() => this._remoteCall(self, 'dispatch', 'setService', service)); + return this.waitForConnection.then(() => + this._remoteCall(self, 'dispatch', 'setService', service) + ); } /** @@ -77,7 +76,7 @@ class _WorkerDispatch extends SharedDispatch { * @returns {{provider:(object|Worker), isRemote:boolean}} - the means to contact the service, if found * @protected */ - _getServiceProvider (service: string) { + _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 { @@ -94,19 +93,21 @@ class _WorkerDispatch extends SharedDispatch { * @returns {Promise|undefined} - a promise for the results of this operation, if appropriate * @protected */ - _onDispatchMessage (worker: Worker, message: DispatchCallMessage) { + _onDispatchMessage(worker: Worker, message: DispatchCallMessage) { 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: - console.error(`Worker dispatch received message for unknown method: ${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: + console.error( + `Worker dispatch received message for unknown method: ${message.method}` + ); } return promise; } diff --git a/src/loader/loader.ts b/src/loader/loader.ts index 4660f04..f9c2ed1 100644 --- a/src/loader/loader.ts +++ b/src/loader/loader.ts @@ -1,5 +1,5 @@ /// -import {log, warn, error} from '../util/log'; +import { log, warn, error } from '../util/log'; import { StandardScratchExtensionClass as ExtensionClass, ExtensionMetadata, @@ -9,16 +9,14 @@ import { MenuItems, BlockArgs } from '../typings'; -import { - maybeFormatMessage -} from '../util/maybe-format-message'; +import { maybeFormatMessage } from '../util/maybe-format-message'; import { CentralDispatch as dispatch } from './dispatch/central-dispatch'; import { makeCtx } from './make-ctx'; import ExtensionSandbox from './sandbox.worker'; import type VM from 'scratch-vm'; interface PendingExtensionWorker { - extensionURL: string, + extensionURL: string; resolve: (value: unknown) => void; reject: (value: unknown) => void; } @@ -44,7 +42,7 @@ class ChibiLoader { * @type {int} */ private nextExtensionWorker = 0; - + /** * Whether Scratch object should be passed inline. * @type {boolean} @@ -71,13 +69,15 @@ class ChibiLoader { */ loadedScratchExtension = new Map(); - constructor (vm: VM) { + constructor(vm: VM) { this.vm = vm; this.inlinedCtx = typeof window.Scratch === 'object'; if (!this.inlinedCtx) { window.Scratch = makeCtx(this.vm); } else { - warn('A Scratch instance already exists in the current environment, so it will be passed inline for unsandboxed extension.'); + warn( + 'A Scratch instance already exists in the current environment, so it will be passed inline for unsandboxed extension.' + ); } dispatch.setService('loader', this).catch((e: Error) => { error(`ChibiLoader was unable to register extension service: ${JSON.stringify(e)}`); @@ -89,34 +89,34 @@ class ChibiLoader { * @param {ExtensionClass | string} ext - Extension's data. * @param {'sandboxed' | 'unsandboxed'} env - Extension's running environment. */ - async load (ext: string | ExtensionClass, env: 'sandboxed' | 'unsandboxed' = 'sandboxed') { + async load(ext: string | ExtensionClass, env: 'sandboxed' | 'unsandboxed' = 'sandboxed') { if (typeof ext === 'string') { switch (env) { - case 'sandboxed': - return new Promise((resolve, reject) => { - // If we `require` this at the global level it breaks non-webpack targets, including tests - const ExtensionWorker = new ExtensionSandbox(); - this.pendingExtensions.push({ - extensionURL: ext, - resolve, - reject + case 'sandboxed': + return new Promise((resolve, reject) => { + // If we `require` this at the global level it breaks non-webpack targets, including tests + const ExtensionWorker = new ExtensionSandbox(); + this.pendingExtensions.push({ + extensionURL: ext, + resolve, + reject + }); + dispatch.addWorker(ExtensionWorker); }); - dispatch.addWorker(ExtensionWorker); - }); - case 'unsandboxed': { - const response = await fetch(ext); - const originalScript = await response.text(); - const closureFunc = new Function('Scratch', originalScript); - const ctx = makeCtx(this.vm); - ctx.extensions.register = (extensionObj: ExtensionClass) => { - const extensionInfo = extensionObj.getInfo(); - this._registerExtensionInfo(extensionObj, extensionInfo, ext); - }; - closureFunc(ctx); - return; - } - default: - throw new Error('unexpected env'); + case 'unsandboxed': { + const response = await fetch(ext); + const originalScript = await response.text(); + const closureFunc = new Function('Scratch', originalScript); + const ctx = makeCtx(this.vm); + ctx.extensions.register = (extensionObj: ExtensionClass) => { + const extensionInfo = extensionObj.getInfo(); + this._registerExtensionInfo(extensionObj, extensionInfo, ext); + }; + closureFunc(ctx); + return; + } + default: + throw new Error('unexpected env'); } } @@ -131,7 +131,7 @@ class ChibiLoader { * Reload a scratch-standard extension. * @param {string} extensionId - Extension's ID */ - async reload (extensionId: string) { + async reload(extensionId: string) { const targetExt = this.loadedScratchExtension.get(extensionId); if (!targetExt) { throw new Error(`Cannot locate extension ${extensionId}.`); @@ -154,7 +154,7 @@ class ChibiLoader { /** * Get all sideloaded extension infos. */ - getLoadedInfo () { + getLoadedInfo() { const extensionURLs: Record = {}; const extensionEnv: Record = {}; for (const [extId, ext] of this.loadedScratchExtension.entries()) { @@ -164,7 +164,7 @@ class ChibiLoader { return [extensionURLs, extensionEnv]; } - getIdByUrl (url: string) { + getIdByUrl(url: string) { for (const [extId, ext] of this.loadedScratchExtension.entries()) { if (ext.url === url) { return extId; @@ -178,7 +178,7 @@ class ChibiLoader { * original extension manager to reload locales. It should * be replaced when there's a better solution. */ - reloadAll () { + reloadAll() { const allPromises: Promise[] = []; for (const [extId] of this.loadedScratchExtension.entries()) { allPromises.push(this.reload(extId)); @@ -193,7 +193,12 @@ class ChibiLoader { * @param {string} serviceName - the name of the service hosting the extension * @private */ - private _registerExtensionInfo (extensionObject: ExtensionClass | null, extensionInfo: ExtensionMetadata, extensionURL: string, serviceName?: string) { + private _registerExtensionInfo( + extensionObject: ExtensionClass | null, + extensionInfo: ExtensionMetadata, + extensionURL: string, + serviceName?: string + ) { if (!this.loadedScratchExtension.has(extensionInfo.id)) { if (!extensionObject && !serviceName) { throw new Error(`Cannnot mark ${extensionInfo.id} as loaded.`); @@ -220,7 +225,7 @@ class ChibiLoader { * @returns {string} - the sanitized text * @private */ - private _sanitizeID (text: string) { + private _sanitizeID(text: string) { return text.toString().replace(/[<"&]/, '_'); } @@ -233,7 +238,11 @@ class ChibiLoader { * @returns {ExtensionInfo} - a new extension info object with cleaned-up values * @private */ - private _prepareExtensionInfo (extensionObject: ExtensionClass | null, extensionInfo: ExtensionMetadata, serviceName?: string) { + private _prepareExtensionInfo( + extensionObject: ExtensionClass | null, + extensionInfo: ExtensionMetadata, + serviceName?: string + ) { extensionInfo = Object.assign({}, extensionInfo); if (!/^[a-z0-9]+$/i.test(extensionInfo.id)) { throw new Error('Invalid extension id'); @@ -241,26 +250,41 @@ class ChibiLoader { extensionInfo.name = extensionInfo.name || extensionInfo.id; extensionInfo.blocks = extensionInfo.blocks || []; extensionInfo.targetTypes = extensionInfo.targetTypes || []; - extensionInfo.blocks = extensionInfo.blocks.reduce((results: Array, blockInfo) => { - try { - let result; - switch (blockInfo) { - case '---': // Separator - result = '---'; - break; - default: // An ExtensionBlockMetadata object - result = this._prepareBlockInfo(extensionObject, blockInfo as ExtensionBlockMetadata, serviceName); - break; + extensionInfo.blocks = extensionInfo.blocks.reduce( + (results: Array, blockInfo) => { + try { + let result; + switch (blockInfo) { + case '---': // Separator + result = '---'; + break; + default: // An ExtensionBlockMetadata object + result = this._prepareBlockInfo( + extensionObject, + blockInfo as ExtensionBlockMetadata, + serviceName + ); + break; + } + results.push(result); + } catch (e: unknown) { + // TODO: more meaningful error reporting + error( + `Error processing block: ${(e as Error).message}, Block:\n${JSON.stringify( + blockInfo + )}` + ); } - results.push(result); - } catch (e: unknown) { - // TODO: more meaningful error reporting - error(`Error processing block: ${(e as Error).message}, Block:\n${JSON.stringify(blockInfo)}`); - } - return results; - }, []); + return results; + }, + [] + ); extensionInfo.menus = extensionInfo.menus || {}; - extensionInfo.menus = this._prepareMenuInfo(extensionObject, extensionInfo.menus, serviceName); + extensionInfo.menus = this._prepareMenuInfo( + extensionObject, + extensionInfo.menus, + serviceName + ); return extensionInfo as ExtensionMetadata; } @@ -272,7 +296,11 @@ class ChibiLoader { * @returns {Array.} - a menuInfo object with all preprocessing done. * @private */ - private _prepareMenuInfo (extensionObject: ExtensionClass | null, menus: Record, serviceName?: string) { + private _prepareMenuInfo( + extensionObject: ExtensionClass | null, + menus: Record, + serviceName?: string + ) { const menuNames = Object.getOwnPropertyNames(menus); for (let i = 0; i < menuNames.length; i++) { const menuName = menuNames[i]; @@ -296,7 +324,12 @@ class ChibiLoader { if (typeof menuInfo.items === 'string') { const menuItemFunctionName = menuInfo.items; // @ts-expect-error Bind the function here so we can pass a simple item generation function to Scratch Blocks later - menuInfo.items = this._getExtensionMenuItems.bind(this, extensionObject, menuItemFunctionName, serviceName); + menuInfo.items = this._getExtensionMenuItems.bind( + this, + extensionObject, + menuItemFunctionName, + serviceName + ); } } return menus; @@ -310,34 +343,37 @@ class ChibiLoader { * @returns {Array} menu items ready for scratch-blocks. * @private */ - private _getExtensionMenuItems (extensionObject: ExtensionClass, menuItemFunctionName: string, serviceName?: string): any[] { + private _getExtensionMenuItems( + extensionObject: ExtensionClass, + menuItemFunctionName: string, + serviceName?: string + ): any[] { /* * 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.vm.runtime.getEditingTarget() || this.vm.runtime.getTargetForStage(); + const editingTarget = + this.vm.runtime.getEditingTarget() || this.vm.runtime.getTargetForStage(); const editingTargetID = editingTarget ? editingTarget.id : null; // @ts-expect-error private method const extensionMessageContext = this.vm.runtime.makeMessageContextForTarget(editingTarget); // TODO: Fix this to use dispatch.call when extensions are running in workers. - const menuFunc = extensionObject[menuItemFunctionName] as (editingTargetID: string | null) => MenuItems; - const menuItems = menuFunc.call(extensionObject, editingTargetID).map( - item => { - item = maybeFormatMessage(item, extensionMessageContext); - switch (typeof item) { + const menuFunc = extensionObject[menuItemFunctionName] as ( + editingTargetID: string | null + ) => MenuItems; + const menuItems = menuFunc.call(extensionObject, editingTargetID).map((item) => { + item = maybeFormatMessage(item, extensionMessageContext); + switch (typeof item) { case 'object': - return [ - maybeFormatMessage(item.text, extensionMessageContext), - item.value - ]; + 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}`); @@ -353,72 +389,92 @@ class ChibiLoader { * @returns {ExtensionBlockMetadata} - a new block info object which has values for all relevant optional fields. * @private */ - private _prepareBlockInfo (extensionObject: ExtensionClass | null, blockInfo: ExtensionBlockMetadata, serviceName?: string) { - blockInfo = Object.assign({}, { - blockType: BlockType.COMMAND, - terminal: false, - blockAllThreads: false, - arguments: {} - }, blockInfo); + private _prepareBlockInfo( + extensionObject: ExtensionClass | null, + blockInfo: ExtensionBlockMetadata, + serviceName?: string + ) { + 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) { - warn(`Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}`); - } - break; - case BlockType.BUTTON: - if (blockInfo.opcode) { - 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: BlockArgs) => args && args.mutation && args.mutation.blockInfo : - () => blockInfo; - const callBlockFunc = (() => { - // Maybe there's a worker - if (extensionObject === null) { - if (serviceName && dispatch._isRemoteService(serviceName)) { - return (args: BlockArgs, _util: unknown, realBlockInfo: unknown) => - dispatch.call(serviceName, funcName, args, undefined, realBlockInfo); - } - warn(`Could not find extension block function called ${funcName}`); - // eslint-disable-next-line @typescript-eslint/no-empty-function - return () => {}; + case BlockType.EVENT: + if (blockInfo.func) { + warn( + `Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}` + ); } - - if (!extensionObject[funcName]) { - // The function might show up later as a dynamic property of the service object - warn(`Could not find extension block function called ${funcName}`); + break; + case BlockType.BUTTON: + if (blockInfo.opcode) { + warn( + `Ignoring opcode "${blockInfo.opcode}" for button with text: ${blockInfo.text}` + ); } - return (args: BlockArgs, util: unknown, realBlockInfo: unknown) => - // @ts-expect-error - extensionObject[funcName](args, util, realBlockInfo); - })(); - - // @ts-expect-error - blockInfo.func = (args: BlockArgs, util: unknown) => { - const realBlockInfo = getBlockInfo(args); - // TODO: filter args using the keys of realBlockInfo.arguments? maybe only if sandboxed? - return callBlockFunc(args, util, realBlockInfo); - }; - break; - } + 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: BlockArgs) => args && args.mutation && args.mutation.blockInfo + : () => blockInfo; + const callBlockFunc = (() => { + // Maybe there's a worker + if (extensionObject === null) { + if (serviceName && dispatch._isRemoteService(serviceName)) { + return (args: BlockArgs, _util: unknown, realBlockInfo: unknown) => + dispatch.call( + serviceName, + funcName, + args, + undefined, + realBlockInfo + ); + } + warn(`Could not find extension block function called ${funcName}`); + // eslint-disable-next-line @typescript-eslint/no-empty-function + return () => {}; + } + + if (!extensionObject[funcName]) { + // The function might show up later as a dynamic property of the service object + warn(`Could not find extension block function called ${funcName}`); + } + return (args: BlockArgs, util: unknown, realBlockInfo: unknown) => + // @ts-expect-error + extensionObject[funcName](args, util, realBlockInfo); + })(); + + // @ts-expect-error + blockInfo.func = (args: BlockArgs, util: unknown) => { + 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; } - async updateLocales () { + async updateLocales() { await this.reloadAll(); } @@ -426,11 +482,11 @@ class ChibiLoader { * Regenerate blockinfo for any loaded extensions * @returns {Promise} resolved once all the extensions have been reinitialized */ - async refreshBlocks () { + async refreshBlocks() { await this.reloadAll(); } - allocateWorker () { + allocateWorker() { const workerInfo = this.pendingExtensions.shift(); if (!workerInfo) { warn('pending extension queue is empty'); @@ -445,7 +501,7 @@ class ChibiLoader { * Collect extension metadata from the specified service and begin the extension registration process. * @param {string} serviceName - the name of the service hosting the extension. */ - async registerExtensionService (extensionURL: string, serviceName: string) { + async registerExtensionService(extensionURL: string, serviceName: string) { const info = await dispatch.call(serviceName, 'getInfo'); this._registerExtensionInfo(null, info, extensionURL, serviceName); } @@ -455,7 +511,7 @@ class ChibiLoader { * @param {int} id - the worker ID. * @param {*?} e - the error encountered during initialization, if any. */ - onWorkerInit (id: number, e?: Error) { + onWorkerInit(id: number, e?: Error) { const workerInfo = this.pendingWorkers[id]; delete this.pendingWorkers[id]; if (e) { @@ -466,6 +522,4 @@ class ChibiLoader { } } -export { - ChibiLoader -}; +export { ChibiLoader }; diff --git a/src/loader/make-ctx.ts b/src/loader/make-ctx.ts index 8d20776..298db22 100644 --- a/src/loader/make-ctx.ts +++ b/src/loader/make-ctx.ts @@ -10,7 +10,7 @@ import formatMessage, { Message } from 'format-message'; import type VM from 'scratch-vm'; import type Renderer from 'scratch-render'; -export interface Ctx { +export interface Context { ArgumentType: typeof ArgumentType; BlockType: typeof BlockType; TargetType: typeof TargetType; @@ -18,15 +18,19 @@ export interface Ctx { Cast: Cast; translate: ReturnType; extensions: { - register: (extensionObj: ExtensionClass) => void, - unsandboxed: boolean, - chibi: true - } + register: (extensionObj: ExtensionClass) => void; + unsandboxed: boolean; + chibi: true; + }; vm?: VM; renderer?: Renderer; } - -function createTranslate (vm?: VM) { +/** + * I10n support for Chibi extensions. + * @param vm Virtual machine instance. Optional. + * @returns Something like Scratch.translate. + */ +function createTranslate(vm?: VM) { const namespace = formatMessage.namespace(); const translate = (message: Message, args?: object) => { @@ -47,7 +51,7 @@ function createTranslate (vm?: VM) { const getLocale = () => { // @ts-expect-error lazy to extend VM interface if (vm) return vm.getLocale(); - if (typeof navigator !== 'undefined') return navigator.language; + if (typeof navigator !== 'undefined') return navigator.language; // FIXME: en-US -> en return 'en'; }; @@ -75,9 +79,13 @@ function createTranslate (vm?: VM) { return translate; } - -export function makeCtx (vm?: VM) { - const ctx: Ctx = { +/** + * Make a fake scratch context. + * @param vm Virtual machine instance. + * @returns The context. + */ +export function makeCtx(vm?: VM) { + const ctx: Context = { ArgumentType: ArgumentType, BlockType: BlockType, TargetType: TargetType, diff --git a/src/loader/sandbox.worker.ts b/src/loader/sandbox.worker.ts index 80d63ce..d1a7378 100644 --- a/src/loader/sandbox.worker.ts +++ b/src/loader/sandbox.worker.ts @@ -1,21 +1,23 @@ /* eslint-env worker */ -import { makeCtx, Ctx } from './make-ctx'; +import { makeCtx, Context } from './make-ctx'; import { WorkerDispatch as dispatch } from './dispatch/worker-dispatch'; declare global { - // eslint-disable-next-line no-var - var Scratch: Ctx; + // eslint-disable-next-line no-var + var Scratch: Context; } - +/** + * Here we implements a Worker dispatcher for sandboxed extensions. + */ class ExtensionWorker { nextExtensionId = 0; initialRegistrations: Promise[] = []; extensions: unknown[] = []; workerId?: number; extensionURL = ''; - constructor () { + constructor() { dispatch.waitForConnection.then(() => { - dispatch.call('loader', 'allocateWorker').then(x => { + dispatch.call('loader', 'allocateWorker').then((x) => { const [id, url] = x; this.workerId = id; this.extensionURL = url; @@ -26,7 +28,9 @@ class ExtensionWorker { const initialRegistrations = this.initialRegistrations; this.initialRegistrations = []; - Promise.all(initialRegistrations).then(() => dispatch.call('scratchAdapter', 'onWorkerInit', id)); + Promise.all(initialRegistrations).then(() => + dispatch.call('scratchAdapter', 'onWorkerInit', id) + ); } catch (e) { dispatch.call('scratchAdapter', 'onWorkerInit', id, e); } @@ -36,12 +40,15 @@ class ExtensionWorker { this.extensions = []; } - register (extensionObject: unknown) { + register(extensionObject: unknown) { const extensionId = this.nextExtensionId++; this.extensions.push(extensionObject); const serviceName = `extension.${this.workerId}.${extensionId}`; - const promise = dispatch.setService(serviceName, extensionObject) - .then(() => dispatch.call('loader', 'registerExtensionService', this.extensionURL, serviceName)); + const promise = dispatch + .setService(serviceName, extensionObject) + .then(() => + dispatch.call('loader', 'registerExtensionService', this.extensionURL, serviceName) + ); if (this.initialRegistrations) { this.initialRegistrations.push(promise); } diff --git a/src/typings/argument-type.ts b/src/typings/argument-type.ts index 41c73e5..a931780 100644 --- a/src/typings/argument-type.ts +++ b/src/typings/argument-type.ts @@ -37,6 +37,4 @@ enum ArgumentType { IMAGE = 'image' } -export { - ArgumentType -}; +export { ArgumentType }; diff --git a/src/typings/block-type.ts b/src/typings/block-type.ts index 12ecc1e..76382c1 100644 --- a/src/typings/block-type.ts +++ b/src/typings/block-type.ts @@ -44,6 +44,4 @@ enum BlockType { Lambda = 'Lambda' } -export { - BlockType -}; +export { BlockType }; diff --git a/src/typings/extension-metadata.ts b/src/typings/extension-metadata.ts index 7d0feb3..4b74526 100644 --- a/src/typings/extension-metadata.ts +++ b/src/typings/extension-metadata.ts @@ -8,15 +8,19 @@ export type MenuItemFunction = () => { value: string; }; -export type MenuItems = Array; +export type MenuItems = Array< + | string + | MenuItemFunction + | { + text: string; + value: string; + } +>; export interface BlockArgs { mutation?: { - blockInfo?: string - }, + blockInfo?: string; + }; [propName: string]: unknown; } @@ -39,7 +43,7 @@ export interface StandardScratchExtensionClass { getInfo: () => ExtensionMetadata; [propName: string]: unknown; } - + /** * All the metadata needed to register an extension. */ @@ -190,37 +194,37 @@ export interface ExtensionBlockMetadata { /** * List of target types for which this block should appear. */ - filter?: TargetType[] - } + filter?: TargetType[]; +} export interface ExtensionArgumentMetadata { /** * The type of the argument (number, string, etc.) */ - type: ArgumentType; - /** - * The default value of this argument - */ - defaultValue?: unknown; - /** - * The name of the menu to use for this argument, if any. - */ - menu?: string; - /** - * Only available when type is INLINE_IMAGE - */ - dataURI?: string; - /** - * Only available when type is INLINE_IMAGE - * Whether the image should be flipped horizontally when the editor has a right to left language selected as its locale. By default, the image is not flipped. - */ - flipRTL?: boolean; - /** - * Only available when type is INLINE_IMAGE - */ - alt?: string; - } - + type: ArgumentType; + /** + * The default value of this argument + */ + defaultValue?: unknown; + /** + * The name of the menu to use for this argument, if any. + */ + menu?: string; + /** + * Only available when type is INLINE_IMAGE + */ + dataURI?: string; + /** + * Only available when type is INLINE_IMAGE + * Whether the image should be flipped horizontally when the editor has a right to left language selected as its locale. By default, the image is not flipped. + */ + flipRTL?: boolean; + /** + * Only available when type is INLINE_IMAGE + */ + alt?: string; +} + /** * @typedef {ExtensionDynamicMenu|ExtensionMenuItems} ExtensionMenuMetadata * All the metadata needed to register an extension drop-down menu. diff --git a/src/typings/reporter-scope.ts b/src/typings/reporter-scope.ts index f5ec68e..5a2fa1a 100644 --- a/src/typings/reporter-scope.ts +++ b/src/typings/reporter-scope.ts @@ -15,6 +15,4 @@ enum ReporterScope { TARGET = 'target' } -export { - ReporterScope -}; +export { ReporterScope }; diff --git a/src/typings/target-type.ts b/src/typings/target-type.ts index bdc3c30..bc18e15 100644 --- a/src/typings/target-type.ts +++ b/src/typings/target-type.ts @@ -12,6 +12,4 @@ enum TargetType { */ STAGE = 'stage' } -export { - TargetType -}; +export { TargetType }; diff --git a/src/util/cast.ts b/src/util/cast.ts index a322a78..c91141f 100644 --- a/src/util/cast.ts +++ b/src/util/cast.ts @@ -1,4 +1,4 @@ -import Color, {RGBObject} from '../util/color'; +import Color, { RGBObject } from '../util/color'; /** * @fileoverview @@ -44,7 +44,7 @@ class Cast { * @param {*} value Value to cast to number. * @return {number} The Scratch-casted number value. */ - static toNumber (value: unknown) : number { + static toNumber(value: unknown): number { /* * If value is already a number we don't need to coerce it with * Number(). @@ -77,16 +77,14 @@ class Cast { * @param {*} value Value to cast to boolean. * @return {boolean} The Scratch-casted boolean value. */ - static toBoolean (value: unknown) : boolean { + static toBoolean(value: unknown): boolean { // Already a boolean? if (typeof value === 'boolean') { return value; } if (typeof value === 'string') { // These specific strings are treated as false in Scratch. - if ((value === '') || - (value === '0') || - (value.toLowerCase() === 'false')) { + if (value === '' || value === '0' || value.toLowerCase() === 'false') { return false; } // All other strings treated as true. @@ -101,7 +99,7 @@ class Cast { * @param {*} value Value to cast to string. * @return {string} The Scratch-casted string value. */ - static toString (value: unknown) { + static toString(value: unknown) { return String(value); } @@ -110,7 +108,7 @@ class Cast { * @param {*} value Value to convert to RGB color array. * @return {Array.} [r,g,b], values between 0-255. */ - static toRgbColorList (value: unknown) : [number, number, number] { + static toRgbColorList(value: unknown): [number, number, number] { const color = Cast.toRgbColorObject(value); return [color.r, color.g, color.b]; } @@ -120,13 +118,13 @@ class Cast { * @param {*} value Value to convert to RGB color object. * @return {RGBObject} [r,g,b], values between 0-255. */ - static toRgbColorObject (value: unknown) : RGBObject { + static toRgbColorObject(value: unknown): RGBObject { let color; if (typeof value === 'string' && value.substring(0, 1) === '#') { color = Color.hexToRgb(value); // If the color wasn't *actually* a hex color, cast to black - if (!color) color = {r: 0, g: 0, b: 0, a: 255}; + if (!color) color = { r: 0, g: 0, b: 0, a: 255 }; } else { color = Color.decimalToRgb(Cast.toNumber(value)); } @@ -138,7 +136,7 @@ class Cast { * @param {*} val value to check. * @return {boolean} True if the argument is all white spaces or null / empty. */ - static isWhiteSpace (val: unknown) { + static isWhiteSpace(val: unknown) { return val === null || (typeof val === 'string' && val.trim().length === 0); } @@ -149,7 +147,7 @@ class Cast { * @param {*} v2 Second value to compare. * @returns {number} Negative number if v1 < v2; 0 if equal; positive otherwise. */ - static compare (v1: unknown, v2: unknown) : number { + static compare(v1: unknown, v2: unknown): number { let n1 = Number(v1); let n2 = Number(v2); if (n1 === 0 && isNotActuallyZero(v1)) { @@ -172,10 +170,7 @@ class Cast { return 0; } // Handle the special case of Infinity - if ( - (n1 === Infinity && n2 === Infinity) || - (n1 === -Infinity && n2 === -Infinity) - ) { + if ((n1 === Infinity && n2 === Infinity) || (n1 === -Infinity && n2 === -Infinity)) { return 0; } // Compare as numbers. @@ -187,10 +182,11 @@ class Cast { * @param {*} val Value to check. * @return {boolean} True if number looks like an integer. */ - static isInt (val: unknown) : boolean { + static isInt(val: unknown): boolean { // Values that are already numbers. if (typeof val === 'number') { - if (isNaN(val)) { // NaN is considered an integer. + if (isNaN(val)) { + // NaN is considered an integer. return true; } // True if it's "round" (e.g., 2.0 and 2). @@ -205,11 +201,11 @@ class Cast { return false; } - static get LIST_INVALID () { + static get LIST_INVALID() { return 'INVALID'; } - static get LIST_ALL () { + static get LIST_ALL() { return 'ALL'; } @@ -223,7 +219,7 @@ class Cast { * @param {boolean} acceptAll Whether it should accept "all" or not. * @return {(number|string)} 1-based index for list, LIST_ALL, or LIST_INVALID. */ - static toListIndex (index : ListIndex, length: number, acceptAll: boolean) { + static toListIndex(index: ListIndex, length: number, acceptAll: boolean) { if (typeof index !== 'number') { if (index === 'all') { return acceptAll ? Cast.LIST_ALL : Cast.LIST_INVALID; @@ -248,6 +244,4 @@ class Cast { } } -export { - Cast -}; +export { Cast }; diff --git a/src/util/color.ts b/src/util/color.ts index 879498b..3ca1df8 100644 --- a/src/util/color.ts +++ b/src/util/color.ts @@ -26,13 +26,13 @@ class Color { */ /** @type {RGBObject} */ - static get RGB_BLACK () { - return {r: 0, g: 0, b: 0}; + static get RGB_BLACK() { + return { r: 0, g: 0, b: 0 }; } /** @type {RGBObject} */ - static get RGB_WHITE () { - return {r: 255, g: 255, b: 255}; + static get RGB_WHITE() { + return { r: 255, g: 255, b: 255 }; } /** @@ -40,9 +40,9 @@ class Color { * @param {number} decimal RGB color as a decimal. * @return {string} RGB color as #RRGGBB hex string. */ - static decimalToHex (decimal: number) { + static decimalToHex(decimal: number) { if (decimal < 0) { - decimal += 0xFFFFFF + 1; + decimal += 0xffffff + 1; } let hex = Number(decimal).toString(16); hex = `#${'000000'.substr(0, 6 - hex.length)}${hex}`; @@ -54,12 +54,12 @@ class Color { * @param {number} decimal RGB color as decimal. * @return {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. */ - static decimalToRgb (decimal: number) { - const a = (decimal >> 24) & 0xFF; - const r = (decimal >> 16) & 0xFF; - const g = (decimal >> 8) & 0xFF; - const b = decimal & 0xFF; - return {r: r, g: g, b: b, a: a > 0 ? a : 255}; + static decimalToRgb(decimal: number) { + const a = (decimal >> 24) & 0xff; + const r = (decimal >> 16) & 0xff; + const g = (decimal >> 8) & 0xff; + const b = decimal & 0xff; + return { r: r, g: g, b: b, a: a > 0 ? a : 255 }; } /** @@ -67,7 +67,7 @@ class Color { * @param {!string} hex Hex representation of the color. * @return {RGBObject | null} null on failure, or rgb: {r: red [0,255], g: green [0,255], b: blue [0,255]}. */ - static hexToRgb (hex: string) : RGBObject | null { + static hexToRgb(hex: string): RGBObject | null { if (hex.startsWith('#')) { hex = hex.substring(1); } @@ -82,8 +82,8 @@ class Color { b: parsed & 0xff }; } else if (hex.length === 3) { - const r = ((parsed >> 8) & 0xf); - const g = ((parsed >> 4) & 0xf); + const r = (parsed >> 8) & 0xf; + const g = (parsed >> 4) & 0xf; const b = parsed & 0xf; return { r: (r << 4) | r, @@ -99,7 +99,7 @@ class Color { * @param {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. * @return {!string} Hex representation of the color. */ - static rgbToHex (rgb: RGBObject) { + static rgbToHex(rgb: RGBObject) { return Color.decimalToHex(Color.rgbToDecimal(rgb)); } @@ -108,7 +108,7 @@ class Color { * @param {RGBObject | null} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. * @return {!number} Number representing the color. */ - static rgbToDecimal (rgb: RGBObject | null) { + static rgbToDecimal(rgb: RGBObject | null) { if (rgb === null) throw new Error('rgb must be an RGBObject'); return (rgb.r << 16) + (rgb.g << 8) + rgb.b; } @@ -118,7 +118,7 @@ class Color { * @param {!string} hex Hex representation of the color. * @return {!number} Number representing the color. */ - static hexToDecimal (hex: string) { + static hexToDecimal(hex: string) { return Color.rgbToDecimal(Color.hexToRgb(hex)); } @@ -127,54 +127,54 @@ class Color { * @param {HSVObject} hsv - {h: hue [0,360), s: saturation [0,1], v: value [0,1]} * @return {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. */ - static hsvToRgb (hsv: HSVObject) { + static hsvToRgb(hsv: HSVObject) { let h = hsv.h % 360; if (h < 0) h += 360; const s = Math.max(0, Math.min(hsv.s, 1)); const v = Math.max(0, Math.min(hsv.v, 1)); const i = Math.floor(h / 60); - const f = (h / 60) - i; + const f = h / 60 - i; const p = v * (1 - s); - const q = v * (1 - (s * f)); - const t = v * (1 - (s * (1 - f))); + const q = v * (1 - s * f); + const t = v * (1 - s * (1 - f)); let r; let g; let b; switch (i) { - default: - case 0: - r = v; - g = t; - b = p; - break; - case 1: - r = q; - g = v; - b = p; - break; - case 2: - r = p; - g = v; - b = t; - break; - case 3: - r = p; - g = q; - b = v; - break; - case 4: - r = t; - g = p; - b = v; - break; - case 5: - r = v; - g = p; - b = q; - break; + default: + case 0: + r = v; + g = t; + b = p; + break; + case 1: + r = q; + g = v; + b = p; + break; + case 2: + r = p; + g = v; + b = t; + break; + case 3: + r = p; + g = q; + b = v; + break; + case 4: + r = t; + g = p; + b = v; + break; + case 5: + r = v; + g = p; + b = q; + break; } return { @@ -189,7 +189,7 @@ class Color { * @param {RGBObject} rgb - {r: red [0,255], g: green [0,255], b: blue [0,255]}. * @return {HSVObject} hsv - {h: hue [0,360), s: saturation [0,1], v: value [0,1]} */ - static rgbToHsv (rgb: RGBObject) { + static rgbToHsv(rgb: RGBObject) { const r = rgb.r / 255; const g = rgb.g / 255; const b = rgb.b / 255; @@ -200,13 +200,13 @@ class Color { let h = 0; let s = 0; if (x !== v) { - const f = (r === x) ? g - b : ((g === x) ? b - r : r - g); - const i = (r === x) ? 3 : ((g === x) ? 5 : 1); - h = ((i - (f / (v - x))) * 60) % 360; + const f = r === x ? g - b : g === x ? b - r : r - g; + const i = r === x ? 3 : g === x ? 5 : 1; + h = ((i - f / (v - x)) * 60) % 360; s = (v - x) / v; } - return {h: h, s: s, v: v}; + return { h: h, s: s, v: v }; } /** @@ -216,14 +216,14 @@ class Color { * @param {number} fraction1 - the interpolation parameter. If this is 0.5, for example, mix the two colors equally. * @return {RGBObject} the interpolated color. */ - static mixRgb (rgb0: RGBObject, rgb1: RGBObject, fraction1: number) { + static mixRgb(rgb0: RGBObject, rgb1: RGBObject, fraction1: number) { if (fraction1 <= 0) return rgb0; if (fraction1 >= 1) return rgb1; const fraction0 = 1 - fraction1; return { - r: (fraction0 * rgb0.r) + (fraction1 * rgb1.r), - g: (fraction0 * rgb0.g) + (fraction1 * rgb1.g), - b: (fraction0 * rgb0.b) + (fraction1 * rgb1.b) + r: fraction0 * rgb0.r + fraction1 * rgb1.r, + g: fraction0 * rgb0.g + fraction1 * rgb1.g, + b: fraction0 * rgb0.b + fraction1 * rgb1.b }; } } diff --git a/src/util/log.ts b/src/util/log.ts index 04a467f..99f4c79 100644 --- a/src/util/log.ts +++ b/src/util/log.ts @@ -1,11 +1,33 @@ -export function log (...params: unknown[]) { - console.log('%c😎 Chibi', ` background-color: #f7c7bb; border-radius: 1rem; margin-right: 0.25rem; padding: 0 0.5rem; color: #271919;`, ...params); +/** + * Output general messages. + * @param params The message. + */ +export function log(...params: unknown[]) { + console.log( + '%c😎 Chibi', + ` background-color: #f7c7bb; border-radius: 1rem; margin-right: 0.25rem; padding: 0 0.5rem; color: #271919;`, + ...params + ); } - -export function warn (...params: unknown[]) { - console.warn('%c😨 Chibi', ` background-color: #f7c7bb; border-radius: 1rem; margin-right: 0.25rem; padding: 0 0.5rem; color: #271919;`, ...params); +/** + * Output warning messages. + * @param params The message. + */ +export function warn(...params: unknown[]) { + console.warn( + '%c😨 Chibi', + ` background-color: #f7c7bb; border-radius: 1rem; margin-right: 0.25rem; padding: 0 0.5rem; color: #271919;`, + ...params + ); } - -export function error (...params: unknown[]) { - console.error('%c😵 Chibi', ` background-color: #f7c7bb; border-radius: 1rem; margin-right: 0.25rem; padding: 0 0.5rem; color: #271919;`, ...params); +/** + * Output error (exception) messages. + * @param params The message. + */ +export function error(...params: unknown[]) { + console.error( + '%c😵 Chibi', + ` background-color: #f7c7bb; border-radius: 1rem; margin-right: 0.25rem; padding: 0 0.5rem; color: #271919;`, + ...params + ); } diff --git a/tsconfig.json b/tsconfig.json index eff8b88..aba4843 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,7 +42,7 @@ // "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. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ + "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. */ diff --git a/webpack.config.js b/webpack.config.js index be599b2..9e00f99 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -59,7 +59,7 @@ const prodConfig = { version: packageJSON.version, grant: ['none'], 'run-at': 'document-start', - include: [ + match: [ 'http://localhost:8601/*', 'https://scratch.mit.edu/projects/*', 'https://aerfaying.com/Projects/*', diff --git a/yarn.lock b/yarn.lock index 7b84378..b0be598 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3454,6 +3454,11 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +prettier@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.0.3.tgz#432a51f7ba422d1469096c0fdc28e235db8f9643" + integrity sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg== + pretty-error@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-4.0.0.tgz#90a703f46dd7234adb46d0f84823e9d1cb8f10d6"