From 39a2c7d05a68502b1bb18c875ff4a7bf3eb63852 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Thu, 12 Dec 2024 09:44:57 +0800 Subject: [PATCH] :art: fix: lint Signed-off-by: SimonShiki --- .eslintrc.cjs | 144 +++++ .eslintrc.json | 88 --- package.json | 4 +- pnpm-lock.yaml | 86 ++- rollup.config.mjs | 3 +- src/main/ctx.ts | 2 +- src/main/index.ts | 61 +- src/main/middleware/extension-metadata.ts | 175 +++--- src/main/middleware/index.ts | 687 ++++++++++++---------- src/main/patches/applier.ts | 197 ++++--- src/main/patches/toolbox-stuffs.ts | 8 +- src/main/placeholder.ts | 3 + src/main/trap/blocks.ts | 17 +- src/main/trap/redux.ts | 85 +-- src/main/trap/vm.ts | 1 + src/main/util/cast.ts | 6 +- src/main/util/color.ts | 70 +-- src/main/util/console.ts | 13 +- src/main/util/inject.ts | 4 +- src/main/util/l10n.ts | 4 + src/main/util/maybe-format-message.ts | 9 +- src/main/util/settings.ts | 12 +- src/main/util/xml-escape.ts | 10 +- src/types/ducktypes.d.ts | 8 +- src/types/global.d.ts | 20 +- 25 files changed, 993 insertions(+), 724 deletions(-) create mode 100644 .eslintrc.cjs delete mode 100644 .eslintrc.json create mode 100644 src/main/placeholder.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..89df65e --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,144 @@ +module.exports = { + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:solid/typescript'], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint', 'solid'], + rules: { + // Best practices + 'array-callback-return': [2], + 'block-scoped-var': [2], + 'curly': [2, 'multi-line'], + 'dot-location': [2, 'property'], + 'dot-notation': [2], + 'eqeqeq': [2], + 'no-alert': [2], + 'no-div-regex': [2], + 'no-else-return': [2], + 'no-eq-null': [2], + 'no-eval': [2], + 'no-extend-native': [2], + 'no-extra-bind': [2], + 'no-global-assign': [2], + 'no-implied-eval': [2], + 'no-invalid-this': [2], + 'no-iterator': [2], + 'no-lone-blocks': [2], + 'no-loop-func': [2], + 'no-multi-spaces': [2], + 'no-multi-str': [2], + 'no-new': [2], + 'no-proto': [2], + 'no-return-assign': [2], + 'no-script-url': [2], + 'no-self-compare': [2], + 'no-sequences': [2], + 'no-throw-literal': [2], + 'no-unmodified-loop-condition': [2], + 'no-unused-expressions': [2], + 'no-useless-call': [2], + 'no-useless-concat': [2], + 'no-useless-escape': [2], + 'no-warning-comments': [0], + 'no-with': [2], + 'radix': [2], + 'wrap-iife': [2], + 'yoda': [2], + + // Variables + 'no-catch-shadow': [2], + 'no-shadow': [2], + 'no-undefined': [2], + 'no-use-before-define': [2], + + // Strict + 'strict': [2, 'never'], + + // Style + 'array-bracket-spacing': [2, 'never'], + 'block-spacing': [2, 'always'], + 'brace-style': [2], + 'camelcase': [2, { + properties: 'never' + }], + 'comma-dangle': [2, 'never'], + 'comma-spacing': [2], + 'comma-style': [2], + 'eol-last': [2, 'always'], + 'func-call-spacing': [2, 'never'], + 'indent': [2, 4], + 'jsx-quotes': [2, 'prefer-double'], + 'key-spacing': [2, { + beforeColon: false, + afterColon: true, + mode: 'strict' + }], + 'keyword-spacing': [2, { + before: true, + after: true + }], + 'linebreak-style': [2, 'unix'], + 'max-len': [2, { + code: 120, + tabWidth: 4, + ignoreUrls: true + }], + 'new-parens': [2], + 'newline-per-chained-call': [2], + 'no-lonely-if': [2], + 'no-multiple-empty-lines': [2, { + max: 2, + maxBOF: 0, + maxEOF: 0 + }], + 'no-negated-condition': [2], + 'no-tabs': [2], + 'no-trailing-spaces': [2, {skipBlankLines: true}], + 'no-unneeded-ternary': [2], + 'object-curly-spacing': [2], + 'object-property-newline': [2, { + allowMultiplePropertiesPerLine: true + }], + 'one-var': [2, 'never'], + 'operator-linebreak': [2, 'after'], + 'quote-props': [2, 'consistent-as-needed'], + 'quotes': [2, 'single', { + allowTemplateLiterals: true, + avoidEscape: true + }], + 'require-jsdoc': [2], + 'semi': [2, 'always'], + 'semi-spacing': [2], + 'space-before-function-paren': [2, 'always'], + 'space-in-parens': [2], + 'space-infix-ops': [2], + 'space-unary-ops': [2], + 'spaced-comment': [2], + 'arrow-body-style': [2, 'as-needed'], + 'arrow-parens': [2, 'as-needed'], + 'arrow-spacing': [2, { + before: true, + after: true + }], + 'no-prototype-builtins': [2], + 'no-confusing-arrow': [2], + 'no-duplicate-imports': [2], + 'no-return-await': [2], + 'no-template-curly-in-string': [2], + 'no-useless-computed-key': [2], + 'no-useless-constructor': [2], + 'no-useless-rename': [2], + 'no-var': [2], + 'prefer-arrow-callback': [2], + 'prefer-const': [2, {destructuring: 'all'}], + 'prefer-promise-reject-errors': [2], + 'prefer-rest-params': [2], + 'prefer-spread': [2], + 'prefer-template': [2], + 'require-atomic-updates': [2], + 'require-await': [2], + 'rest-spread-spacing': [2, 'never'], + 'symbol-description': [2], + 'template-curly-spacing': [2, 'never'], + + '@typescript-eslint/no-explicit-any': [0], + } +}; diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index d2a0127..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "plugins": [ - "@typescript-eslint", - "solid" - ], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended", - "plugin:solid/typescript" - ], - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module", - "ecmaFeatures": { - "jsx": true - } - }, - "rules": { - "@typescript-eslint/no-explicit-any": "off", - "semi": "error", - "indent": [ - "error", - 4, - { - "SwitchCase": 1 - } - ], - "dot-notation": "error", - "block-scoped-var": "error", - "capitalized-comments": "warn", - "eqeqeq": "error", - "no-confusing-arrow": [ - "error" - ], - "no-else-return": "error", - "no-lonely-if": "error", - "no-useless-constructor": "error", - "no-useless-return": "error", - "no-var": "error", - "comma-spacing": "error", - "func-call-spacing": "error", - "space-before-function-paren": "error", - "dot-location": [ - "error", - "property" - ], - "no-whitespace-before-property": "error", - "space-unary-ops": [ - "error", - { - "words": true, - "nonwords": false - } - ], - "quotes": [ - 2, - "single", - { - "allowTemplateLiterals": true, - "avoidEscape": true - } - ], - "no-unneeded-ternary": [ - 2 - ], - "eol-last": [ - 2, - "always" - ], - "key-spacing": [ - 2, - { - "beforeColon": false, - "afterColon": true, - "mode": "strict" - } - ], - "keyword-spacing": [ - 2, - { - "before": true, - "after": true - } - ] - } -} diff --git a/package.json b/package.json index 209cd9e..97c88e9 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "dev": "rollup -wc", "clean": "del-cli dist", "typecheck": "tsc --noEmit", - "lint": "eslint --ext .ts,.tsx .", - "lint:fix": "eslint --ext .ts,.tsx --fix .", + "lint": "eslint --ext .ts,.tsx ./src/", + "lint:fix": "eslint --ext .ts,.tsx --fix ./src/", "build:js": "rollup -c", "build": "cross-env NODE_ENV=production run-s typecheck clean build:js", "test": "jest --config jest.config.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f931f3..53b6261 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1529,6 +1529,10 @@ packages: resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/scope-manager@8.16.0': + resolution: {integrity: sha512-mwsZWubQvBki2t5565uxF0EYvG+FwdFb8bMtDuGQLdCCnGPrDEDvm1gtfynuKlnpzeBRqdFCkMf9jg1fnAK8sg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.18.0': resolution: {integrity: sha512-PNGcHop0jkK2WVYGotk/hxj+UFLhXtGPiGtiaWgVBVP1jhMoMCHlTyJA+hEj4rszoSdLTK3fN4oOatrL0Cp+Xw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1554,6 +1558,10 @@ packages: resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/types@8.16.0': + resolution: {integrity: sha512-NzrHj6thBAOSE4d9bsuRNMvk+BvaQvmY4dDglgkgGC0EW/tB3Kelnp3tAKH87GEwzoxgeQn9fNGRyFJM/xd+GQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.18.0': resolution: {integrity: sha512-FNYxgyTCAnFwTrzpBGq+zrnoTO4x0c1CKYY5MuUTzpScqmY5fmsh2o3+57lqdI3NZucBDCzDgdEbIaNfAjAHQA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1567,6 +1575,15 @@ packages: typescript: optional: true + '@typescript-eslint/typescript-estree@8.16.0': + resolution: {integrity: sha512-E2+9IzzXMc1iaBy9zmo+UYvluE3TW7bCGWSF41hVWUE01o8nzr1rvOQYSxelxr6StUvRcTMe633eY8mXASMaNw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@typescript-eslint/typescript-estree@8.18.0': resolution: {integrity: sha512-rqQgFRu6yPkauz+ms3nQpohwejS8bvgbPyIDq13cgEDbkXt4LH4OkDMT0/fN1RUtzG8e8AKJyDBoocuQh8qNeg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1579,6 +1596,16 @@ packages: peerDependencies: eslint: ^7.0.0 || ^8.0.0 + '@typescript-eslint/utils@8.16.0': + resolution: {integrity: sha512-C1zRy/mOL8Pj157GiX4kaw7iyRLKfJXBR3L82hk5kS/GyHcOFmy4YUq/zfZti72I9wnuQtA/+xzft4wCC8PJdA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@typescript-eslint/utils@8.18.0': resolution: {integrity: sha512-p6GLdY383i7h5b0Qrfbix3Vc3+J2k6QWw6UMUeY5JGfm3C5LbZ4QIZzJNoNOfgyRe0uuYKjvVOsO/jD4SJO+xg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1590,6 +1617,10 @@ packages: resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/visitor-keys@8.16.0': + resolution: {integrity: sha512-pq19gbaMOmFE3CbL0ZB8J8BFCo2ckfHBfaIsaOZgBIF4EoISJIdLX5xRhd0FGB0LlHReNRuzoJoMGpTjq8F2CQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.18.0': resolution: {integrity: sha512-pCh/qEA8Lb1wVIqNvBke8UaRjJ6wrAWkJO5yyIbs8Yx6TNGYyfNjOo61tLv+WwLvoLPp4BQ8B7AHKijl8NGUfw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3223,8 +3254,8 @@ packages: magic-string@0.30.12: resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==} - magic-string@0.30.14: - resolution: {integrity: sha512-5c99P1WKTed11ZC0HMJOj6CDIue6F8ySu+bJL+85q1zBEIY8IklrJ1eiKC2NDRh3Ct3FcvmJPyQHb9erXMTJNw==} + magic-string@0.30.15: + resolution: {integrity: sha512-zXeaYRgZ6ldS1RJJUrMrYgNJ4fdwnyI6tVqoiIhyCyv5IVTK9BU8Ic2l253GGETQHxI4HNUwhJ3fjDhKqEoaAw==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -6087,6 +6118,11 @@ snapshots: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 + '@typescript-eslint/scope-manager@8.16.0': + dependencies: + '@typescript-eslint/types': 8.16.0 + '@typescript-eslint/visitor-keys': 8.16.0 + '@typescript-eslint/scope-manager@8.18.0': dependencies: '@typescript-eslint/types': 8.18.0 @@ -6117,6 +6153,8 @@ snapshots: '@typescript-eslint/types@6.21.0': {} + '@typescript-eslint/types@8.16.0': {} + '@typescript-eslint/types@8.18.0': {} '@typescript-eslint/typescript-estree@6.21.0(typescript@5.7.2)': @@ -6134,6 +6172,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.16.0(typescript@5.7.2)': + dependencies: + '@typescript-eslint/types': 8.16.0 + '@typescript-eslint/visitor-keys': 8.16.0 + debug: 4.3.7 + fast-glob: 3.3.2 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 1.4.0(typescript@5.7.2) + optionalDependencies: + typescript: 5.7.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/typescript-estree@8.18.0(typescript@5.7.2)': dependencies: '@typescript-eslint/types': 8.18.0 @@ -6162,6 +6215,18 @@ snapshots: - supports-color - typescript + '@typescript-eslint/utils@8.16.0(eslint@8.57.1)(typescript@5.7.2)': + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.16.0 + '@typescript-eslint/types': 8.16.0 + '@typescript-eslint/typescript-estree': 8.16.0(typescript@5.7.2) + eslint: 8.57.1 + optionalDependencies: + typescript: 5.7.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.18.0(eslint@8.57.1)(typescript@5.7.2)': dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) @@ -6178,6 +6243,11 @@ snapshots: '@typescript-eslint/types': 6.21.0 eslint-visitor-keys: 3.4.3 + '@typescript-eslint/visitor-keys@8.16.0': + dependencies: + '@typescript-eslint/types': 8.16.0 + eslint-visitor-keys: 4.2.0 + '@typescript-eslint/visitor-keys@8.18.0': dependencies: '@typescript-eslint/types': 8.18.0 @@ -6236,7 +6306,7 @@ snapshots: chokidar: 3.6.0 colorette: 2.0.20 consola: 3.2.3 - magic-string: 0.30.14 + magic-string: 0.30.15 pathe: 1.1.2 perfect-debounce: 1.0.0 tinyglobby: 0.2.10 @@ -6408,7 +6478,7 @@ snapshots: '@unocss/rule-utils@0.65.1': dependencies: '@unocss/core': 0.65.1 - magic-string: 0.30.14 + magic-string: 0.30.15 '@unocss/scope@0.58.9': {} @@ -6481,7 +6551,7 @@ snapshots: '@unocss/core': 0.65.1 '@unocss/inspector': 0.65.1(vue@3.5.12(typescript@5.7.2)) chokidar: 3.6.0 - magic-string: 0.30.14 + magic-string: 0.30.15 tinyglobby: 0.2.10 vite: 5.4.10(@types/node@22.9.1)(terser@5.36.0) transitivePeerDependencies: @@ -6512,7 +6582,7 @@ snapshots: '@vue/compiler-ssr': 3.5.12 '@vue/shared': 3.5.12 estree-walker: 2.0.2 - magic-string: 0.30.14 + magic-string: 0.30.12 postcss: 8.4.47 source-map-js: 1.2.1 @@ -7298,7 +7368,7 @@ snapshots: eslint-plugin-solid@0.14.5(eslint@8.57.1)(typescript@5.7.2): dependencies: - '@typescript-eslint/utils': 8.18.0(eslint@8.57.1)(typescript@5.7.2) + '@typescript-eslint/utils': 8.16.0(eslint@8.57.1)(typescript@5.7.2) eslint: 8.57.1 estraverse: 5.3.0 is-html: 2.0.0 @@ -8312,7 +8382,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 - magic-string@0.30.14: + magic-string@0.30.15: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 diff --git a/rollup.config.mjs b/rollup.config.mjs index 82c2ebf..19d78f8 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -10,6 +10,7 @@ const { packageJson } = await readPackageUp(); export default defineConfig( Object.entries({ 'eureka': 'src/main/index.ts', + 'eureka-meta': 'src/main/placeholder.ts', }).map(([name, entry]) => ({ input: entry, plugins: [ @@ -30,7 +31,7 @@ export default defineConfig( process.env.ROLLUP_WATCH ? serve('dist') : undefined ], output: { - format: 'iife', + format: name === 'eureka-meta' ? 'es' : 'iife', file: `dist/${name}.user.js`, indent: false, }, diff --git a/src/main/ctx.ts b/src/main/ctx.ts index b42fb9b..35a7eab 100644 --- a/src/main/ctx.ts +++ b/src/main/ctx.ts @@ -1,4 +1,4 @@ -import { version } from '../../package.json'; +import {version} from '../../package.json'; export const eureka: EurekaContext = { declaredIds: [], diff --git a/src/main/index.ts b/src/main/index.ts index 02556da..272b3f5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,15 +1,14 @@ import './meta.js?userscript-metadata'; import log from './util/console'; import settingsAgent from './util/settings'; -import { version } from '../../package.json'; -import { getVMInstance } from './trap/vm'; -import { getScratchBlocksInstance } from './trap/blocks'; -import './util/l10n'; +import {version} from '../../package.json'; +import {getVMInstance} from './trap/vm'; +import {getScratchBlocksInstance} from './trap/blocks'; import formatMessage from 'format-message'; -import { eureka } from './ctx'; -import { applyPatchesForVM, applyPatchesForBlocks } from './patches/applier'; -import { setLocale } from './util/l10n'; -import { getRedux, getReduxStoreFromDOM } from './trap/redux'; +import {eureka} from './ctx'; +import {applyPatchesForVM, applyPatchesForBlocks} from './patches/applier'; +import {setLocale} from './util/l10n'; +import {getRedux, getReduxStoreFromDOM} from './trap/redux'; import './dashboard/app'; log.info( @@ -28,10 +27,10 @@ let vmTrapped = false; const trapViaBind = async () => { if (settings.trap.vm) { try { - const vm = eureka.vm = await getVMInstance().then(vm => { + const vm = eureka.vm = await getVMInstance().then(trappedVM => { vmTrapped = true; if (settings.trap.blocks) { - getScratchBlocksInstance(vm).then(blocks => { + getScratchBlocksInstance(trappedVM).then(blocks => { eureka.blocks = blocks; log.info( formatMessage({ @@ -39,23 +38,25 @@ const trapViaBind = async () => { default: 'ScratchBlocks is ready.' }) ); - if (settings.behavior.polyfillGlobalInstances && typeof globalThis.ScratchBlocks === 'undefined') { + if (settings.behavior.polyfillGlobalInstances && + typeof globalThis.ScratchBlocks === 'undefined') { globalThis.ScratchBlocks = eureka.blocks; } if (!settings.behavior.headless) { applyPatchesForBlocks(eureka.blocks); } - }).catch(e => { - log.error( - formatMessage({ - id: 'eureka.failedToGetBlocks', - default: 'Failed to get ScratchBlocks.' - }) - , '\n', e); - }); + }) + .catch(e => { + log.error( + formatMessage({ + id: 'eureka.failedToGetBlocks', + default: 'Failed to get ScratchBlocks.' + }) + , '\n', e); + }); } - return vm; + return trappedVM; }); if (settings.behavior.polyfillGlobalInstances && typeof globalThis.vm === 'undefined') { globalThis.vm = vm; @@ -132,7 +133,7 @@ const trapRedux = async () => { }; // Second trap - Using React internal Redux store -const trapViaReduxStore = async () => { +const trapViaReduxStore = () => { if (vmTrapped) return; try { const store = getReduxStoreFromDOM(); @@ -160,14 +161,15 @@ const trapViaReduxStore = async () => { if (!settings.behavior.headless) { applyPatchesForBlocks(eureka.blocks); } - }).catch(e => { - log.error( - formatMessage({ - id: 'eureka.failedToGetBlocks', - default: 'Failed to get ScratchBlocks.' - }) - , '\n', e); - }); + }) + .catch(e => { + log.error( + formatMessage({ + id: 'eureka.failedToGetBlocks', + default: 'Failed to get ScratchBlocks.' + }) + , '\n', e); + }); } if (settings.behavior.polyfillGlobalInstances && typeof globalThis.vm === 'undefined') { @@ -200,6 +202,7 @@ const trapViaReduxStore = async () => { } }; +// eslint-disable-next-line no-negated-condition if (document.readyState !== 'complete') { // Run both traps with race condition trapViaBind(); diff --git a/src/main/middleware/extension-metadata.ts b/src/main/middleware/extension-metadata.ts index 832f346..f5f2cad 100644 --- a/src/main/middleware/extension-metadata.ts +++ b/src/main/middleware/extension-metadata.ts @@ -148,95 +148,46 @@ export interface BlockArgs { */ export type FormattableString = string; -/** - * Standard Scratch extension class. - * Based on LLK's example https://github.com/LLK/scratch-vm/blob/develop/docs/extensions.md - */ -export interface StandardScratchExtensionClass { - new?: (runtime: object) => void; - /** - * Scratch will call this method *once* when the extension loads. - * This method's job is to tell Scratch things like the extension's ID, name, and what blocks it supports. - */ - getInfo: () => ExtensionMetadata; - [propName: string]: unknown; +export interface ExtensionMenu { + acceptReporters?: boolean; + items: MenuItems; } /** - * All the metadata needed to register an extension. + * @deprecated only preserved, no practical use */ -export interface ExtensionMetadata { - /** - * A unique alphanumeric identifier for this extension. No special characters allowed. - */ - id: string; - /** - * The human-readable name of this extension. - * Defaults to ID if not specified. - */ - name?: string; - showStatusButton?: boolean; - /** - * URI for an image to be placed on each block in this extension. - * Should be a data: URI - */ - blockIconURI?: string; - /** - * URI for an image to be placed on this extension's category menu item. - * Should be a data: URI - */ - menuIconURI?: string; - /** - * Link to documentation content for this extension - */ - docsURI?: string; - /** - * Should be a hex color code. - */ - color1?: `#${string}`; - /** - * Should be a hex color code. - */ - color2?: `#${string}`; +export interface CustomFieldType { + extendedName: string; + implementation: unknown; +} + +export interface ExtensionArgumentMetadata { /** - * Should be a hex color code. + * The type of the argument (number, string, etc.) */ - color3?: `#${string}`; + type: ArgumentType; /** - * The blocks provided by this extension, plus separators + * The default value of this argument */ - blocks: (ExtensionBlockMetadata | string)[]; + defaultValue?: unknown; /** - * Map of menu name to metadata for each of this extension's menus. + * The name of the menu to use for this argument, if any. */ - menus?: Record; + menu?: string; /** - * @deprecated only preserved, no practical use + * Only available when type is INLINE_IMAGE */ - customFieldTypes?: Record; + dataURI?: string; /** - * Translation maps - * @deprecated only exists in documentation, not implemented + * 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. */ - translation_map?: Record>; + flipRTL?: boolean; /** - * Target types - * @deprecated only exists in documentation, not implemented + * Only available when type is INLINE_IMAGE */ - targetTypes?: string[]; -} - -export interface ExtensionMenu { - acceptReporters?: boolean; - items: MenuItems; -} - -/** - * @deprecated only preserved, no practical use - */ -export interface CustomFieldType { - extendedName: string; - implementation: unknown; + alt?: string; } /** @@ -325,30 +276,80 @@ export interface ExtensionBlockMetadata { extensions?: string[]; } -export interface ExtensionArgumentMetadata { +/** + * All the metadata needed to register an extension. + */ +export interface ExtensionMetadata { /** - * The type of the argument (number, string, etc.) + * A unique alphanumeric identifier for this extension. No special characters allowed. */ - type: ArgumentType; + id: string; /** - * The default value of this argument + * The human-readable name of this extension. + * Defaults to ID if not specified. */ - defaultValue?: unknown; + name?: string; + showStatusButton?: boolean; /** - * The name of the menu to use for this argument, if any. + * URI for an image to be placed on each block in this extension. + * Should be a data: URI */ - menu?: string; + blockIconURI?: string; /** - * Only available when type is INLINE_IMAGE + * URI for an image to be placed on this extension's category menu item. + * Should be a data: URI */ - dataURI?: string; + menuIconURI?: 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. + * Link to documentation content for this extension */ - flipRTL?: boolean; + docsURI?: string; /** - * Only available when type is INLINE_IMAGE + * Should be a hex color code. */ - alt?: string; + color1?: `#${string}`; + /** + * Should be a hex color code. + */ + color2?: `#${string}`; + /** + * Should be a hex color code. + */ + color3?: `#${string}`; + /** + * The blocks provided by this extension, plus separators + */ + blocks: (ExtensionBlockMetadata | string)[]; + /** + * Map of menu name to metadata for each of this extension's menus. + */ + menus?: Record; + /** + * @deprecated only preserved, no practical use + */ + customFieldTypes?: Record; + /** + * Translation maps + * @deprecated only exists in documentation, not implemented + */ + translation_map?: Record>; + /** + * Target types + * @deprecated only exists in documentation, not implemented + */ + targetTypes?: string[]; +} + +/** + * Standard Scratch extension class. + * Based on LLK's example https://github.com/LLK/scratch-vm/blob/develop/docs/extensions.md + */ +export interface StandardScratchExtensionClass { + new?: (runtime: object) => void; + /** + * Scratch will call this method *once* when the extension loads. + * This method's job is to tell Scratch things like the extension's ID, name, and what blocks it supports. + */ + getInfo: () => ExtensionMetadata; + [propName: string]: unknown; } diff --git a/src/main/middleware/index.ts b/src/main/middleware/index.ts index 19391db..c6051b4 100644 --- a/src/main/middleware/index.ts +++ b/src/main/middleware/index.ts @@ -1,12 +1,128 @@ -import formatMessage, { Message } from 'format-message'; -import { Cast } from '../util/cast'; -import { eureka } from '../ctx'; -import { ArgumentType, BlockType, TargetType, ReporterScope, StandardScratchExtensionClass, ExtensionMetadata, ExtensionBlockMetadata, BlockArgs, ExtensionMenu, MenuItems } from './extension-metadata'; +import formatMessage, {Message} from 'format-message'; +import {Cast} from '../util/cast'; +import {eureka} from '../ctx'; +import { + ArgumentType, + BlockType, + TargetType, + ReporterScope, + StandardScratchExtensionClass, + ExtensionMetadata, + ExtensionBlockMetadata, + BlockArgs, + ExtensionMenu, + MenuItems +} from './extension-metadata'; import log from '../util/console'; -import { getScratchBlocksInstance } from '../trap/blocks'; -import { maybeFormatMessage } from '../util/maybe-format-message'; +import {getScratchBlocksInstance} from '../trap/blocks'; +import {maybeFormatMessage} from '../util/maybe-format-message'; -export const loadedExtensions = new Map(); +/** + * I10n support for Eureka extensions. + * @param vm Virtual machine instance. Optional. + * @returns Something like Scratch.translate. + */ +function createTranslate (vm: DucktypedVM) { + const namespace = formatMessage.namespace(); + + const translate = (message: Message, args?: object) => { + if (message && typeof message === 'object') { + // Already in the expected format + } else if (typeof message === 'string') { + message = { + default: message + }; + } else { + throw new Error('unsupported data type in translate()'); + } + return namespace(message, args); + }; + + const generateId = (defaultMessage: string) => `_${defaultMessage}`; + + let currentLocale = vm.getLocale(); + + const getLocale = () => currentLocale; + + let storedTranslations = {}; + translate.setup = (newTranslations: Message | object | null) => { + if (newTranslations) { + storedTranslations = newTranslations; + } + namespace.setup({ + locale: getLocale(), + missingTranslation: 'ignore', + generateId, + translations: storedTranslations + }); + }; + + translate.setup({}); + + if (vm) { + vm.on('LOCALE_CHANGED', (locale: string) => { + currentLocale = locale; + translate.setup(null); + }); + } + + // TurboWarp/scratch-vm@24b6036 + Object.defineProperty(translate, 'language', { + configurable: true, + enumerable: true, + get: () => getLocale() + }); + + return translate; +} + +interface LoadedExtensionInfo { + extension: StandardScratchExtensionClass; + info: ExtensionMetadata; +} + +/** + * Scratch 3's primitive Scratch object. + */ +interface BaseScratchObject { + ArgumentType: typeof ArgumentType; + BlockType: typeof BlockType; + TargetType: typeof TargetType; + ReporterScope: typeof ReporterScope; + extensions: { + register: (extensionObj: StandardScratchExtensionClass) => void; + }; +} + +interface ExtendedScratchObject extends BaseScratchObject { + Cast: Cast; + extensions: BaseScratchObject['extensions'] & { + unsandboxed: boolean; + chibi: true; + eureka: true; + } + vm: DucktypedVM; + translate: ReturnType; + renderer?: any; + fetch: typeof fetch; + canFetch(url: string): Promise; + canEmbed(url: string): Promise; + canOpenWindow(url: string): Promise; + canRedirect(url: string): Promise; + canRecordAudio(): Promise; + canRecordVideo(): Promise; + canReadClipboard(): Promise; + canNotify(): Promise; + canGeolocate(): Promise; + openWindow(url: string, features?: string): Promise; + redirect(url: string): Promise; + gui: { + getBlockly: () => Promise; + getBlocklyEagerly: () => typeof Blockly | null; + } +} + +export const loadedExtensions = new Map(); interface ExtensionContainer extends HTMLScriptElement { Scratch?: ExtendedScratchObject; @@ -21,102 +137,55 @@ export const predefinedCallbackKeys = [ 'CREATE_VARIABLE' ]; -export async function forwardedLoadExtensionURL (url: string) { - const res = await fetch(url, { - cache: 'no-cache' - }); - const code = await res.text(); - return new Promise((resolve, reject) => { - const elem: ExtensionContainer = document.createElement('script'); - const scratchObj = makeScratchObject(eureka); - scratchObj.extensions.register = function (extensionObj) { - const info = prepareExtensionInfo(extensionObj, extensionObj.getInfo()); - eureka.vm.runtime._registerExtensionPrimitives(info); - loadedExtensions.set(url, { extension: extensionObj, info }); - - eureka.declaredIds.push(info.id); - eureka.idToURLMapping.set(info.id, url); - - // Dispose temporary extension container - URL.revokeObjectURL(src); - document.head.removeChild(elem); - resolve(); - }; - const src = URL.createObjectURL( - new Blob( - [ - ` /** - * Generated by Eureka + * Get the menu items via the extension object. + * @param extensionObject the extension object + * @param menuItemFunctionName the function name to get the menu items + * @returns the menu items */ -let Scratch = document.getElementById('eureka-extension')?.Scratch; -${code} -//# sourceURL=${url} -` - ], - { type: 'text/javascript' } - ) - ); - elem.defer = true; - elem.Scratch = scratchObj; - elem.type = 'module'; // Experimental ESM support - elem.src = src; - elem.id = `eureka-extension`; - document.head.appendChild(elem); - elem.addEventListener('error', (err) => { - URL.revokeObjectURL(src); - document.head.removeChild(elem); - reject(err.error); - }); +function getExtensionMenuItems ( + extensionObject: StandardScratchExtensionClass, + 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 = + eureka.vm.runtime.getEditingTarget() || eureka.vm.runtime.getTargetForStage(); + const editingTargetID = editingTarget ? editingTarget.id : null; + eureka.vm.runtime.makeMessageContextForTarget(editingTarget); + + // TODO: Fix this to use dispatch.call when extensions are running in workers. + const menuFunc = extensionObject[menuItemFunctionName] as ( + id: string | null + ) => MenuItems; + const menuItems = menuFunc.call(extensionObject, editingTargetID).map(item => { + item = maybeFormatMessage(item)!; + switch (typeof item) { + case 'object': + return [maybeFormatMessage(item.text), item.value]; + case 'string': + return [item, item]; + default: + return item; + } }); -} -function prepareExtensionInfo (extensionObject: StandardScratchExtensionClass, info: ExtensionMetadata) { - info = Object.assign({}, info); - if (!/^[a-z0-9]+$/i.test(info.id)) { - throw new Error('Invalid extension id'); + if (!menuItems || menuItems.length < 1) { + throw new Error(`Extension menu returned no items: ${menuItemFunctionName}`); } - - info.name ??= info.id; - info.blocks ??= []; - info.targetTypes ??= []; - info.blocks = info.blocks.reduce( - (results: Array, blockInfo) => { - try { - let result; - switch (blockInfo) { - case '---': // Separator - result = '---'; - break; - default: // An ExtensionBlockMetadata object - result = prepareBlockInfo( - extensionObject, - blockInfo as ExtensionBlockMetadata - ); - break; - } - results.push(result); - } catch (e: unknown) { - // TODO: more meaningful error reporting - log.error( - `Error processing block: ${(e as Error).message}, Block:\n${JSON.stringify( - blockInfo - )}` - ); - } - return results; - }, - [] - ); - info.menus ??= {}; - info.menus = prepareMenuInfo( - extensionObject, - info.menus - ); - return info as ExtensionMetadata; + return menuItems; } +/** + * Prepare extension metadata for Scratch. + * @param extensionObject The extension object + * @param menus The extension menus + * @returns The prepared extension menus + */ function prepareMenuInfo ( extensionObject: StandardScratchExtensionClass, menus: Record @@ -144,6 +213,7 @@ function prepareMenuInfo ( if (typeof menuInfo.items === 'string') { const menuItemFunctionName = menuInfo.items; menuInfo.items = getExtensionMenuItems.bind( + // eslint-disable-next-line no-invalid-this this, extensionObject, menuItemFunctionName @@ -153,46 +223,21 @@ function prepareMenuInfo ( return menus; } -function getExtensionMenuItems ( - extensionObject: StandardScratchExtensionClass, - 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 = - eureka.vm.runtime.getEditingTarget() || eureka.vm.runtime.getTargetForStage(); - const editingTargetID = editingTarget ? editingTarget.id : null; - eureka.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)!; - switch (typeof item) { - case 'object': - return [maybeFormatMessage(item.text), 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; -} - +/** + * Sanitize an ID. + * @param text The text to sanitize + * @returns The sanitized ID + */ function sanitizeID (text: string) { return text.toString().replace(/[<"&]/, '_'); } +/** + * Prepare block info for Scratch. + * @param extensionObject The extension object + * @param blockInfo The block metadata + * @returns The prepared block metadata + */ function prepareBlockInfo (extensionObject: StandardScratchExtensionClass, blockInfo: ExtensionBlockMetadata) { blockInfo = Object.assign( {}, @@ -208,131 +253,146 @@ function prepareBlockInfo (extensionObject: StandardScratchExtensionClass, block 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}` - ); - } + case BlockType.EVENT: + if (blockInfo.func) { + log.warn( + `Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}` + ); + } + break; + case BlockType.BUTTON: { + if (!blockInfo.func) { break; - case BlockType.BUTTON: { - if (!blockInfo.func) { - break; - } - if (blockInfo.opcode) { - log.warn( - `Ignoring opcode "${blockInfo.opcode}" for button with text: ${blockInfo.text}` - ); - } - - if (predefinedCallbackKeys.includes(blockInfo.func)) { - break; - } + } + if (blockInfo.opcode) { + log.warn( + `Ignoring opcode "${blockInfo.opcode}" for button with text: ${blockInfo.text}` + ); + } - const funcName = blockInfo.func; - const buttonCallback = (() => { - if (!extensionObject[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 () => - // @ts-expect-error treat as callable - extensionObject[funcName](); - })(); - // @ts-expect-error internal hack - blockInfo.callFunc = buttonCallback; - blockInfo.func = funcName; + if (predefinedCallbackKeys.includes(blockInfo.func)) { break; } - case BlockType.LABEL: - if (blockInfo.opcode) { - log.warn( - `Ignoring opcode "${blockInfo.opcode}" for label with text: ${blockInfo.text}` - ); - } - break; - case BlockType.XML: - if (blockInfo.opcode) { - log.warn(`Ignoring opcode "${blockInfo.opcode}" for xml: ${blockInfo.xml}`); - } - break; - default: { - if (!blockInfo.opcode) { - throw new Error('Missing opcode for block'); - } - - const funcName = blockInfo.func - ? sanitizeID(blockInfo.func) - : blockInfo.opcode; - - const getBlockInfo = blockInfo.isDynamic - ? (args: BlockArgs) => args && args.mutation && args.mutation.blockInfo - : () => blockInfo; - const callBlockFunc = (() => { - if (!extensionObject[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: BlockArgs, util: DucktypedBlockUtility, realBlockInfo: unknown) => - // @ts-expect-error treat it as callable - extensionObject[funcName](args, util, realBlockInfo); - })(); + const funcName = blockInfo.func; + const buttonCallback = (() => { + if (!extensionObject[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 () => + // @ts-expect-error treat as callable + extensionObject[funcName](); + })(); // @ts-expect-error internal hack - blockInfo.func = (args: BlockArgs, util: DucktypedBlockUtility) => { - const realBlockInfo = getBlockInfo(args); - // TODO: filter args using the keys of realBlockInfo.arguments? maybe only if sandboxed? - return callBlockFunc(args, util, realBlockInfo); - }; - break; + blockInfo.callFunc = buttonCallback; + blockInfo.func = funcName; + break; + } + case BlockType.LABEL: + if (blockInfo.opcode) { + log.warn( + `Ignoring opcode "${blockInfo.opcode}" for label with text: ${blockInfo.text}` + ); + } + break; + case BlockType.XML: + if (blockInfo.opcode) { + log.warn(`Ignoring opcode "${blockInfo.opcode}" for xml: ${blockInfo.xml}`); + } + break; + default: { + if (!blockInfo.opcode) { + throw new Error('Missing opcode for block'); } + + const funcName = blockInfo.func ? + sanitizeID(blockInfo.func) : + blockInfo.opcode; + + const getBlockInfo = blockInfo.isDynamic ? + (args: BlockArgs) => args && args.mutation && args.mutation.blockInfo : + () => blockInfo; + const callBlockFunc = (() => { + if (!extensionObject[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: BlockArgs, util: DucktypedBlockUtility, realBlockInfo: unknown) => + // @ts-expect-error treat it as callable + extensionObject[funcName](args, util, realBlockInfo); + })(); + + // @ts-expect-error internal hack + blockInfo.func = (args: BlockArgs, util: DucktypedBlockUtility) => { + 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; } - /** - * Scratch 3's primitive Scratch object. + * Prepare extension metadata for Scratch. + * @param extensionObject The extension object + * @param info The extension metadata + * @returns The prepared extension metadata */ -interface BaseScratchObject { - ArgumentType: typeof ArgumentType; - BlockType: typeof BlockType; - TargetType: typeof TargetType; - ReporterScope: typeof ReporterScope; - extensions: { - register: (extensionObj: StandardScratchExtensionClass) => void; - }; -} - -interface ExtendedScratchObject extends BaseScratchObject { - Cast: Cast; - extensions: BaseScratchObject['extensions'] & { - unsandboxed: boolean; - chibi: true; - eureka: true; - } - vm: DucktypedVM; - translate: ReturnType; - renderer?: any; - fetch: typeof fetch; - canFetch(url: string): Promise; - canEmbed(url: string): Promise; - canOpenWindow(url: string): Promise; - canRedirect(url: string): Promise; - canRecordAudio(): Promise; - canRecordVideo(): Promise; - canReadClipboard(): Promise; - canNotify(): Promise; - canGeolocate(): Promise; - openWindow(url: string, features?: string): Promise; - redirect(url: string): Promise; - gui: { - getBlockly: () => Promise; - getBlocklyEagerly: () => typeof Blockly | null; +function prepareExtensionInfo (extensionObject: StandardScratchExtensionClass, info: ExtensionMetadata) { + info = Object.assign({}, info); + if (!/^[a-z0-9]+$/i.test(info.id)) { + throw new Error('Invalid extension id'); } + + info.name ??= info.id; + info.blocks ??= []; + info.targetTypes ??= []; + info.blocks = info.blocks.reduce( + (results: Array, blockInfo) => { + try { + let result; + switch (blockInfo) { + case '---': // Separator + result = '---'; + break; + default: // An ExtensionBlockMetadata object + result = prepareBlockInfo( + extensionObject, + blockInfo as ExtensionBlockMetadata + ); + break; + } + results.push(result); + } catch (e: unknown) { + // TODO: more meaningful error reporting + log.error( + `Error processing block: ${(e as Error).message}, Block:\n${JSON.stringify( + blockInfo + )}` + ); + } + return results; + }, + [] + ); + info.menus ??= {}; + info.menus = prepareMenuInfo( + extensionObject, + info.menus + ); + return info as ExtensionMetadata; } + +/** + * Trying parse a url, return null if failed. + * @param url the url to parse + * @returns the parsed URL, or null if failed + */ function parseURL (url: string) { try { return new URL(url, location.href); @@ -342,6 +402,11 @@ function parseURL (url: string) { } } +/** + * Make a Scratch object for extensions. + * @param ctx the Eureka context + * @returns the Scratch object + */ function makeScratchObject (ctx: EurekaContext): ExtendedScratchObject { return { ArgumentType, @@ -361,24 +426,19 @@ function makeScratchObject (ctx: EurekaContext): ExtendedScratchObject { renderer: ctx.vm.runtime.renderer, translate: createTranslate(ctx.vm), fetch: (url: URL | RequestInfo, options?: RequestInit | undefined) => fetch(url, options), - canFetch: async (url: string) => { - return !!parseURL(url); - }, - canEmbed: async (url: string) => { - return !!parseURL(url); - }, - canOpenWindow: async (url: string) => { - return parseURL(url)?.protocol !== 'javascript:'; - }, - canRedirect: async (url: string) => { - return parseURL(url)?.protocol !== 'javascript:'; - }, - canRecordAudio: async () => true, - canRecordVideo: async () => true, - canReadClipboard: async () => true, - canNotify: async () => true, - canGeolocate: async () => true, + canFetch: (url: string) => Promise.resolve(!!parseURL(url)), + canEmbed: (url: string) => Promise.resolve(!!parseURL(url)), + // eslint-disable-next-line no-script-url + canOpenWindow: (url: string) => Promise.resolve(parseURL(url)?.protocol !== 'javascript:'), + // eslint-disable-next-line no-script-url + canRedirect: (url: string) => Promise.resolve(parseURL(url)?.protocol !== 'javascript:'), + canRecordAudio: () => Promise.resolve(true), + canRecordVideo: () => Promise.resolve(true), + canReadClipboard: () => Promise.resolve(true), + canNotify: () => Promise.resolve(true), + canGeolocate: () => Promise.resolve(true), openWindow: async (url: string, features?: string) => { + // eslint-disable-next-line no-invalid-this if (!await this.canOpenWindow(url)) { throw new Error(`Permission to open tab ${url} rejected.`); } @@ -388,6 +448,7 @@ function makeScratchObject (ctx: EurekaContext): ExtendedScratchObject { return window.open(url, '_blank', features); }, redirect: async (url: string) => { + // eslint-disable-next-line no-invalid-this if (!await this.canRedirect(url)) { throw new Error(`Permission to redirect to ${url} rejected.`); } @@ -401,69 +462,69 @@ function makeScratchObject (ctx: EurekaContext): ExtendedScratchObject { } /** - * I10n support for Eureka extensions. - * @param vm Virtual machine instance. Optional. - * @returns Something like Scratch.translate. + * Refresh the forwarded blocks. */ -function createTranslate (vm: DucktypedVM) { - const namespace = formatMessage.namespace(); - - const translate = (message: Message, args?: object) => { - if (message && typeof message === 'object') { - // Already in the expected format - } else if (typeof message === 'string') { - message = { - default: message - }; - } else { - throw new Error('unsupported data type in translate()'); - } - return namespace(message, args); - }; - - const generateId = (defaultMessage: string) => `_${defaultMessage}`; - - let currentLocale = vm.getLocale(); +export function refreshForwardedBlocks () { + loadedExtensions.forEach(({extension}, url) => { + const info = prepareExtensionInfo(extension, extension.getInfo()); + eureka.vm.runtime._refreshExtensionPrimitives(info); + loadedExtensions.set(url, {extension, info}); + }); +} - const getLocale = () => currentLocale; +/** + * Load an extension from a URL in Eureka. + * @param url the URL to load the extension from + * @returns a promise that resolves when the extension is loaded + */ +export async function forwardedLoadExtensionURL (url: string) { + const res = await fetch(url, { + cache: 'no-cache' + }); + const code = await res.text(); + return new Promise((resolve, reject) => { + const elem: ExtensionContainer = document.createElement('script'); + const scratchObj = makeScratchObject(eureka); + const src = URL.createObjectURL( + new Blob( + [ + ` +/** + * Generated by Eureka + */ +let Scratch = document.getElementById('eureka-extension')?.Scratch; +${code} +//# sourceURL=${url} +` + ], + {type: 'text/javascript'} + ) + ); + scratchObj.extensions.register = function (extensionObj) { + const info = prepareExtensionInfo(extensionObj, extensionObj.getInfo()); + eureka.vm.runtime._registerExtensionPrimitives(info); - let storedTranslations = {}; - translate.setup = (newTranslations: Message | object | null) => { - if (newTranslations) { - storedTranslations = newTranslations; - } - namespace.setup({ - locale: getLocale(), - missingTranslation: 'ignore', - generateId, - translations: storedTranslations - }); - }; + loadedExtensions.set(url, {extension: extensionObj, info}); - translate.setup({}); + eureka.declaredIds.push(info.id); + eureka.idToURLMapping.set(info.id, url); - if (vm) { - vm.on('LOCALE_CHANGED', (locale: string) => { - currentLocale = locale; - translate.setup(null); + // Dispose temporary extension container + URL.revokeObjectURL(src); + document.head.removeChild(elem); + resolve(); + }; + elem.defer = true; + elem.Scratch = scratchObj; + elem.type = 'module'; // Experimental ESM support + elem.src = src; + elem.id = `eureka-extension`; + document.head.appendChild(elem); + elem.addEventListener('error', err => { + URL.revokeObjectURL(src); + document.head.removeChild(elem); + reject(err.error); }); - } - - // TurboWarp/scratch-vm@24b6036 - Object.defineProperty(translate, 'language', { - configurable: true, - enumerable: true, - get: () => getLocale() - }); - - return translate; -} - -export async function refreshForwardedBlocks () { - loadedExtensions.forEach(({ extension }, url) => { - const info = prepareExtensionInfo(extension, extension.getInfo()); - eureka.vm.runtime._refreshExtensionPrimitives(info); - loadedExtensions.set(url, { extension, info }); }); } diff --git a/src/main/patches/applier.ts b/src/main/patches/applier.ts index c55a909..9e4b18b 100644 --- a/src/main/patches/applier.ts +++ b/src/main/patches/applier.ts @@ -1,11 +1,16 @@ import log from '../util/console'; import settingsAgent from '../util/settings'; -import { MixinApplicator } from '../util/inject'; -import { injectToolbox } from './toolbox-stuffs'; -import { forwardedLoadExtensionURL, loadedExtensions, predefinedCallbackKeys, refreshForwardedBlocks } from '../middleware'; -import { BlockType } from '../middleware/extension-metadata'; +import {MixinApplicator} from '../util/inject'; +import {injectToolbox} from './toolbox-stuffs'; +import { + forwardedLoadExtensionURL, + loadedExtensions, + predefinedCallbackKeys, + refreshForwardedBlocks +} from '../middleware'; +import {BlockType} from '../middleware/extension-metadata'; import xmlEscape from '../util/xml-escape'; -import { maybeFormatMessage } from '../util/maybe-format-message'; +import {maybeFormatMessage} from '../util/maybe-format-message'; import * as l10n from '../util/l10n'; import formatMessage from 'format-message'; @@ -26,14 +31,14 @@ function isPromise (value: unknown) { const checkEureka = (eurekaFlag: string): boolean | null => { switch (eurekaFlag) { - case '🧐 Chibi?': - log.warn("'🧐 Chibi?' is deprecated, use '🧐 Eureka?' instead."); - return true; - case '🧐 Chibi Installed?': - log.warn("'🧐 Chibi Installed?' is deprecated, use '🧐 Eureka?' instead."); - return true; - case '🧐 Eureka?': - return true; + case '🧐 Chibi?': + log.warn("'🧐 Chibi?' is deprecated, use '🧐 Eureka?' instead."); + return true; + case '🧐 Chibi Installed?': + log.warn("'🧐 Chibi Installed?' is deprecated, use '🧐 Eureka?' instead."); + return true; + case '🧐 Eureka?': + return true; } return null; }; @@ -68,6 +73,10 @@ function getExtensionIdForOpcode (opcode: string): string { } } +/** + * Apply scratch-blocks-related patches. + * @param blocks The ScratchBlocks instance. + */ export function applyPatchesForBlocks (blocks?: DucktypedScratchBlocks) { // Add eureka's toolbox stuffs if (blocks) { @@ -83,6 +92,7 @@ export function applyPatchesForBlocks (blocks?: DucktypedScratchBlocks) { const toolboxCallbacks = blocks?.getMainWorkspace()?.toolboxCategoryCallbacks; const originalCallback = toolboxCallbacks.get('PROCEDURE'); toolboxCallbacks.set('PROCEDURE', function (workspace) { + // eslint-disable-next-line no-invalid-this const xmlList = originalCallback.call(this, workspace); injectToolbox(xmlList, workspace); @@ -99,6 +109,7 @@ export function applyPatchesForBlocks (blocks?: DucktypedScratchBlocks) { if (key === 'PROCEDURE') { const originalCallback = callback; callback = function (workspace) { + // eslint-disable-next-line no-invalid-this const xmlList = originalCallback.call(this, workspace); injectToolbox(xmlList, workspace); @@ -107,7 +118,7 @@ export function applyPatchesForBlocks (blocks?: DucktypedScratchBlocks) { } return originalMethod(key, callback); - }, + } } ); } @@ -121,7 +132,9 @@ export function applyPatchesForBlocks (blocks?: DucktypedScratchBlocks) { init (originalMethod) { originalMethod(); queueMicrotask(() => { - if (this.getFieldValue('VALUE') === '🧐 Eureka?' && !(this.dragStrategy instanceof blocks.dragging.BlockDragStrategy) && !this.isInFlyout) { + if (this.getFieldValue('VALUE') === '🧐 Eureka?' && + !(this.dragStrategy instanceof blocks.dragging.BlockDragStrategy) && + !this.isInFlyout) { this.setDragStrategy(new blocks.dragging.BlockDragStrategy(this)); this.dragStrategy.block?.dispose(); } @@ -168,6 +181,28 @@ export function applyPatchesForBlocks (blocks?: DucktypedScratchBlocks) { workspace.toolboxRefreshEnabled_ = true; } +/** + * Get unsupported API from a Turbowarp VM instance. + * @param vm The VM instance. + * @returns The unsupported API, if it exists. (otherwise null) + */ +function getUnsupportedAPI (vm: DucktypedVM) { + if (typeof vm.exports?.i_will_not_ask_for_help_when_these_break === 'function') { + // Do not emit any warning messages + const warn = console.warn; + console.warn = function () { }; // No-op + const api = vm.exports.i_will_not_ask_for_help_when_these_break(); + console.warn = warn; + return api; + } + return null; +} + +/** + * Apply VM-related patches. + * @param vm The VM instance. + * @param ctx The Eureka context. + */ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { if (settings.mixins['vm.extensionManager.loadExtensionURL']) { MixinApplicator.applyTo( @@ -178,16 +213,19 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { extensionURL = ctx.idToURLMapping.get(extensionURL)!; } - if (settings.behavior.redirectDeclared && ctx.declaredIds.includes(extensionURL) && !loadedExtensions.has(extensionURL)) { + if (settings.behavior.redirectDeclared && + ctx.declaredIds.includes(extensionURL) && + !loadedExtensions.has(extensionURL)) { log.info(formatMessage({ id: 'eureka.redirectingDeclared', default: 'Redirecting declared extension {extensionURL}' - }, { extensionURL })); + }, {extensionURL})); return forwardedLoadExtensionURL(extensionURL); } const isURL = (url: string) => { try { + // eslint-disable-next-line no-new new URL(url); return true; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -201,12 +239,12 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { log.info(formatMessage({ id: 'eureka.redirectingURL', default: 'Redirecting URL {extensionURL}' - }, { extensionURL })); + }, {extensionURL})); return forwardedLoadExtensionURL(extensionURL); } return originalMethod?.(extensionURL); - }, + } } ); } @@ -218,7 +256,7 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { async refreshBlocks (originalMethod) { const result = await originalMethod?.(); return [...result, await refreshForwardedBlocks()]; - }, + } } ); } @@ -291,7 +329,7 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { obj.sideloadExtensionURLs = extensionInfo; return JSON.stringify(obj); - }, + } } ); } @@ -301,8 +339,12 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { vm, { deserializeProject (originalMethod, projectJSON, zip, extensionCallback) { - const sideloadExtensionURLs: Record = typeof projectJSON.sideloadExtensionURLs === 'object' ? projectJSON.sideloadExtensionURLs as Record : {}; - const extensionURLs: Record = typeof projectJSON.extensionURLs === 'object' ? projectJSON.extensionURLs as Record : {}; + const sideloadExtensionURLs: Record = + typeof projectJSON.sideloadExtensionURLs === 'object' ? + projectJSON.sideloadExtensionURLs as Record : {}; + const extensionURLs: Record = + typeof projectJSON.extensionURLs === 'object' ? + projectJSON.extensionURLs as Record : {}; // Migrate from old eureka if (projectJSON.extensionEnvs) { @@ -343,18 +385,27 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { block.opcode = originalOpcode; try { - const mutation = typeof block.mutation.mutation === 'string' ? JSON.parse(block.mutation.mutation) : null; + const mutation = + typeof block.mutation.mutation === 'string' ? + JSON.parse(block.mutation.mutation) : null; if (mutation) { block.mutation = mutation; } else delete block.mutation; } catch (e) { log.error(formatMessage({ id: 'eureka.errorIgnored', - default: 'An error occurred while parsing the mutation of a sideload block, ignored. Error: {error}', + // eslint-disable-next-line max-len + default: 'An error occurred while parsing the mutation of a sideload block, ignored. Error: {error}' }), e); delete block.mutation; } - } else if ((getExtensionIdForOpcode(block.opcode) in sideloadExtensionURLs) || (typeof projectJSON.sideloadExtensionEnvs === 'object' && getExtensionIdForOpcode(block.opcode) in projectJSON.sideloadExtensionEnvs)) { + } else if ( + (getExtensionIdForOpcode(block.opcode) in sideloadExtensionURLs) || + ( + typeof projectJSON.sideloadExtensionEnvs === 'object' && + getExtensionIdForOpcode(block.opcode) in projectJSON.sideloadExtensionEnvs + ) + ) { const extensionId = getExtensionIdForOpcode(block.opcode); const url = sideloadExtensionURLs[extensionId] ?? extensionURLs[extensionId]; if (!url) { @@ -396,7 +447,7 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { } return originalMethod?.(projectJSON, zip, extensionCallback); - }, + } } ); } @@ -406,7 +457,7 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { MixinApplicator.applyTo( vm, { - async _loadExtensions (originalMethod, extensionIDs, extensionURLs) { + _loadExtensions (originalMethod, extensionIDs, extensionURLs) { const sideloadExtensionPromises: Promise[] = []; for (const extensionId of extensionIDs) { if (ctx.declaredIds.includes(extensionId)) { @@ -423,7 +474,7 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { ...sideloadExtensionPromises ]).then(); - }, + } } ); } @@ -436,7 +487,7 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { l10n.setLocale(locale); vm.emit('LOCALE_CHANGED', locale); return originalMethod?.(locale, messages); - }, + } } ); } @@ -456,7 +507,7 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { } // Since the param exists, assume the following checks will be skipped for performance purposes. return value; - }, + } } ); } @@ -469,21 +520,21 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { { descendInput (originalMethod, block) { switch (block.opcode) { - case 'argument_reporter_boolean': { - const name = block.fields.VALUE.value; - const index = this.script.arguments.lastIndexOf(name); - if (index === -1) { - if (checkEureka(name) !== null) { - return { - kind: 'constant', - value: true - }; - } + case 'argument_reporter_boolean': { + const name = block.fields.VALUE.value; + const index = this.script.arguments.lastIndexOf(name); + if (index === -1) { + if (checkEureka(name) !== null) { + return { + kind: 'constant', + value: true + }; } } } + } return originalMethod?.(block); - }, + } } ); } @@ -540,7 +591,7 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { beforeProjectSave ({projectData}: CCXSaveData) { // Create a Record of extension's id - extension's url from loadedExtensions const extensionInfo: Record = {}; - loadedExtensions.forEach(({ info }, url) => { + loadedExtensions.forEach(({info}, url) => { extensionInfo[info.id] = url; }); @@ -589,33 +640,35 @@ export function applyPatchesForVM (vm: DucktypedVM, ctx: EurekaContext) { _convertForScratchBlocks (originalMethod, blockInfo, categoryInfo) { if (typeof blockInfo !== 'string') { switch (blockInfo.blockType) { - case BlockType.LABEL: - return { - info: blockInfo, - xml: `