From af15d67cb36ce49d9df5ff1ad4d9defe3fad837e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 10 Jan 2025 17:56:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E9=83=A8=E5=88=86=E5=86=85?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yaml | 45 + .github/workflows/packageRelease.yml | 76 ++ .github/workflows/test.yaml | 30 + eslint/compat-grant.js | 260 ++++++ eslint/compat-headers.js | 201 ++++ eslint/linter-config.ts | 131 +++ example/cat_file_storage.js | 50 + example/cloudcat.js | 16 + example/error_retry.js | 18 + example/gm_add_element.js | 15 + example/gm_bg_menu.js | 18 + example/gm_clipboard.js | 11 + example/gm_cookie.js | 44 + example/gm_download.js | 20 + example/gm_get_resource.js | 17 + example/gm_log.js | 11 + example/gm_menu.js | 16 + example/gm_notification.js | 43 + example/gm_save_tab.js | 21 + example/gm_tab.js | 20 + example/gm_value.js | 32 + example/gm_xhr.js | 36 + example/userconfig.js | 92 ++ example/usersubscribe.user.sub.js | 8 + example/vscode.user.js | 13 + src/app/service/sandbox/index.ts | 18 +- src/app/service/sandbox/runtime.ts | 92 ++ src/pages/components/CodeEditor/index.tsx | 2 +- src/pages/components/layout/MainLayout.tsx | 4 +- src/pages/install/main.tsx | 2 +- src/pages/options/main.tsx | 2 +- src/pages/options/routes/ScriptList.tsx | 6 +- src/{ => pages}/store/features/script.ts | 0 src/{ => pages}/store/features/setting.ts | 0 src/{ => pages}/store/hooks.ts | 0 src/{ => pages}/store/store.ts | 0 src/pkg/utils/lodash.ts | 6 + src/runtime/background/gm_api.ts | 961 ++++++++++++++++++++ src/runtime/background/permission_verify.ts | 410 +++++++++ src/runtime/background/runtime.ts | 735 +++++++++++++++ src/runtime/background/utils.ts | 535 +++++++++++ src/runtime/content/content.ts | 145 +++ src/runtime/content/exec_script.test.ts | 120 +++ src/runtime/content/exec_script.ts | 68 ++ src/runtime/content/exec_warp.ts | 92 ++ src/runtime/content/gm_api.ts | 913 +++++++++++++++++++ src/runtime/content/inject.ts | 143 +++ src/runtime/content/runtime.ts | 51 ++ src/runtime/content/sandbox.ts | 328 +++++++ src/runtime/content/utils.test.ts | 101 ++ src/runtime/content/utils.ts | 349 +++++++ src/service_worker.ts | 4 +- 52 files changed, 6308 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/build.yaml create mode 100644 .github/workflows/packageRelease.yml create mode 100644 .github/workflows/test.yaml create mode 100644 eslint/compat-grant.js create mode 100644 eslint/compat-headers.js create mode 100644 eslint/linter-config.ts create mode 100644 example/cat_file_storage.js create mode 100644 example/cloudcat.js create mode 100644 example/error_retry.js create mode 100644 example/gm_add_element.js create mode 100644 example/gm_bg_menu.js create mode 100644 example/gm_clipboard.js create mode 100644 example/gm_cookie.js create mode 100644 example/gm_download.js create mode 100644 example/gm_get_resource.js create mode 100644 example/gm_log.js create mode 100644 example/gm_menu.js create mode 100644 example/gm_notification.js create mode 100644 example/gm_save_tab.js create mode 100644 example/gm_tab.js create mode 100644 example/gm_value.js create mode 100644 example/gm_xhr.js create mode 100644 example/userconfig.js create mode 100644 example/usersubscribe.user.sub.js create mode 100644 example/vscode.user.js create mode 100644 src/app/service/sandbox/runtime.ts rename src/{ => pages}/store/features/script.ts (100%) rename src/{ => pages}/store/features/setting.ts (100%) rename src/{ => pages}/store/hooks.ts (100%) rename src/{ => pages}/store/store.ts (100%) create mode 100644 src/pkg/utils/lodash.ts create mode 100644 src/runtime/background/gm_api.ts create mode 100644 src/runtime/background/permission_verify.ts create mode 100644 src/runtime/background/runtime.ts create mode 100644 src/runtime/background/utils.ts create mode 100644 src/runtime/content/content.ts create mode 100644 src/runtime/content/exec_script.test.ts create mode 100644 src/runtime/content/exec_script.ts create mode 100644 src/runtime/content/exec_warp.ts create mode 100644 src/runtime/content/gm_api.ts create mode 100644 src/runtime/content/inject.ts create mode 100644 src/runtime/content/runtime.ts create mode 100644 src/runtime/content/sandbox.ts create mode 100644 src/runtime/content/utils.test.ts create mode 100644 src/runtime/content/utils.ts diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..442bee9 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,45 @@ +name: build + +on: + push: + branches: + - main + - release/* + - dev + +jobs: + build-deploy: + runs-on: ubuntu-latest + name: Build + steps: + - uses: actions/checkout@v3 + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + cache: 'npm' + + - name: Package with Node + env: + CHROME_PEM: ${{ secrets.CHROME_PEM }} + run: | + mkdir dist + echo "$CHROME_PEM" > ./dist/scriptcat.pem + chmod 600 ./dist/scriptcat.pem + npm ci + npm run pack + + - name: Archive production artifacts + uses: actions/upload-artifact@v3 + with: + name: all-artifacts + path: | + dist/*.zip + dist/*.crx + + - name: Archive extension + uses: actions/upload-artifact@v3 + with: + name: scriptcat + path: | + dist/ext/* diff --git a/.github/workflows/packageRelease.yml b/.github/workflows/packageRelease.yml new file mode 100644 index 0000000..e889757 --- /dev/null +++ b/.github/workflows/packageRelease.yml @@ -0,0 +1,76 @@ +name: Auto_Package + +on: + push: + tags: + - "*" + workflow_dispatch: + +jobs: + build-deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + cache: 'npm' + + - name: Package with Node + env: + CHROME_PEM: ${{ secrets.CHROME_PEM }} + run: | + mkdir dist + echo "$CHROME_PEM" > ./dist/scriptcat.pem + chmod 600 ./dist/scriptcat.pem + npm ci + npm test + npm run pack + + - name: Create Release + id: create_release + uses: actions/create-release@latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token + with: + tag_name: ${{ github.ref }} + release_name: ${{ github.ref }} + body: | + 'no description' + draft: false + prerelease: false + + - name: Upload Release Asset zip + id: upload-release-asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./dist/scriptcat-${{ github.ref_name }}-chrome.zip + asset_name: scriptcat-${{ github.ref_name }}-chrome.zip + asset_content_type: application/zip + + - name: Upload FireFox Release Asset zip + id: upload-firefox-release-asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./dist/scriptcat-${{ github.ref_name }}-firefox.zip + asset_name: scriptcat-${{ github.ref_name }}-firefox.zip + asset_content_type: application/zip + + - name: Upload Crx Release Asset zip + id: upload-crx-release-asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./dist/scriptcat-${{ github.ref_name }}-chrome.crx + asset_name: scriptcat-${{ github.ref_name }}-chrome.crx + asset_content_type: application/zip \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..81ccbbd --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,30 @@ +name: test + +on: + push: + branches: + - main + - release/* + - dev + - develop/* + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + name: Run tests + steps: + - uses: actions/checkout@v3 + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + cache: 'npm' + + - name: Unit Test + run: | + npm ci + npm test + + - name: Upload coverage reports to Codecov with GitHub Action + uses: codecov/codecov-action@v3 diff --git a/eslint/compat-grant.js b/eslint/compat-grant.js new file mode 100644 index 0000000..d30d7c4 --- /dev/null +++ b/eslint/compat-grant.js @@ -0,0 +1,260 @@ +// Fork from eslint-plugin-userscripts +// Documentation: +// - Tampermonkey: https://www.tampermonkey.net/documentation.php#_grant +// - Violentmonkey: https://violentmonkey.github.io/api/gm +// - Greasemonkey: https://wiki.greasespot.net/Greasemonkey_Manual:API +// - ScriptCat: https://docs.scriptcat.org/docs/dev/cat-api/ +const compatMap = { + CAT_userConfig: [{ type: "scriptcat", versionConstraint: ">=0.11.0-beta" }], + CAT_fileStorage: [{ type: "scriptcat", versionConstraint: ">=0.11.0" }], + "GM.addElement": [ + { type: "tampermonkey", versionConstraint: ">=4.11.6113" }, + { type: "violentmonkey", versionConstraint: ">=2.13.0-beta.3" }, + ], + GM_addElement: [ + { type: "tampermonkey", versionConstraint: ">=4.11.6113" }, + { type: "violentmonkey", versionConstraint: ">=2.13.0-beta.3" }, + ], + "GM.addStyle": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + { type: "violentmonkey", versionConstraint: ">=2.12.0" }, + ], + GM_addStyle: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.6.1.4 <4" }, + ], + "GM.addValueChangeListener": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + ], + GM_addValueChangeListener: [ + { type: "tampermonkey", versionConstraint: ">=2.3.2607" }, + { type: "violentmonkey", versionConstraint: ">=2.12.0" }, + ], + "GM.cookie": [{ type: "tampermonkey", versionConstraint: ">=4.8" }], + GM_cookie: [{ type: "tampermonkey", versionConstraint: ">=4.8" }], + "GM.deleteValue": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + { type: "violentmonkey", versionConstraint: ">=2.12.0" }, + { type: "greasemonkey", versionConstraint: ">=4.0" }, + ], + GM_deleteValue: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.8.20090123.1 <4" }, + ], + "GM.download": [{ type: "tampermonkey", versionConstraint: ">=4.5" }], + GM_download: [ + { type: "tampermonkey", versionConstraint: ">=3.8" }, + { type: "violentmonkey", versionConstraint: ">=2.9.5" }, + ], + "GM.getResourceText": [{ type: "tampermonkey", versionConstraint: ">=4.5" }], + GM_getResourceText: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.8.20080609.0 <4" }, + ], + "GM.getResourceURL": [ + { type: "violentmonkey", versionConstraint: ">=2.12.0 <2.13.0.10" }, + ], + GM_getResourceURL: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.8.20080609.0 <4" }, + ], + "GM.getResourceUrl": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + { type: "violentmonkey", versionConstraint: ">=2.13.0.10" }, + { type: "greasemonkey", versionConstraint: ">=4.0" }, + ], + "GM.getTab": [{ type: "tampermonkey", versionConstraint: ">=4.5" }], + GM_getTab: [{ type: "tampermonkey", versionConstraint: ">=4.0.10" }], + "GM.getTabs": [{ type: "tampermonkey", versionConstraint: ">=4.5" }], + GM_getTabs: [{ type: "tampermonkey", versionConstraint: ">=4.0.10" }], + "GM.getValue": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + { type: "violentmonkey", versionConstraint: ">=2.12.0" }, + { type: "greasemonkey", versionConstraint: ">=4.0" }, + ], + GM_getValue: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.3-beta <4" }, + ], + "GM.info": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + { type: "violentmonkey", versionConstraint: ">=2.12.0" }, + { type: "greasemonkey", versionConstraint: ">=4" }, + ], + GM_info: [ + { type: "tampermonkey", versionConstraint: ">=2.4.2718" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.9.16 <4" }, + ], + "GM.listValues": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + { type: "violentmonkey", versionConstraint: ">=2.12.0" }, + { type: "greasemonkey", versionConstraint: ">=4" }, + ], + GM_listValues: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.8.20090123.1 <4" }, + ], + "GM.log": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + { type: "greasemonkey", versionConstraint: ">=4" }, + ], + GM_log: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.3-beta <4" }, + ], + "GM.notification": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + { type: "violentmonkey", versionConstraint: ">=2.12.0" }, + { type: "greasemonkey", versionConstraint: ">=4" }, + ], + GM_notification: [ + { type: "tampermonkey", versionConstraint: ">=2.0.2344" }, + { type: "violentmonkey", versionConstraint: ">=2.5.0" }, + ], + "GM.openInTab": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + { type: "violentmonkey", versionConstraint: ">=2.12.0" }, + { type: "greasemonkey", versionConstraint: ">=4" }, + ], + GM_openInTab: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.5-beta <4" }, + ], + "GM.registerMenuCommand": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + { type: "violentmonkey", versionConstraint: ">=2.12.0" }, + { type: "greasemonkey", versionConstraint: ">=4.11" }, + ], + GM_registerMenuCommand: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.2.5 <4" }, + ], + "GM.removeValueChangeListener": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + ], + GM_removeValueChangeListener: [ + { type: "tampermonkey", versionConstraint: ">=2.3.2607" }, + { type: "violentmonkey", versionConstraint: ">=2.12.0" }, + ], + "GM.saveTab": [{ type: "tampermonkey", versionConstraint: ">=4.5" }], + GM_saveTab: [{ type: "tampermonkey", versionConstraint: ">=4.0.10" }], + "GM.setClipboard": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + { type: "violentmonkey", versionConstraint: ">=2.12.0" }, + { type: "greasemonkey", versionConstraint: ">=4" }, + ], + GM_setClipboard: [ + { type: "tampermonkey", versionConstraint: ">=2.6.2767" }, + { type: "violentmonkey", versionConstraint: ">=2.5.0" }, + { type: "greasemonkey", versionConstraint: ">=1.10 <4" }, + ], + "GM.setValue": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + { type: "violentmonkey", versionConstraint: ">=2.12.0" }, + { type: "greasemonkey", versionConstraint: ">=4" }, + ], + GM_setValue: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.3-beta <4" }, + ], + "GM.unregisterMenuCommand": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + ], + GM_unregisterMenuCommand: [ + { type: "tampermonkey", versionConstraint: ">=3.6.3737" }, + { type: "violentmonkey", versionConstraint: ">=2.9.4" }, + ], + "GM.webRequest": [{ type: "tampermonkey", versionConstraint: ">=4.5" }], + GM_webRequest: [{ type: "tampermonkey", versionConstraint: ">=4.4" }], + GM_xmlhttpRequest: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.2.5 <4" }, + ], + "GM.xmlHttpRequest": [ + { type: "tampermonkey", versionConstraint: ">=4.5" }, + { type: "violentmonkey", versionConstraint: ">=2.12.0" }, + { type: "greasemonkey", versionConstraint: ">=4.0" }, + ], + none: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: "*" }, + ], + unsafeWindow: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.5-beta" }, + ], + "window.close": [ + { type: "tampermonkey", versionConstraint: ">=3.12.58" }, + { type: "violentmonkey", versionConstraint: ">=2.6.2" }, + ], + "window.focus": [ + { type: "tampermonkey", versionConstraint: ">=3.12.58" }, + { type: "violentmonkey", versionConstraint: ">=2.12.10" }, + ], + "window.onurlchange": [{ type: "tampermonkey", versionConstraint: ">=4.11" }], +}; + +const gmPolyfillOverride = { + GM_addStyle: "ignore", + GM_registerMenuCommand: "ignore", + GM_getResourceText: { + deps: ["GM.getResourceUrl", "GM.log"], + }, + "GM.log": "ignore", + "GM.info": { + deps: ["GM_info"], + }, + "GM.addStyle": { + deps: ["GM_addStyle"], + }, + "GM.deleteValue": { + deps: ["GM_deleteValue"], + }, + "GM.getResourceUrl": { + deps: ["GM_getResourceURL"], + }, + "GM.getValue": { + deps: ["GM_getValue"], + }, + "GM.listValues": { + deps: ["GM_listValues"], + }, + "GM.notification": { + deps: ["GM_notification"], + }, + "GM.openInTab": { + deps: ["GM_openInTab"], + }, + "GM.registerMenuCommand": { + deps: ["GM_registerMenuCommand"], + }, + "GM.setClipboard": { + deps: ["GM_setClipboard"], + }, + "GM.setValue": { + deps: ["GM_setValue"], + }, + "GM.xmlHttpRequest": { + deps: ["GM_xmlhttpRequest"], + }, + "GM.getResourceText": { + deps: ["GM_getResourceText"], + }, +}; + +module.exports.compatMap = compatMap; +module.exports.gmPolyfillOverride = gmPolyfillOverride; diff --git a/eslint/compat-headers.js b/eslint/compat-headers.js new file mode 100644 index 0000000..7961c8c --- /dev/null +++ b/eslint/compat-headers.js @@ -0,0 +1,201 @@ +// Fork from eslint-plugin-userscripts +// Documentation: +// - Tampermonkey: https://www.tampermonkey.net/documentation.php +// - Violentmonkey: https://violentmonkey.github.io/api/metadata-block/ +// - Greasemonkey: https://wiki.greasespot.net/Metadata_Block +// - ScriptCat: https://docs.scriptcat.org/docs/dev/ +const compatMap = { + localized: { + name: [ + { type: "tampermonkey", versionConstraint: ">=3.9" }, + { type: "violentmonkey", versionConstraint: ">=2.1.6.8" }, + { type: "greasemonkey", versionConstraint: ">=2.2 <4 || >=4.11" }, + ], + description: [ + { type: "tampermonkey", versionConstraint: ">=3.9" }, + { type: "violentmonkey", versionConstraint: ">=2.1.6.8" }, + { type: "greasemonkey", versionConstraint: ">=2.2 <4 || >=4.11" }, + ], + antifeature: [ + { type: "tampermonkey", versionConstraint: ">=4.12" }, + { type: "violentmonkey", versionConstraint: ">=2.12.10" }, + ], + }, + unlocalized: { + include: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: "*" }, + ], + exclude: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: "*" }, + ], + "exclude-match": [{ type: "violentmonkey", versionConstraint: ">=2.6.2" }], + version: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.9.0" }, + ], + "run-at": [ + { type: "tampermonkey", versionConstraint: ">=1.1.2190" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.9.8" }, + ], + resource: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.8.20080609.0" }, + ], + require: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.8.20080609.0" }, + ], + match: [ + { type: "tampermonkey", versionConstraint: ">=1.1.2190" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.9.8" }, + ], + "user-agent": [{ type: "tampermonkey", versionConstraint: ">=2.8.2894" }], + unwrap: [ + { type: "greasemonkey", versionConstraint: "0.8.1 - 0.9.22" }, + { type: "tampermonkey", versionConstraint: ">=4.14" }, + { type: "violentmonkey", versionConstraint: ">=2.13.0.16" }, + ], + grant: [ + { type: "tampermonkey", versionConstraint: ">=3.0.3389" }, + { type: "violentmonkey", versionConstraint: ">=2.1.6.1" }, + { type: "greasemonkey", versionConstraint: ">=1" }, + ], + noframes: [ + { type: "violentmonkey", versionConstraint: ">=2.8.17" }, + { type: "greasemonkey", versionConstraint: ">=2.3" }, + { type: "tampermonkey", versionConstraint: ">=2.0.2355" }, + ], + connect: [ + { type: "tampermonkey", versionConstraint: ">=4.0" }, + { type: "violentmonkey", versionConstraint: ">=2.12.10" }, + ], + webRequest: [{ type: "tampermonkey", versionConstraint: ">=4.4" }], + "inject-into": [{ type: "violentmonkey", versionConstraint: ">=2.10.0" }], + domain: [], // Scriptish + nocompat: [{ type: "tampermonkey", versionConstraint: ">=2.4.2683" }], + namespace: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.2.5" }, + ], + sandbox: [{ type: "tampermonkey", versionConstraint: ">=4.18" }], + }, + nonFunctional: { + name: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: "*" }, + ], + description: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: "*" }, + ], + author: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + ], + antifeature: [ + { type: "tampermonkey", versionConstraint: ">=4.12" }, + { type: "violentmonkey", versionConstraint: ">=2.12.10" }, + ], + copyright: [ + { type: "tampermonkey", versionConstraint: "*" }, + { type: "violentmonkey", versionConstraint: "*" }, + ], + license: [{ type: "tampermonkey", versionConstraint: "*" }], + icon: [ + { type: "tampermonkey", versionConstraint: ">=2.0.2359" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.9.0" }, + ], + defaulticon: [{ type: "tampermonkey", versionConstraint: ">=2.0.2359" }], + icon64: [{ type: "tampermonkey", versionConstraint: ">=2.0.2359" }], + iconURL: [{ type: "tampermonkey", versionConstraint: ">=2.0.2359" }], + icon64URL: [{ type: "tampermonkey", versionConstraint: ">=2.0.2359" }], + homepage: [ + { type: "tampermonkey", versionConstraint: ">=2.0.2395" }, + { type: "violentmonkey", versionConstraint: "*" }, + ], + homepageURL: [ + { type: "tampermonkey", versionConstraint: ">=2.0.2395" }, + { type: "violentmonkey", versionConstraint: ">=2.1.5" }, + ], + website: [ + { type: "tampermonkey", versionConstraint: ">=2.0.2395" }, + { type: "violentmonkey", versionConstraint: ">=2.13.1.2" }, + ], + source: [ + { type: "tampermonkey", versionConstraint: ">=2.0.2395" }, + { type: "violentmonkey", versionConstraint: ">=2.13.1.2" }, + ], + downloadURL: [ + { type: "tampermonkey", versionConstraint: ">=2.5.64" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.9.14" }, + ], + updateURL: [ + { type: "tampermonkey", versionConstraint: ">=2.5.64" }, + { type: "violentmonkey", versionConstraint: "*" }, + { type: "greasemonkey", versionConstraint: ">=0.9.12" }, + ], + installURL: [{ type: "greasemonkey", versionConstraint: ">=0.9.2" }], + supportURL: [ + { type: "tampermonkey", versionConstraint: ">=3.8" }, + { type: "violentmonkey", versionConstraint: ">=2.1.6.2" }, + ], + + // OpenUserJS + collaborator: [], + unstableMinify: [], + "oujs:author": [], + "oujs:collaborator": [], + + // UserScripts.org + "uso:script": [], + "uso:version": [], + "uso:timestamp": [], + "uso:hash": [], + "uso:rating": [], + "uso:installs": [], + "uso:reviews": [], + "uso:discussions": [], + "uso:fans": [], + "uso:unlisted": [], + contributor: [], + contributors: [], + major: [], + minor: [], + build: [], + + // GreasyFork + contributionURL: [], + contributionAmount: [], + incompatible: [], + compatible: [{ type: "violentmonkey", versionConstraint: ">=2.12.10" }], + + // Popular but not documented + history: [], + developer: [], + + // SctiptCat + background: [], + crontab: [], + cloudCat: [], + cloudServer: [], + exportValue: [], + exportCookie: [], + scriptUrl: [], + }, +}; + +module.exports = compatMap; diff --git a/eslint/linter-config.ts b/eslint/linter-config.ts new file mode 100644 index 0000000..0ff9785 --- /dev/null +++ b/eslint/linter-config.ts @@ -0,0 +1,131 @@ +// 由于原库(eslint-plugin-userscripts)使用了 fs 模块,无法在 webpack5 中直接使用,故改写成如下形式 +const userscriptsConfig = { + rules: { + "userscripts/filename-user": ["error", "always"], + "userscripts/no-invalid-metadata": ["error", { top: "required" }], + "userscripts/require-name": ["error", "required"], + "userscripts/require-description": ["error", "required"], + "userscripts/require-version": ["error", "required"], + "userscripts/require-attribute-space-prefix": "error", + "userscripts/use-homepage-and-url": "error", + "userscripts/use-download-and-update-url": "error", + "userscripts/align-attributes": ["error", 2], + "userscripts/metadata-spacing": ["error", "always"], + "userscripts/no-invalid-headers": "error", + "userscripts/no-invalid-grant": "error", + "userscripts/compat-grant": "off", + "userscripts/compat-headers": "off", + "userscripts/better-use-match": "warn", + }, +}; + +const userscriptsRules = Object.fromEntries( + Object.keys(userscriptsConfig.rules).map((name) => { + const ruleName = name.split("/")[1]; + // eslint-disable-next-line import/no-dynamic-require, global-require + const ruleMeta = require(`eslint-plugin-userscripts/lib/rules/${ruleName}.js`); + return [ + name, + { + ...ruleMeta, + meta: { + ...ruleMeta.meta, + docs: { + ...ruleMeta.meta.docs, + url: `https://yash-singh1.github.io/eslint-plugin-userscripts/#/rules/${ruleName}`, + }, + }, + }, + ]; + }) +); + +// 默认规则 +const config = { + parserOptions: { + ecmaVersion: "latest", + sourceType: "script", + ecmaFeatures: { + globalReturn: true, + }, + }, + globals: { + CATRetryError: "readonly", + CAT_fileStorage: "readonly", + CAT_userConfig: "readonly", + }, + rules: { + "constructor-super": ["error"], + "for-direction": ["error"], + "getter-return": ["error"], + "no-async-promise-executor": ["error"], + "no-case-declarations": ["error"], + "no-class-assign": ["error"], + "no-compare-neg-zero": ["error"], + "no-cond-assign": ["error"], + "no-const-assign": ["error"], + "no-constant-condition": ["error"], + "no-control-regex": ["error"], + "no-debugger": ["error"], + "no-delete-var": ["error"], + "no-dupe-args": ["error"], + "no-dupe-class-members": ["error"], + "no-dupe-else-if": ["error"], + "no-dupe-keys": ["error"], + "no-duplicate-case": ["error"], + "no-empty": ["error"], + "no-empty-character-class": ["error"], + "no-empty-pattern": ["error"], + "no-ex-assign": ["error"], + "no-extra-boolean-cast": ["error"], + "no-extra-semi": ["error"], + "no-fallthrough": ["error"], + "no-func-assign": ["error"], + "no-global-assign": ["error"], + "no-import-assign": ["error"], + "no-inner-declarations": ["error"], + "no-invalid-regexp": ["error"], + "no-irregular-whitespace": ["error"], + "no-loss-of-precision": ["error"], + "no-misleading-character-class": ["error"], + "no-mixed-spaces-and-tabs": ["error"], + "no-new-symbol": ["error"], + "no-nonoctal-decimal-escape": ["error"], + "no-obj-calls": ["error"], + "no-octal": ["error"], + "no-prototype-builtins": ["error"], + "no-redeclare": ["error"], + "no-regex-spaces": ["error"], + "no-self-assign": ["error"], + "no-setter-return": ["error"], + "no-shadow-restricted-names": ["error"], + "no-sparse-arrays": ["error"], + "no-this-before-super": ["error"], + "no-undef": ["warn"], + "no-unexpected-multiline": ["error"], + "no-unreachable": ["error"], + "no-unsafe-finally": ["error"], + "no-unsafe-negation": ["error"], + "no-unsafe-optional-chaining": ["error"], + "no-unused-labels": ["error"], + "no-unused-vars": ["error"], + "no-useless-backreference": ["error"], + "no-useless-catch": ["error"], + "no-useless-escape": ["error"], + "no-with": ["error"], + "require-yield": ["error"], + "use-isnan": ["error"], + "valid-typeof": ["error"], + ...userscriptsConfig.rules, + }, + env: { + es6: true, + browser: true, + greasemonkey: true, + }, +}; + +// 以文本形式导出默认规则 +const defaultConfig = JSON.stringify(config); + +export { defaultConfig, userscriptsConfig, userscriptsRules }; diff --git a/example/cat_file_storage.js b/example/cat_file_storage.js new file mode 100644 index 0000000..051a8d0 --- /dev/null +++ b/example/cat_file_storage.js @@ -0,0 +1,50 @@ +// ==UserScript== +// @name cat file storage +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 脚本同步储存空间操作 +// @author You +// @match https://bbs.tampermonkey.net.cn/ +// @grant CAT_fileStorage +// @run-at document-start +// ==/UserScript== + +CAT_fileStorage("upload", { + path: "test.txt", + baseDir: "test-dir", + data: new Blob(["Hello World"]), + onload() { + CAT_fileStorage("list", { + baseDir: "test-dir", + onload(list) { + console.log(list); + list.forEach(value => { + if (value.name === "test.txt") { + CAT_fileStorage("download", { + file: value, + baseDir: "test-dir", + async onload(data) { + console.log(await data.text()); + CAT_fileStorage("delete", { + path: value.name, + baseDir: "test-dir", + onload() { + console.log('ok'); + } + }); + } + }); + } + }); + } + }) + }, onerror(err) { + console.log(err); + switch (err.code) { + case 1: + case 2: + CAT_fileStorage("config"); + break; + } + } +}) \ No newline at end of file diff --git a/example/cloudcat.js b/example/cloudcat.js new file mode 100644 index 0000000..959950e --- /dev/null +++ b/example/cloudcat.js @@ -0,0 +1,16 @@ +// ==UserScript== +// @name cloudscript +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 可以导出成nodejs可执行的包,在云端执行 +// @author You +// @crontab * * once * * +// @cloudCat +// @exportCookie domain=.scriptscat.org +// ==/UserScript== + +return new Promise((resolve, reject) => { + // Your code here... + resolve(); +}); + diff --git a/example/error_retry.js b/example/error_retry.js new file mode 100644 index 0000000..8b14c6c --- /dev/null +++ b/example/error_retry.js @@ -0,0 +1,18 @@ +// ==UserScript== +// @name 重试示例 +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description try to take over the world! +// @author You +// @crontab * * once * * +// @grant GM_notification +// ==/UserScript== + +return new Promise((resolve, reject) => { + // Your code here... + GM_notification({ + title: "retry", + text: "10秒后重试" + }); + reject(new CATRetryError("xxx错误", 10)); +}); diff --git a/example/gm_add_element.js b/example/gm_add_element.js new file mode 100644 index 0000000..1129d0f --- /dev/null +++ b/example/gm_add_element.js @@ -0,0 +1,15 @@ +// ==UserScript== +// @name gm add element +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 在页面中插入元素,可以绕过CSP限制 +// @author You +// @match https://github.com/scriptscat/scriptcat +// @grant GM_addElement +// ==/UserScript== + +const el = GM_addElement(document.querySelector('.BorderGrid-cell'), "img", { + src: "https://bbs.tampermonkey.net.cn/uc_server/avatar.php?uid=4&size=small&ts=1" +}); + +console.log(el); \ No newline at end of file diff --git a/example/gm_bg_menu.js b/example/gm_bg_menu.js new file mode 100644 index 0000000..55e9352 --- /dev/null +++ b/example/gm_bg_menu.js @@ -0,0 +1,18 @@ +// ==UserScript== +// @name bg gm menu +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 在后台脚本中使用菜单 +// @author You +// @background +// @grant GM_registerMenuCommand +// @grant GM_unregisterMenuCommand +// ==/UserScript== + +return new Promise((resolve) => { + const id = GM_registerMenuCommand("测试菜单", () => { + console.log(id); + GM_unregisterMenuCommand(id); + resolve(); + }, "z"); +}); diff --git a/example/gm_clipboard.js b/example/gm_clipboard.js new file mode 100644 index 0000000..205c04e --- /dev/null +++ b/example/gm_clipboard.js @@ -0,0 +1,11 @@ +// ==UserScript== +// @name gm clipboard +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description try to take over the world! +// @author You +// @match https://bbs.tampermonkey.net.cn/ +// @grant GM_setClipboard +// ==/UserScript== + +GM_setClipboard("我爱ScriptCat"); diff --git a/example/gm_cookie.js b/example/gm_cookie.js new file mode 100644 index 0000000..2df9151 --- /dev/null +++ b/example/gm_cookie.js @@ -0,0 +1,44 @@ +// ==UserScript== +// @name New Userscript +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 可以控制浏览器的cookie, 必须指定@connect, 并且每次一个新的域调用都需要用户确定 +// @author You +// @match https://bbs.tampermonkey.net.cn/ +// @grant GM_cookie +// @connect example.com +// ==/UserScript== + +// GM_cookie("store") 方法请看gm_value.js的例子, 可用于隐身窗口的操作 + +GM_cookie("set", { + url: "http://example.com/cookie", + name: "cookie1", value: "value" +}, () => { + GM_cookie("set", { + url: "http://www.example.com/", + domain: ".example.com", path: "/path", + name: "cookie2", value: "path" + }, () => { + GM_cookie("list", { + domain: "example.com" + }, (cookies) => { + console.log("domain", cookies); + }); + GM_cookie("list", { + url: "http://example.com/cookie", + }, (cookies) => { + console.log("domain", cookies); + }); + GM_cookie("delete", { + url: "http://www.example.com/path", + name: "cookie2" + }, () => { + GM_cookie("list", { + domain: "example.com" + }, (cookies) => { + console.log("delete", cookies); + }); + }) + }); +}); diff --git a/example/gm_download.js b/example/gm_download.js new file mode 100644 index 0000000..0cfa78a --- /dev/null +++ b/example/gm_download.js @@ -0,0 +1,20 @@ +// ==UserScript== +// @name gm download +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description try to take over the world! +// @author You +// @match https://bbs.tampermonkey.net.cn/ +// @grant GM_download +// ==/UserScript== + +GM_download({ + url: "https://scriptcat.org/api/v1/gm_crx/download/ScriptCat", + name: "scriptcat.crx", + headers: { + "referer": "http://www.example.com/", + "origin": "www.example.com" + }, onprogress(data) { + console.log(data); + } +}); diff --git a/example/gm_get_resource.js b/example/gm_get_resource.js new file mode 100644 index 0000000..776de5c --- /dev/null +++ b/example/gm_get_resource.js @@ -0,0 +1,17 @@ +// ==UserScript== +// @name gm get resource +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 通过@resource引用资源,这个资源会被管理器进行缓存,不可修改 +// @author You +// @match https://bbs.tampermonkey.net.cn/ +// @resource bbs https://bbs.tampermonkey.net.cn/ +// @grant GM_getResourceURL +// @grant GM_getResourceText +// ==/UserScript== + + +console.log(GM_getResourceURL("bbs")); +console.log(GM_getResourceURL("bbs", false)); +console.log(GM_getResourceURL("bbs", true)); +console.log(GM_getResourceText("bbs")); \ No newline at end of file diff --git a/example/gm_log.js b/example/gm_log.js new file mode 100644 index 0000000..7bbc630 --- /dev/null +++ b/example/gm_log.js @@ -0,0 +1,11 @@ +// ==UserScript== +// @name gm log +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 日志功能,为你的脚本加上丰富的日志吧,支持日志分级与日志标签 +// @author You +// @match https://bbs.tampermonkey.net.cn/ +// @grant GM_log +// ==/UserScript== + +GM_log("log message", "info", { component: "example" }); \ No newline at end of file diff --git a/example/gm_menu.js b/example/gm_menu.js new file mode 100644 index 0000000..71f61eb --- /dev/null +++ b/example/gm_menu.js @@ -0,0 +1,16 @@ +// ==UserScript== +// @name gm menu +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 创建菜单, 可以显示在右上角的插件弹出页和浏览器右键菜单中 +// @author You +// @match https://bbs.tampermonkey.net.cn/ +// @grant GM_registerMenuCommand +// @grant GM_unregisterMenuCommand +// ==/UserScript== + + +const id = GM_registerMenuCommand("测试菜单", () => { + console.log(id); + GM_unregisterMenuCommand(id); +}, "h"); diff --git a/example/gm_notification.js b/example/gm_notification.js new file mode 100644 index 0000000..cabce55 --- /dev/null +++ b/example/gm_notification.js @@ -0,0 +1,43 @@ +// ==UserScript== +// @name gm notification +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 用来发送一个浏览器通知, 支持图标/文字/进度条(进度条只在 Chrome 有效) +// @author You +// @match https://bbs.tampermonkey.net.cn/ +// @grant GM_notification +// ==/UserScript== + +let i; +GM_notification({ + title: '倒计时', + text: '准备进入倒计时,创建和获取通知id', + ondone: (byUser) => { + console.log('done user:', byUser); + clearInterval(i); + }, + onclick: () => { + console.log('click'); + }, + oncreate: (id) => { + let t = 1; + i = setInterval(() => { + GM_updateNotification(id, { + title: '倒计时', + text: (60 - t) + 's倒计时', + progress: 100 / 60 * t + }); + if (t == 60) { + clearInterval(i); + GM_updateNotification(id, { + title: '倒计时', + text: '倒计时结束', + progress: 100 + }); + } + t++; + }, 1000); + }, + // 开启进度条模式 + progress: 0, +}); diff --git a/example/gm_save_tab.js b/example/gm_save_tab.js new file mode 100644 index 0000000..385bfc2 --- /dev/null +++ b/example/gm_save_tab.js @@ -0,0 +1,21 @@ +// ==UserScript== +// @name gm get/save tab +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 用于保存当前标签页的数据, 关闭后会自动删除, 可以获取其它标签页的数据 +// @author You +// @match https://bbs.tampermonkey.net.cn/ +// @grant GM_saveTab +// @grant GM_getTab +// @grant GM_getTabs +// ==/UserScript== + +GM_saveTab({ test: "save" }); + +GM_getTab(data => { + console.log(data); +}); + +GM_getTabs(data => { + console.log(data); +}); diff --git a/example/gm_tab.js b/example/gm_tab.js new file mode 100644 index 0000000..4dab1f8 --- /dev/null +++ b/example/gm_tab.js @@ -0,0 +1,20 @@ +// ==UserScript== +// @name gm open tab +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 打开一个标签页 +// @author You +// @match https://bbs.tampermonkey.net.cn/ +// @grant GM_openInTab +// ==/UserScript== + +const tab = GM_openInTab("https://scriptcat.org/search"); + +tab.onclose = () => { + console.log("close"); +} + +setTimeout(() => { + tab.close(); +}, 3000) + diff --git a/example/gm_value.js b/example/gm_value.js new file mode 100644 index 0000000..0b1c1ba --- /dev/null +++ b/example/gm_value.js @@ -0,0 +1,32 @@ +// ==UserScript== +// @name gm value +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 可以持久化存储数据, 并且可以监听数据变化 +// @author You +// @match https://bbs.tampermonkey.net.cn/ +// @run-at document-start +// @grant GM_setValue +// @grant GM_getValue +// @grant GM_addValueChangeListener +// @grant GM_listValues +// @grant GM_deleteValue +// @grant GM_cookie +// ==/UserScript== + +GM_addValueChangeListener("test_set", function (name, oldval, newval, remote, tabid) { + GM_cookie("store", tabid,(storeId) => { + console.log("store",storeId); + }); +}); + +setInterval(() => { + console.log(GM_getValue("test_set")); + console.log(GM_listValues()); +}, 2000); + +setTimeout(() => { + GM_deleteValue("test_set"); +}, 3000); + +GM_setValue("test_set", new Date().getTime()); diff --git a/example/gm_xhr.js b/example/gm_xhr.js new file mode 100644 index 0000000..59be5a6 --- /dev/null +++ b/example/gm_xhr.js @@ -0,0 +1,36 @@ +// ==UserScript== +// @name gm xhr +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 无视浏览器的cors的跨域请求,可以设置各种unsafeHeader与cookie,需要使用@connect获取权限,或者由用户确认 +// @author You +// @grant GM_xmlhttpRequest +// @match https://bbs.tampermonkey.net.cn/ +// @connect tampermonkey.net.cn +// ==/UserScript== + +const data = new FormData(); + +data.append("username", "admin"); + +GM_xmlhttpRequest({ + url: "https://bbs.tampermonkey.net.cn/", + method: "POST", + responseType: "blob", + data: data, + headers: { + "referer": "http://www.example.com/", + "origin": "www.example.com", + // 为空将不会发送此header + "sec-ch-ua-mobile": "", + }, + onload(resp) { + console.log("onload", resp); + }, + onreadystatechange(resp) { + console.log("onreadystatechange", resp); + }, + onloadend(resp) { + console.log("onloadend", resp); + }, +}); diff --git a/example/userconfig.js b/example/userconfig.js new file mode 100644 index 0000000..93a309f --- /dev/null +++ b/example/userconfig.js @@ -0,0 +1,92 @@ +// ==UserScript== +// @name userconfig +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description 会在页面上显示用户配置,可以可视化的进行配置 +// @author You +// @background +// @grant GM_getValue +// @grant CAT_userConfig +// ==/UserScript== + +/* ==UserConfig== +group1: + configA: # 键值为group.config,例如本键为:group1.configA + title: 配置A # 配置的标题 + description: 这是一个文本类型的配置 # 配置的描述内容 + type: text # 选项类型,如果不填写会根据数据自动识别 + default: 默认值 # 配置的默认值 + min: 2 # 文本最短2个字符 + max: 18 # 文本最长18个字符 + password: true # 设置为密码 + configB: + title: 配置B + description: 这是一个选择框的配置 + type: checkbox + default: true + configC: + title: 配置C + description: 这是一个列表选择的配置 + type: select + default: 1 + values: [1,2,3,4,5] + configD: + title: 配置D + description: 这是一个动态列表选择的配置 + type: select + bind: $cookies # 动态显示绑定的values,值是以$开头的key,value需要是一个数组 + configE: + title: 配置E + description: 这是一个多选列表的配置 + type: mult-select + default: [1] + values: [1,2,3,4,5] + configF: + title: 配置F + description: 这是一个动态多选列表的配置 + type: mult-select + bind: $cookies + configG: + title: 配置G + description: 这是一个数字的配置 + type: number + default: 11 + min: 10 # 最小值 + max: 16 # 最大值 + unit: 分 # 表示单位 + configH: + title: 配置H + description: 这是一个长文本类型的配置 + type: textarea + default: 默认值 + rows: 6 +--- +group2: + configX: + title: 配置A + description: 这是一个文本类型的配置 + default: 默认值 + ==/UserConfig== */ + +// 通过GM_info新方法获取UserConfig对象 +const rawUserConfig = GM_info.userConfig; +// 定义一个对象暂存读取到的UserConfig值 +const userConfig = {}; +// 解构遍历读取UserConfig并赋缺省值 +Object.entries(rawUserConfig).forEach(([mainKey, configs]) => { + Object.entries(configs).forEach(([subKey, { default: defaultValue }]) => { + userConfig[`${mainKey}.${subKey}`] = GM_getValue(`${mainKey}.${subKey}`, defaultValue) + }) +}) + +setInterval(() => { + // 传统方法读取UserConfig,每个缺省值需要单独静态声明,修改UserConfig缺省值后代码也需要手动修改 + console.log(GM_getValue("group1.configA", "默认值")); + console.log(GM_getValue("group1.configG", 11)); + // GM_info新方法读取UserConfig,可直接关联读取缺省值,无需额外修改 + console.log(userConfig["group1.configA"]); + console.log(userConfig["group1.configG"]); +}, 5000) + +// 打开用户配置 +CAT_userConfig(); \ No newline at end of file diff --git a/example/usersubscribe.user.sub.js b/example/usersubscribe.user.sub.js new file mode 100644 index 0000000..443b671 --- /dev/null +++ b/example/usersubscribe.user.sub.js @@ -0,0 +1,8 @@ +// ==UserSubscribe== +// @name 订阅脚本 +// @description 可以通过指定脚本url订阅一系列的脚本 +// @version 1.0.0 +// @author You +// @connect www.baidu.com +// @scriptUrl https://scriptcat.org/scripts/code/22/test.user.js +// ==/UserSubscribe== diff --git a/example/vscode.user.js b/example/vscode.user.js new file mode 100644 index 0000000..caa9744 --- /dev/null +++ b/example/vscode.user.js @@ -0,0 +1,13 @@ +// ==UserScript== +// @name vscode 同步测试 +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 0.1.0 +// @description vscode scriptcat 插件同步测试 +// @author You +// @match https://bbs.tampermonkey.net.cn/ +// ==/UserScript== + +(function() { + 'use strict'; + // Your code here... +})(); \ No newline at end of file diff --git a/src/app/service/sandbox/index.ts b/src/app/service/sandbox/index.ts index b084b95..3d3293f 100644 --- a/src/app/service/sandbox/index.ts +++ b/src/app/service/sandbox/index.ts @@ -2,6 +2,9 @@ import { Server } from "@Packages/message/server"; import { WindowMessage } from "@Packages/message/window_message"; import { preparationSandbox } from "../offscreen/client"; import { Script, SCRIPT_TYPE_BACKGROUND } from "@App/app/repo/scripts"; +import { CronJob } from "cron"; +import ExecScript from "@App/runtime/content/exec_script"; +import { Runtime } from "./runtime"; // sandbox环境的管理器 export class SandboxManager { @@ -9,20 +12,9 @@ export class SandboxManager { constructor(private windowMessage: WindowMessage) {} - enableScript(data: Script) { - // 开启脚本, 判断脚本是后台脚本还是定时脚本 - if(data.type === SCRIPT_TYPE_BACKGROUND) { - // 后台脚本直接运行起来 - }else{ - // 定时脚本加入定时任务 - } - eval("console.log('hello')"); - console.log("enableScript", data); - } - initManager() { - this.api.on("enableScript", this.enableScript.bind(this)); - + const runtime = new Runtime(this.windowMessage, this.api); + runtime.init(); // 通知初始化好环境了 preparationSandbox(this.windowMessage); } diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts new file mode 100644 index 0000000..8ec4fe2 --- /dev/null +++ b/src/app/service/sandbox/runtime.ts @@ -0,0 +1,92 @@ +import LoggerCore from "@App/app/logger/core"; +import Logger from "@App/app/logger/logger"; +import { Script, SCRIPT_TYPE_BACKGROUND, ScriptRunResouce } from "@App/app/repo/scripts"; +import ExecScript from "@App/runtime/content/exec_script"; +import { Server } from "@Packages/message/server"; +import { WindowMessage } from "@Packages/message/window_message"; +import { CronJob } from "cron"; + +export class Runtime { + cronJob: Map> = new Map(); + + execScripts: Map = new Map(); + + logger: Logger; + + retryList: { + script: ScriptRunResouce; + retryTime: number; + }[] = []; + + constructor( + private windowMessage: WindowMessage, + private api: Server + ) { + this.logger = LoggerCore.getInstance().logger({ component: "sandbox" }); + // 重试队列,5s检查一次 + setInterval(() => { + if (!this.retryList.length) { + return; + } + const now = Date.now(); + const retryList = []; + for (let i = 0; i < this.retryList.length; i += 1) { + const item = this.retryList[i]; + if (item.retryTime < now) { + this.retryList.splice(i, 1); + i -= 1; + retryList.push(item.script); + } + } + retryList.forEach((script) => { + script.nextruntime = 0; + this.execScript(script); + }); + }, 5000); + } + + joinRetryList(script: ScriptRunResouce) { + if (script.nextruntime) { + this.retryList.push({ + script, + retryTime: script.nextruntime, + }); + this.retryList.sort((a, b) => a.retryTime - b.retryTime); + } + } + + removeRetryList(scriptId: number) { + for (let i = 0; i < this.retryList.length; i += 1) { + if (this.retryList[i].script.id === scriptId) { + this.retryList.splice(i, 1); + i -= 1; + } + } + } + + enableScript(data: Script) { + // 开启脚本, 判断脚本是后台脚本还是定时脚本 + if (data.type === SCRIPT_TYPE_BACKGROUND) { + // 后台脚本直接运行起来 + } else { + // 定时脚本加入定时任务 + } + eval("console.log('hello')"); + console.log("enableScript", data); + } + + disableScript(data: Script) { + // 关闭脚本, 判断脚本是后台脚本还是定时脚本 + if (data.type === SCRIPT_TYPE_BACKGROUND) { + // 后台脚本直接停止 + } else { + // 定时脚本停止定时任务 + } + console.log("disableScript", data); + } + + init() { + this.api.on("enableScript", this.enableScript.bind(this)); + this.api.on("disableScript", this.disableScript.bind(this)); + } +} diff --git a/src/pages/components/CodeEditor/index.tsx b/src/pages/components/CodeEditor/index.tsx index 2575a7d..d0cfd2d 100644 --- a/src/pages/components/CodeEditor/index.tsx +++ b/src/pages/components/CodeEditor/index.tsx @@ -1,6 +1,6 @@ import Cache from "@App/app/cache"; import { LinterWorker } from "@App/pkg/utils/monaco-editor"; -import { useAppSelector } from "@App/store/hooks"; +import { useAppSelector } from "@App/pages/store/hooks"; import { editor, Range } from "monaco-editor"; import React, { useEffect, useImperativeHandle, useState } from "react"; diff --git a/src/pages/components/layout/MainLayout.tsx b/src/pages/components/layout/MainLayout.tsx index 4964bda..a45f1c0 100644 --- a/src/pages/components/layout/MainLayout.tsx +++ b/src/pages/components/layout/MainLayout.tsx @@ -16,8 +16,8 @@ import { IconDesktop, IconDown, IconLink, IconMoonFill, IconSunFill } from "@arc import React, { ReactNode, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import "./index.css"; -import { useAppDispatch, useAppSelector } from "@App/store/hooks"; -import { selectThemeMode, setDarkMode } from "@App/store/features/setting"; +import { useAppDispatch, useAppSelector } from "@App/pages/store/hooks"; +import { selectThemeMode, setDarkMode } from "@App/pages/store/features/setting"; import { RiFileCodeLine, RiImportLine, RiPlayListAddLine, RiTerminalBoxLine, RiTimerLine } from "react-icons/ri"; const MainLayout: React.FC<{ diff --git a/src/pages/install/main.tsx b/src/pages/install/main.tsx index 32dd876..d88c93a 100644 --- a/src/pages/install/main.tsx +++ b/src/pages/install/main.tsx @@ -6,7 +6,7 @@ import "@arco-design/web-react/dist/css/arco.css"; import "@App/locales/locales"; import "@App/index.css"; import { Provider } from "react-redux"; -import { store } from "@App/store/store.ts"; +import { store } from "@App/pages/store/store.ts"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/pages/options/main.tsx b/src/pages/options/main.tsx index d79e422..7801341 100644 --- a/src/pages/options/main.tsx +++ b/src/pages/options/main.tsx @@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client"; import MainLayout from "../components/layout/MainLayout.tsx"; import Sider from "../components/layout/Sider.tsx"; import { Provider } from "react-redux"; -import { store } from "@App/store/store.ts"; +import { store } from "@App/pages/store/store.ts"; import "@arco-design/web-react/dist/css/arco.css"; import "@App/locales/locales"; import "@App/index.css"; diff --git a/src/pages/options/routes/ScriptList.tsx b/src/pages/options/routes/ScriptList.tsx index b360ccc..a15cded 100644 --- a/src/pages/options/routes/ScriptList.tsx +++ b/src/pages/options/routes/ScriptList.tsx @@ -69,7 +69,7 @@ import { useTranslation } from "react-i18next"; import { nextTime, semTime } from "@App/pkg/utils/utils"; import { i18nName } from "@App/locales/locales"; import { getValues, ListHomeRender, ScriptIcons } from "./utils"; -import { useAppDispatch, useAppSelector } from "@App/store/hooks"; +import { useAppDispatch, useAppSelector } from "@App/pages/store/hooks"; import { deleteScript, requestEnableScript, @@ -79,8 +79,8 @@ import { selectScripts, sortScript, upsertScript, -} from "@App/store/features/script"; -import { selectScriptListColumnWidth } from "@App/store/features/setting"; +} from "@App/pages/store/features/script"; +import { selectScriptListColumnWidth } from "@App/pages/store/features/setting"; import { Broker } from "@Packages/message/message_queue"; import { subscribeScriptDelete, subscribeScriptInstall } from "@App/app/service/service_worker/client"; import { ExtensionMessage } from "@Packages/message/extension_message"; diff --git a/src/store/features/script.ts b/src/pages/store/features/script.ts similarity index 100% rename from src/store/features/script.ts rename to src/pages/store/features/script.ts diff --git a/src/store/features/setting.ts b/src/pages/store/features/setting.ts similarity index 100% rename from src/store/features/setting.ts rename to src/pages/store/features/setting.ts diff --git a/src/store/hooks.ts b/src/pages/store/hooks.ts similarity index 100% rename from src/store/hooks.ts rename to src/pages/store/hooks.ts diff --git a/src/store/store.ts b/src/pages/store/store.ts similarity index 100% rename from src/store/store.ts rename to src/pages/store/store.ts diff --git a/src/pkg/utils/lodash.ts b/src/pkg/utils/lodash.ts new file mode 100644 index 0000000..d83b3ba --- /dev/null +++ b/src/pkg/utils/lodash.ts @@ -0,0 +1,6 @@ +// 因为这个包出过好几次问题, 从原仓库单独剥离出来使用 +// copyright: https://github.com/lodash/lodash + +export function has(object: any, key: any) { + return object != null && Object.prototype.hasOwnProperty.call(object, key); +} diff --git a/src/runtime/background/gm_api.ts b/src/runtime/background/gm_api.ts new file mode 100644 index 0000000..4db5b0e --- /dev/null +++ b/src/runtime/background/gm_api.ts @@ -0,0 +1,961 @@ +/* eslint-disable camelcase */ +import Cache from "@App/app/cache"; +import LoggerCore from "@App/app/logger/core"; +import Logger from "@App/app/logger/logger"; +import { Channel } from "@App/app/message/channel"; +import { MessageHander, MessageSender } from "@App/app/message/message"; +import { Script, ScriptDAO } from "@App/app/repo/scripts"; +import ValueManager from "@App/app/service/value/manager"; +import CacheKey from "@App/pkg/utils/cache_key"; +import { v4 as uuidv4 } from "uuid"; +import { base64ToBlob } from "@App/pkg/utils/script"; +import { isFirefox } from "@App/pkg/utils/utils"; +import Hook from "@App/app/service/hook"; +import IoC from "@App/app/ioc"; +import { SystemConfig } from "@App/pkg/config/config"; +import FileSystemFactory from "@Pkg/filesystem/factory"; +import FileSystem from "@Pkg/filesystem/filesystem"; +import { joinPath } from "@Pkg/filesystem/utils"; +import i18next from "i18next"; +import { i18nName } from "@App/locales/locales"; +import { isWarpTokenError } from "@Pkg/filesystem/error"; +import PermissionVerify, { + ConfirmParam, + IPermissionVerify, +} from "./permission_verify"; +import { + dealFetch, + dealXhr, + getFetchHeader, + getIcon, + listenerWebRequest, + setXhrHeader, +} from "./utils"; + +// GMApi,处理脚本的GM API调用请求 + +export type MessageRequest = { + scriptId: number; // 脚本id + api: string; + runFlag: string; + params: any[]; +}; + +export type Request = MessageRequest & { + script: Script; + sender: MessageSender; +}; + +export type Api = (request: Request, connect?: Channel) => Promise; + +export default class GMApi { + message: MessageHander; + + script: ScriptDAO; + + permissionVerify: IPermissionVerify; + + valueManager: ValueManager; + + logger: Logger = LoggerCore.getLogger({ component: "GMApi" }); + + static hook: Hook<"registerMenu" | "unregisterMenu"> = new Hook(); + + systemConfig: SystemConfig; + + constructor(message: MessageHander, permissionVerify: IPermissionVerify) { + this.message = message; + this.script = new ScriptDAO(); + this.permissionVerify = permissionVerify; + this.systemConfig = IoC.instance(SystemConfig) as SystemConfig; + // 证明是后台运行的,生成一个随机的headerFlag + if (permissionVerify instanceof PermissionVerify) { + this.systemConfig.scriptCatFlag = `x-cat-${uuidv4()}`; + } + this.valueManager = IoC.instance(ValueManager); + } + + start() { + this.message.setHandler( + "gmApi", + async (_action: string, data: MessageRequest, sender: MessageSender) => { + const api = PermissionVerify.apis.get(data.api); + if (!api) { + return Promise.reject(new Error("api is not found")); + } + const req = await this.parseRequest(data, sender); + try { + await this.permissionVerify.verify(req, api); + } catch (e) { + this.logger.error("verify error", { api: data.api }, Logger.E(e)); + return Promise.reject(e); + } + return api.api.call(this, req); + } + ); + this.message.setHandlerWithChannel( + "gmApiChannel", + async ( + connect: Channel, + _action: string, + data: MessageRequest, + sender: MessageSender + ) => { + const api = PermissionVerify.apis.get(data.api); + if (!api) { + return connect.throw("api is not found"); + } + const req = await this.parseRequest(data, sender); + try { + await this.permissionVerify.verify(req, api); + } catch (e: any) { + this.logger.error("verify error", { api: data.api }, Logger.E(e)); + return connect.throw(e.message); + } + return api.api.call(this, req, connect); + } + ); + // 只有background页才监听web请求 + if (this.permissionVerify instanceof PermissionVerify) { + listenerWebRequest(this.systemConfig.scriptCatFlag); + } + // 处理sandbox来的CAT_fetchBlob和CAT_createBlobUrl + this.message.setHandler("CAT_createBlobUrl", (_: string, blob: Blob) => { + const url = URL.createObjectURL(blob); + setTimeout(() => { + URL.revokeObjectURL(url); + }, 60 * 1000); + return Promise.resolve(url); + }); + this.message.setHandler("CAT_fetchBlob", (_: string, url: string) => { + return fetch(url).then((data) => data.blob()); + }); + } + + // 解析请求 + async parseRequest( + data: MessageRequest, + sender: MessageSender + ): Promise { + const script = await Cache.getInstance().getOrSet( + CacheKey.script(data.scriptId), + () => { + return this.script.findById(data.scriptId); + } + ); + if (!script) { + return Promise.reject(new Error("script is not found")); + } + const req: Request = data; + req.script = script; + req.sender = sender; + return Promise.resolve(req); + } + + @PermissionVerify.API() + GM_setValue(request: Request): Promise { + if (!request.params || request.params.length !== 2) { + return Promise.reject(new Error("param is failed")); + } + const [key, value] = request.params; + const sender = request.sender; + sender.runFlag = request.runFlag; + return this.valueManager.setValue(request.script, key, value, sender); + } + + // 处理GM_xmlhttpRequest fetch的情况,先只处理ReadableStream的情况 + // 且不考虑复杂的情况 + CAT_fetch(request: Request, channel: Channel): Promise { + const config = request.params[0]; + const { url } = config; + return fetch(url, { + method: config.method || "GET", + body: config.data, + headers: getFetchHeader(this.systemConfig.scriptCatFlag, config), + }) + .then((resp) => { + const send = dealFetch( + this.systemConfig.scriptCatFlag, + config, + resp, + 1 + ); + const reader = resp.body?.getReader(); + if (!reader) { + throw new Error("read is not found"); + } + const { scriptCatFlag } = this.systemConfig; + reader.read().then(function read({ done, value }) { + if (done) { + const data = dealFetch(scriptCatFlag, config, resp, 4); + channel.send({ event: "onreadystatechange", data }); + channel.send({ event: "onload", data }); + channel.send({ event: "onloadend", data }); + channel.disChannel(); + } else { + channel.send({ event: "onstream", data: Array.from(value) }); + reader.read().then(read); + } + }); + channel.send({ event: "onloadstart", data: send }); + send.readyState = 2; + channel.send({ event: "onreadystatechange", data: send }); + }) + .catch((e) => { + channel.throw(e); + }); + } + + @PermissionVerify.API({ + confirm: (request: Request) => { + const config = request.params[0]; + const url = new URL(config.url); + if (request.script.metadata.connect) { + const { connect } = request.script.metadata; + for (let i = 0; i < connect.length; i += 1) { + if (url.hostname.endsWith(connect[i])) { + return Promise.resolve(true); + } + } + } + const metadata: { [key: string]: string } = {}; + metadata[i18next.t("script_name")] = i18nName(request.script); + metadata[i18next.t("request_domain")] = url.hostname; + metadata[i18next.t("request_url")] = config.url; + + return Promise.resolve({ + permission: "cors", + permissionValue: url.hostname, + title: i18next.t("script_accessing_cross_origin_resource"), + metadata, + describe: i18next.t("confirm_operation_description"), + wildcard: true, + permissionContent: i18next.t("domain"), + } as ConfirmParam); + }, + alias: ["GM.xmlHttpRequest"], + }) + async GM_xmlhttpRequest(request: Request, channel: Channel): Promise { + const config = request.params[0]; + if (config.responseType === "stream") { + // 只有fetch支持ReadableStream + return this.CAT_fetch(request, channel); + } + const xhr = new XMLHttpRequest(); + xhr.open( + config.method || "GET", + config.url, + true, + config.user || "", + config.password || "" + ); + if (config.overrideMimeType) { + xhr.overrideMimeType(config.overrideMimeType); + } + if (config.responseType !== "json") { + xhr.responseType = config.responseType || ""; + } + + const deal = async (event: string, data?: any) => { + const response: any = await dealXhr( + this.systemConfig.scriptCatFlag, + config, + xhr + ); + if (data) { + Object.keys(data).forEach((key) => { + response[key] = data[key]; + }); + } + channel.send({ event, data: response }); + if (event === "onload") { + channel.disChannel(); + } + }; + xhr.onload = () => { + deal("onload"); + }; + xhr.onloadstart = () => { + deal("onloadstart"); + }; + xhr.onloadend = () => { + deal("onloadstart"); + }; + xhr.onabort = () => { + deal("onabort"); + }; + xhr.onerror = () => { + deal("onerror"); + }; + xhr.onprogress = (event) => { + const respond: GMTypes.XHRProgress = { + done: xhr.DONE, + lengthComputable: event.lengthComputable, + loaded: event.loaded, + total: event.total, + totalSize: event.total, + }; + deal("onprogress", respond); + }; + xhr.onreadystatechange = () => { + deal("onreadystatechange"); + }; + xhr.ontimeout = () => { + channel.send({ event: "ontimeout" }); + }; + setXhrHeader(this.systemConfig.scriptCatFlag, config, xhr); + + if (config.timeout) { + xhr.timeout = config.timeout; + } + + if (config.overrideMimeType) { + xhr.overrideMimeType(config.overrideMimeType); + } + + if (config.dataType === "FormData") { + const data = new FormData(); + if (config.data && config.data instanceof Array) { + config.data.forEach((val: GMSend.XHRFormData) => { + if (val.type === "file") { + data.append(val.key, base64ToBlob(val.val), val.filename); + } else { + data.append(val.key, val.val); + } + }); + xhr.send(data); + } + } else if (config.dataType === "Blob") { + if (!config.data) { + return channel.throw("data is null"); + } + const resp = await (await fetch(config.data)).blob(); + xhr.send(resp); + } else { + xhr.send(config.data); + } + + channel.setDisChannelHandler(() => { + xhr.abort(); + }); + return Promise.resolve(); + } + + @PermissionVerify.API({ + listener() { + chrome.notifications.onClosed.addListener((id, user) => { + const ret = Cache.getInstance().get(`GM_notification:${id}`); + if (ret) { + const channel = ret; + channel.send({ event: "done", id, user }); + channel.disChannel(); + Cache.getInstance().del(`GM_notification:${id}`); + } + }); + chrome.notifications.onClicked.addListener((id) => { + const ret = Cache.getInstance().get(`GM_notification:${id}`); + if (ret) { + const channel = ret; + channel.send({ event: "click", id, index: undefined }); + channel.send({ event: "done", id, user: true }); + channel.disChannel(); + Cache.getInstance().del(`GM_notification:${id}`); + } + }); + chrome.notifications.onButtonClicked.addListener((id, buttonIndex) => { + const ret = Cache.getInstance().get(`GM_notification:${id}`); + if (ret) { + const channel = ret; + channel.send({ event: "click", id, index: buttonIndex }); + channel.send({ event: "done", id, user: true }); + channel.disChannel(); + Cache.getInstance().del(`GM_notification:${id}`); + } + }); + }, + }) + GM_notification(request: Request, channel: Channel): any { + if (request.params.length === 0) { + return channel.throw("param is failed"); + } + const details: GMTypes.NotificationDetails = request.params[0]; + const options: chrome.notifications.NotificationOptions = { + title: details.title || "ScriptCat", + message: details.text || "无消息内容", + iconUrl: + details.image || + getIcon(request.script) || + chrome.runtime.getURL("assets/logo.png"), + type: + isFirefox() || details.progress === undefined ? "basic" : "progress", + }; + if (!isFirefox()) { + options.silent = details.silent; + options.buttons = details.buttons; + } + + chrome.notifications.create(options, (notificationId) => { + Cache.getInstance().set(`GM_notification:${notificationId}`, channel); + channel.send({ event: "create", id: notificationId }); + if (details.timeout) { + setTimeout(() => { + chrome.notifications.clear(notificationId); + channel.send({ event: "done", id: notificationId, user: false }); + channel.disChannel(); + Cache.getInstance().del(`GM_notification:${notificationId}`); + }, details.timeout); + } + }); + + return true; + } + + @PermissionVerify.API() + GM_closeNotification(request: Request): Promise { + chrome.notifications.clear(request.params[0]); + const ret = Cache.getInstance().get( + `GM_notification:${request.params[0]}` + ); + if (ret) { + const channel = ret; + channel.send({ event: "done", id: request.params[0], user: false }); + Cache.getInstance().del(`GM_notification:${request.params[0]}`); + } + return Promise.resolve(true); + } + + @PermissionVerify.API() + GM_updateNotification(request: Request): Promise { + if (isFirefox()) { + return Promise.reject(new Error("firefox does not support this method")); + } + const id = request.params[0]; + const details: GMTypes.NotificationDetails = request.params[1]; + const options: chrome.notifications.NotificationOptions = { + title: details.title, + message: details.text, + iconUrl: details.image, + type: details.progress === undefined ? "basic" : "progress", + silent: details.silent, + progress: details.progress, + }; + chrome.notifications.update(id, options); + return Promise.resolve(true); + } + + @PermissionVerify.API() + GM_log(request: Request): Promise { + const message = request.params[0]; + const level = request.params[1] || "info"; + const labels = request.params[2] || {}; + LoggerCore.getLogger(labels).log(level, message, { + scriptId: request.scriptId, + component: "GM_log", + }); + return Promise.resolve(true); + } + + @PermissionVerify.API({ + listener: () => { + chrome.tabs.onRemoved.addListener((tabId) => { + const channel = ( + Cache.getInstance().get(`GM_openInTab:${tabId}`) + ); + if (channel) { + channel.send({ event: "onclose" }); + channel.disChannel(); + Cache.getInstance().del(`GM_openInTab:${tabId}`); + } + }); + }, + }) + GM_openInTab(request: Request, channel: Channel) { + const url = request.params[0]; + const options = request.params[1] || {}; + if (options.useOpen === true) { + const newWindow = window.open(url); + if (newWindow) { + // 由于不符合同源策略无法直接监听newWindow关闭事件,因此改用CDP方法监听 + // 由于window.open强制在前台打开标签,因此获取状态为{ active:true }的标签即为新标签 + chrome.tabs.query({ active: true }, ([tab]) => { + Cache.getInstance().set(`GM_openInTab:${tab.id}`, channel); + channel.send({ event: "oncreate", tabId: tab.id }); + }); + } else { + // 当新tab被浏览器阻止时window.open()会返回null 视为已经关闭 + // 似乎在Firefox中禁止在background页面使用window.open(),强制返回null + channel.send({ event: "onclose" }); + channel.disChannel(); + } + } else { + chrome.tabs.create({ url, active: options.active }, (tab) => { + Cache.getInstance().set(`GM_openInTab:${tab.id}`, channel); + channel.send({ event: "oncreate", tabId: tab.id }); + }); + } + } + + @PermissionVerify.API({ + link: "GM_openInTab", + }) + async GM_closeInTab(request: Request): Promise { + try { + await chrome.tabs.remove(request.params[0]); + } catch (e) { + this.logger.error("GM_closeInTab", Logger.E(e)); + } + return Promise.resolve(true); + } + + static tabData = new Map>(); + + @PermissionVerify.API({ + listener: () => { + chrome.tabs.onRemoved.addListener((tabId) => { + GMApi.tabData.forEach((value) => { + value.forEach((v, tabIdKey) => { + if (tabIdKey === tabId) { + value.delete(tabIdKey); + } + }); + }); + }); + }, + }) + GM_getTab(request: Request) { + return Promise.resolve( + GMApi.tabData + .get(request.scriptId) + ?.get(request.sender.tabId || request.sender.targetTag) + ); + } + + @PermissionVerify.API() + GM_saveTab(request: Request) { + const data = request.params[0]; + const tabId = request.sender.tabId || request.sender.targetTag; + if (!GMApi.tabData.has(request.scriptId)) { + GMApi.tabData.set(request.scriptId, new Map()); + } + GMApi.tabData.get(request.scriptId)?.set(tabId, data); + return Promise.resolve(true); + } + + @PermissionVerify.API() + GM_getTabs(request: Request) { + if (!GMApi.tabData.has(request.scriptId)) { + return Promise.resolve({}); + } + const resp: { [key: string | number]: object } = {}; + GMApi.tabData.get(request.scriptId)?.forEach((value, key) => { + resp[key] = value; + }); + return Promise.resolve(resp); + } + + @PermissionVerify.API() + GM_download(request: Request, channel: Channel) { + const config = request.params[0]; + // blob本地文件直接下载 + if (config.url.startsWith("blob:")) { + chrome.downloads.download( + { + url: config.url, + saveAs: config.saveAs, + filename: config.name, + }, + () => { + channel.send({ event: "onload" }); + } + ); + return; + } + // 使用ajax下载blob,再使用download api创建下载 + const xhr = new XMLHttpRequest(); + xhr.open(config.method || "GET", config.url, true); + xhr.responseType = "blob"; + const deal = (event: string, data?: any) => { + const removeXCat = new RegExp(`${this.systemConfig.scriptCatFlag}-`, "g"); + const respond: any = { + finalUrl: xhr.responseURL || config.url, + readyState: xhr.readyState, + status: xhr.status, + statusText: xhr.statusText, + responseHeaders: xhr.getAllResponseHeaders().replace(removeXCat, ""), + }; + if (data) { + Object.keys(data).forEach((key) => { + respond[key] = data[key]; + }); + } + channel.send({ event, data: respond }); + }; + xhr.onload = () => { + deal("onload"); + const url = URL.createObjectURL(xhr.response); + setTimeout(() => { + URL.revokeObjectURL(url); + }, 6000); + chrome.downloads.download({ + url, + saveAs: config.saveAs, + filename: config.name, + }); + }; + xhr.onerror = () => { + deal("onerror"); + }; + xhr.onprogress = (event) => { + const respond: GMTypes.XHRProgress = { + done: xhr.DONE, + lengthComputable: event.lengthComputable, + loaded: event.loaded, + total: event.total, + totalSize: event.total, + }; + deal("onprogress", respond); + }; + xhr.ontimeout = () => { + channel.send({ event: "ontimeout" }); + }; + setXhrHeader(this.systemConfig.scriptCatFlag, config, xhr); + + if (config.timeout) { + xhr.timeout = config.timeout; + } + + xhr.send(); + channel.setDisChannelHandler(() => { + xhr.abort(); + }); + } + + static clipboardData: { type?: string; data: string } | undefined; + + @PermissionVerify.API({ + listener() { + PermissionVerify.textarea.style.display = "none"; + document.documentElement.appendChild(PermissionVerify.textarea); + document.addEventListener("copy", (e: ClipboardEvent) => { + if (!GMApi.clipboardData || !e.clipboardData) { + return; + } + e.preventDefault(); + const { type, data } = GMApi.clipboardData; + e.clipboardData.setData(type || "text/plain", data); + GMApi.clipboardData = undefined; + }); + }, + }) + GM_setClipboard(request: Request) { + return new Promise((resolve) => { + GMApi.clipboardData = { + type: request.params[1], + data: request.params[0], + }; + PermissionVerify.textarea.focus(); + document.execCommand("copy", false, null); + resolve(undefined); + }); + } + + @PermissionVerify.API({ + confirm(request: Request) { + if (request.params[0] === "store") { + return Promise.resolve(true); + } + const detail = request.params[1]; + if (!detail.url && !detail.domain) { + return Promise.reject(new Error("there must be one of url or domain")); + } + let url: URL = {}; + if (detail.url) { + url = new URL(detail.url); + } else { + url.host = detail.domain || ""; + url.hostname = detail.domain || ""; + } + let flag = false; + if (request.script.metadata.connect) { + const { connect } = request.script.metadata; + for (let i = 0; i < connect.length; i += 1) { + if (url.hostname.endsWith(connect[i])) { + flag = true; + break; + } + } + } + if (!flag) { + return Promise.reject( + new Error("hostname must be in the definition of connect") + ); + } + const metadata: { [key: string]: string } = {}; + metadata[i18next.t("script_name")] = i18nName(request.script); + metadata[i18next.t("request_domain")] = url.host; + return Promise.resolve({ + permission: "cookie", + permissionValue: url.host, + title: i18next.t("access_cookie_content")!, + metadata, + describe: i18next.t("confirm_script_operation")!, + permissionContent: i18next.t("cookie_domain")!, + uuid: "", + }); + }, + }) + GM_cookie(request: Request) { + return new Promise((resolve, reject) => { + const param = request.params; + if (param.length !== 2) { + reject(new Error("there must be two parameters")); + return; + } + const detail = request.params[1]; + if (param[0] === "store") { + chrome.cookies.getAllCookieStores((res) => { + const data: any[] = []; + res.forEach((val) => { + if (detail.tabId) { + for (let n = 0; n < val.tabIds.length; n += 1) { + if (val.tabIds[n] === detail.tabId) { + data.push({ storeId: val.id }); + break; + } + } + } else { + data.push({ storeId: val.id }); + } + }); + resolve(data); + }); + return; + } + // url或者域名不能为空 + if (detail.url) { + detail.url = detail.url.trim(); + } + if (detail.domain) { + detail.domain = detail.domain.trim(); + } + if (!detail.url && !detail.domain) { + reject(new Error("there must be one of url or domain")); + return; + } + switch (param[0]) { + case "list": { + chrome.cookies.getAll( + { + domain: detail.domain, + name: detail.name, + path: detail.path, + secure: detail.secure, + session: detail.session, + url: detail.url, + storeId: detail.storeId, + }, + (cookies) => { + resolve(cookies); + } + ); + break; + } + case "delete": { + if (!detail.url || !detail.name) { + reject(new Error("delete operation must have url and name")); + return; + } + chrome.cookies.remove( + { + name: detail.name, + url: detail.url, + storeId: detail.storeId, + }, + () => { + resolve(undefined); + } + ); + break; + } + case "set": { + if (!detail.url || !detail.name) { + reject(new Error("set operation must have name and value")); + return; + } + chrome.cookies.set( + { + url: detail.url, + name: detail.name, + domain: detail.domain, + value: detail.value, + expirationDate: detail.expirationDate, + path: detail.path, + httpOnly: detail.httpOnly, + secure: detail.secure, + storeId: detail.storeId, + }, + () => { + resolve(undefined); + } + ); + break; + } + default: { + reject(new Error("action can only be: get, set, delete, store")); + break; + } + } + }); + } + + @PermissionVerify.API() + GM_registerMenuCommand(request: Request, channel: Channel) { + GMApi.hook.trigger("registerMenu", request, channel); + channel.setDisChannelHandler(() => { + GMApi.hook.trigger("unregisterMenu", request.params[0], request); + }); + return Promise.resolve(); + } + + @PermissionVerify.API() + GM_unregisterMenuCommand(request: Request) { + GMApi.hook.trigger("unregisterMenu", request.params[0], request); + } + + @PermissionVerify.API() + CAT_userConfig(request: Request) { + chrome.tabs.create({ + url: `/src/options.html#/?userConfig=${request.scriptId}`, + active: true, + }); + } + + @PermissionVerify.API({ + confirm: (request: Request) => { + const [action, details] = request.params; + if (action === "config") { + return Promise.resolve(true); + } + const dir = details.baseDir ? details.baseDir : request.script.uuid; + const metadata: { [key: string]: string } = {}; + metadata[i18next.t("script_name")] = i18nName(request.script); + return Promise.resolve({ + permission: "file_storage", + permissionValue: dir, + title: i18next.t("script_operation_title"), + metadata, + describe: i18next.t("script_operation_description", { dir }), + wildcard: false, + permissionContent: i18next.t("script_permission_content"), + } as ConfirmParam); + }, + alias: ["GM.xmlHttpRequest"], + }) + // eslint-disable-next-line consistent-return + async CAT_fileStorage(request: Request, channel: Channel) { + const [action, details] = request.params; + if (action === "config") { + chrome.tabs.create({ + url: `/src/options.html#/setting`, + active: true, + }); + return Promise.resolve(true); + } + const fsConfig = this.systemConfig.catFileStorage; + if (fsConfig.status === "unset") { + return channel.throw({ code: 1, error: "file storage is disable" }); + } + if (fsConfig.status === "error") { + return channel.throw({ code: 2, error: "file storge is error" }); + } + let fs: FileSystem; + const baseDir = `ScriptCat/app/${ + details.baseDir ? details.baseDir : request.script.uuid + }`; + try { + fs = await FileSystemFactory.create( + fsConfig.filesystem, + fsConfig.params[fsConfig.filesystem] + ); + await FileSystemFactory.mkdirAll(fs, baseDir); + fs = await fs.openDir(baseDir); + } catch (e: any) { + if (isWarpTokenError(e)) { + fsConfig.status = "error"; + this.systemConfig.catFileStorage = fsConfig; + return channel.throw({ code: 2, error: e.error.message }); + } + return channel.throw({ code: 8, error: e.message }); + } + switch (action) { + case "list": + fs.list() + .then((list) => { + list.forEach((file) => { + (file).absPath = file.path; + file.path = joinPath( + file.path.substring(file.path.indexOf(baseDir) + baseDir.length) + ); + }); + channel.send({ action: "onload", data: list }); + channel.disChannel(); + }) + .catch((e) => { + channel.throw({ code: 3, error: e.message }); + }); + break; + case "upload": + // eslint-disable-next-line no-case-declarations + const w = await fs.create(details.path); + w.write(await (await fetch(details.data)).blob()) + .then(() => { + channel.send({ action: "onload", data: true }); + channel.disChannel(); + }) + .catch((e) => { + channel.throw({ code: 4, error: e.message }); + }); + break; + case "download": + // eslint-disable-next-line no-case-declarations, no-undef + const info = details.file; + fs = await fs.openDir(`${info.path}`); + // eslint-disable-next-line no-case-declarations + const r = await fs.open({ + fsid: (info).fsid, + name: info.name, + path: info.absPath, + size: info.size, + digest: info.digest, + createtime: info.createtime, + updatetime: info.updatetime, + }); + r.read("blob") + .then((blob) => { + const url = URL.createObjectURL(blob); + setTimeout(() => { + URL.revokeObjectURL(url); + }, 6000); + channel.send({ action: "onload", data: url }); + channel.disChannel(); + }) + .catch((e) => { + channel.throw({ code: 5, error: e.message }); + }); + break; + case "delete": + fs.delete(`${details.path}`) + .then(() => { + channel.send({ action: "onload", data: true }); + channel.disChannel(); + }) + .catch((e) => { + channel.throw({ code: 6, error: e.message }); + }); + break; + default: + channel.disChannel(); + break; + } + } +} diff --git a/src/runtime/background/permission_verify.ts b/src/runtime/background/permission_verify.ts new file mode 100644 index 0000000..d5e78d7 --- /dev/null +++ b/src/runtime/background/permission_verify.ts @@ -0,0 +1,410 @@ +// gm api 权限验证 +import Cache from "@App/app/cache"; +import { Permission, PermissionDAO } from "@App/app/repo/permission"; +import { Script } from "@App/app/repo/scripts"; +import CacheKey from "@App/pkg/utils/cache_key"; +import { v4 as uuidv4 } from "uuid"; +import MessageQueue from "@App/pkg/utils/message_queue"; +import IoC from "@App/app/ioc"; +import { MessageHander } from "@App/app/message/message"; +import { Api, Request } from "./gm_api"; + +export interface ConfirmParam { + // 权限名 + permission: string; + // 权限值 + permissionValue?: string; + // 确认权限标题 + title?: string; + // 权限详情内容 + metadata?: { [key: string]: string }; + // 权限描述 + describe?: string; + // 是否通配 + wildcard?: boolean; + // 权限内容 + permissionContent?: string; +} + +export interface UserConfirm { + allow: boolean; + type: number; // 1: 允许一次 2: 临时允许全部 3: 临时允许此 4: 永久允许全部 5: 永久允许此 +} + +export interface ApiParam { + // 默认提供的函数 + default?: boolean; + // 是否只有后台环境中才能执行 + background?: boolean; + // 是否需要弹出页面让用户进行确认 + confirm?: (request: Request) => Promise; + // 监听方法 + listener?: () => void; + // 别名 + alias?: string[]; + // 关联 + link?: string; +} + +export interface ApiValue { + api: Api; + param: ApiParam; +} + +export interface IPermissionVerify { + verify(request: Request, api: ApiValue): Promise; +} + +export default class PermissionVerify { + static apis: Map = new Map(); + + static textarea: HTMLTextAreaElement = document.createElement("textarea"); + + public static API(param: ApiParam = {}) { + return ( + target: any, + propertyName: string, + descriptor: PropertyDescriptor + ) => { + const key = propertyName; + if (param.listener) { + param.listener(); + } + PermissionVerify.apis.set(key, { + api: descriptor.value, + param, + }); + // 兼容GM.* + const dot = key.replace("_", "."); + if (dot !== key) { + PermissionVerify.apis.set(dot, { + api: descriptor.value, + param, + }); + if (param.alias) { + param.alias.push(dot); + } else { + param.alias = [dot]; + } + } + + // 处理别名 + if (param.alias) { + param.alias.forEach((alias) => { + PermissionVerify.apis.set(alias, { + api: descriptor.value, + param, + }); + }); + } + }; + } + + permissionDAO: PermissionDAO; + + // 确认队列 + confirmQueue: MessageQueue<{ + request: Request; + confirm: ConfirmParam | boolean; + resolve: (value: boolean) => void; + reject: (reason: any) => void; + }> = new MessageQueue(); + + removePermissionCache(scriptId: number) { + // 先删除缓存 + Cache.getInstance() + .list() + .forEach((key) => { + if (key.startsWith(`permission:${scriptId.toString()}:`)) { + Cache.getInstance().del(key); + } + }); + } + + constructor() { + this.permissionDAO = new PermissionDAO(); + // 监听用户确认消息 + const message = IoC.instance(MessageHander); + message.setHandler( + "permissionConfirm", + (_action, data: { uuid: string; userConfirm: UserConfirm }) => { + const confirm = this.confirmMap.get(data.uuid); + if (!confirm) { + if (data.userConfirm.type === 0) { + // 忽略 + return Promise.resolve(undefined); + } + return Promise.reject(new Error("confirm not found")); + } + this.confirmMap.delete(data.uuid); + confirm.resolve(data.userConfirm); + return Promise.resolve(true); + } + ); + // 监听获取用户确认消息 + message.setHandler("getConfirm", (_action, uuid: string) => { + const data = this.confirmMap.get(uuid); + if (!data) { + return Promise.reject(new Error("uuid not found")); + } + // 查询允许统配的有多少个相同等待确认权限 + let likeNum = 0; + if (data.confirm.wildcard) { + this.confirmQueue.list.forEach((value) => { + const confirm = value.confirm as ConfirmParam; + if ( + confirm.wildcard && + value.request.scriptId === data.script.id && + confirm.permission === data.confirm.permission + ) { + likeNum += 1; + } + }); + } + return Promise.resolve({ + script: data.script, + confirm: data.confirm, + likeNum, + }); + }); + // 监听删除权限 + message.setHandler( + "deletePermission", + async (_action, data: { scriptId: number; confirm: ConfirmParam }) => { + // 先删除缓存 + this.removePermissionCache(data.scriptId); + // 再删除数据库 + const m = await this.permissionDAO.findOne({ + scriptId: data.scriptId, + permission: data.confirm.permission, + permissionValue: data.confirm.permissionValue || "", + }); + if (!m) { + return Promise.resolve(true); + } + await this.permissionDAO.delete(m.id); + return Promise.resolve(true); + } + ); + // 监听添加权限 + message.setHandler( + "addPermission", + async (_action, data: { scriptId: number; permission: Permission }) => { + // 先删除缓存 + this.removePermissionCache(data.scriptId); + // 从数据库中查询是否有此权限 + const m = await this.permissionDAO.findOne({ + scriptId: data.scriptId, + permission: data.permission.permission, + permissionValue: data.permission.permissionValue || "", + }); + if (!m) { + // 没有添加 + await this.permissionDAO.save(data.permission); + return Promise.resolve(true); + } + // 有则更新 + data.permission.id = m.id; + data.permission.createtime = m.createtime; + data.permission.updatetime = new Date().getTime(); + this.permissionDAO.update(m.id, data.permission); + return Promise.resolve(true); + } + ); + // 监听重置权限 + message.setHandler( + "resetPermission", + async (_action, data: { scriptId: number }) => { + // 先删除缓存 + this.removePermissionCache(data.scriptId); + // 从数据库中查询是否有此权限 + await this.permissionDAO.delete({ + scriptId: data.scriptId, + }); + return Promise.resolve(true); + } + ); + this.dealConfirmQueue(); + } + + // 验证是否有权限 + verify(request: Request, api: ApiValue): Promise { + if (api.param.default) { + return Promise.resolve(true); + } + // 没有其它条件,从metadata.grant中判断 + const { grant } = request.script.metadata; + if (!grant) { + return Promise.reject(new Error("grant is undefined")); + } + for (let i = 0; i < grant.length; i += 1) { + if ( + // 名称相等 + grant[i] === request.api || + // 别名相等 + (api.param.alias && api.param.alias.includes(grant[i])) || + // 有关联的 + grant[i] === api.param.link + ) { + // 需要用户确认 + if (api.param.confirm) { + return this.pushConfirmQueue(request, api); + } + return Promise.resolve(true); + } + } + return Promise.reject(new Error("permission not requested")); + } + + async dealConfirmQueue() { + // 处理确认队列 + const data = await this.confirmQueue.pop(); + if (!data) { + this.dealConfirmQueue(); + return; + } + try { + const ret = await this.confirm(data.request, data.confirm); + data.resolve(ret); + } catch (e) { + data.reject(e); + } + this.dealConfirmQueue(); + } + + // 确认队列,为了防止一次性打开过多的窗口 + async pushConfirmQueue(request: Request, api: ApiValue): Promise { + const confirm = await api.param.confirm!(request); + if (confirm === true) { + return Promise.resolve(true); + } + return new Promise((resolve, reject) => { + this.confirmQueue.push({ request, confirm, resolve, reject }); + }); + } + + async confirm( + request: Request, + confirm: boolean | ConfirmParam + ): Promise { + if (typeof confirm === "boolean") { + return confirm; + } + const cacheKey = CacheKey.permissionConfirm(request.script.id, confirm); + // 从数据库中查询是否有此权限 + const ret = await Cache.getInstance().getOrSet(cacheKey, async () => { + let model = await this.permissionDAO.findOne({ + scriptId: request.scriptId, + permission: confirm.permission, + permissionValue: confirm.permissionValue || "", + }); + if (!model) { + // 允许通配 + if (confirm.wildcard) { + model = await this.permissionDAO.findOne({ + scriptId: request.scriptId, + permission: confirm.permission, + permissionValue: "*", + }); + } + } + return Promise.resolve(model); + }); + // 有查询到结果,进入判断,不再需要用户确认 + if (ret) { + if (ret.allow) { + return Promise.resolve(true); + } + // 权限拒绝 + return Promise.reject(new Error("permission denied")); + } + // 没有权限,则弹出页面让用户进行确认 + const userConfirm = await this.confirmWindow(request.script, confirm); + // 成功存入数据库 + const model = { + id: 0, + scriptId: request.scriptId, + permission: confirm.permission, + permissionValue: "", + allow: userConfirm.allow, + createtime: new Date().getTime(), + updatetime: 0, + }; + switch (userConfirm.type) { + case 4: + case 2: { + // 通配 + model.permissionValue = "*"; + break; + } + case 5: + case 3: { + model.permissionValue = confirm.permissionValue || ""; + break; + } + default: + break; + } + // 临时 放入缓存 + if (userConfirm.type >= 2) { + Cache.getInstance().set(cacheKey, model); + } + // 总是 放入数据库 + if (userConfirm.type >= 4) { + const oldConfirm = await this.permissionDAO.findOne({ + scriptId: request.scriptId, + permission: model.permission, + permissionValue: model.permissionValue, + }); + if (!oldConfirm) { + await this.permissionDAO.save(model); + } else { + await this.permissionDAO.update(oldConfirm.id, model); + } + } + if (userConfirm.allow) { + return Promise.resolve(true); + } + return Promise.reject(new Error("permission not allowed")); + } + + // 确认map + confirmMap: Map< + string, + { + confirm: ConfirmParam; + script: Script; + resolve: (value: UserConfirm) => void; + reject: (reason: any) => void; + } + > = new Map(); + + // 弹出窗口让用户进行确认 + async confirmWindow( + script: Script, + confirm: ConfirmParam + ): Promise { + return new Promise((resolve, reject) => { + const uuid = uuidv4(); + // 超时处理 + const timeout = setTimeout(() => { + this.confirmMap.delete(uuid); + reject(new Error("permission confirm timeout")); + }, 40 * 1000); + // 保存到map中 + this.confirmMap.set(uuid, { + confirm, + script, + resolve: (value: UserConfirm) => { + clearTimeout(timeout); + resolve(value); + }, + reject, + }); + // 打开窗口 + chrome.tabs.create({ + url: chrome.runtime.getURL(`src/confirm.html?uuid=${uuid}`), + }); + }); + } +} diff --git a/src/runtime/background/runtime.ts b/src/runtime/background/runtime.ts new file mode 100644 index 0000000..de57f2c --- /dev/null +++ b/src/runtime/background/runtime.ts @@ -0,0 +1,735 @@ +// 脚本运行时,主要负责脚本的加载和匹配 +// 油猴脚本将监听页面的创建,将代码注入到页面中 +import MessageSandbox from "@App/app/message/sandbox"; +import LoggerCore from "@App/app/logger/core"; +import Logger from "@App/app/logger/logger"; +import { + Script, + SCRIPT_RUN_STATUS, + SCRIPT_STATUS_ENABLE, + SCRIPT_TYPE_NORMAL, + ScriptDAO, + ScriptRunResouce, + SCRIPT_RUN_STATUS_RUNNING, + Metadata, +} from "@App/app/repo/scripts"; +import ResourceManager from "@App/app/service/resource/manager"; +import ValueManager from "@App/app/service/value/manager"; +import { dealScript, randomString } from "@App/pkg/utils/utils"; +import { UrlInclude, UrlMatch } from "@App/pkg/utils/match"; +import { + MessageHander, + MessageSender, + TargetTag, +} from "@App/app/message/message"; +import ScriptManager from "@App/app/service/script/manager"; +import { Channel } from "@App/app/message/channel"; +import IoC from "@App/app/ioc"; +import Manager from "@App/app/service/manager"; +import Hook from "@App/app/service/hook"; +import { i18nName } from "@App/locales/locales"; +import { compileInjectScript, compileScriptCode } from "../content/utils"; +import GMApi, { Request } from "./gm_api"; +import { genScriptMenu } from "./utils"; + +export type RuntimeEvent = "start" | "stop" | "watchRunStatus"; + +export type ScriptMenuItem = { + id: number; + name: string; + accessKey?: string; + sender: MessageSender; + channelFlag: string; +}; + +export type ScriptMenu = { + id: number; + name: string; + enable: boolean; + updatetime: number; + hasUserConfig: boolean; + metadata: Metadata; + runStatus?: SCRIPT_RUN_STATUS; + runNum: number; + runNumByIframe: number; + menus?: ScriptMenuItem[]; + customExclude?: string[]; +}; + +// 后台脚本将会将代码注入到沙盒中 +@IoC.Singleton(MessageHander, ResourceManager, ValueManager) +export default class Runtime extends Manager { + messageSandbox?: MessageSandbox; + + scriptDAO: ScriptDAO; + + resourceManager: ResourceManager; + + valueManager: ValueManager; + + logger: Logger; + + match: UrlMatch = new UrlMatch(); + + include: UrlInclude = new UrlInclude(); + + // 自定义排除 + customizeExclude: UrlMatch = new UrlMatch(); + + static hook = new Hook<"runStatus">(); + + // 运行中和开启的后台脚本 + runBackScript: Map = new Map(); + + constructor( + message: MessageHander, + resourceManager: ResourceManager, + valueManager: ValueManager + ) { + super(message, "runtime"); + this.scriptDAO = new ScriptDAO(); + this.resourceManager = resourceManager; + this.valueManager = valueManager; + this.logger = LoggerCore.getInstance().logger({ component: "runtime" }); + ScriptManager.hook.addListener("upsert", this.scriptUpdate.bind(this)); + ScriptManager.hook.addListener("delete", this.scriptDelete.bind(this)); + ScriptManager.hook.addListener("enable", this.scriptUpdate.bind(this)); + ScriptManager.hook.addListener("disable", this.scriptUpdate.bind(this)); + } + + start(): void { + // 监听前端消息 + // 此处是处理执行单次脚本的消息 + this.listenEvent("start", (id) => { + return this.scriptDAO + .findById(id) + .then((script) => { + if (!script) { + throw new Error("script not found"); + } + // 因为如果直接引用Runtime,会导致循环依赖,暂时这样处理,后面再梳理梳理 + return this.startBackgroundScript(script); + }) + .catch((e) => { + this.logger.error("run error", Logger.E(e)); + throw e; + }); + }); + + this.listenEvent("stop", (id) => { + return this.scriptDAO + .findById(id) + .then((script) => { + if (!script) { + throw new Error("script not found"); + } + // 因为如果直接引用Runtime,会导致循环依赖,暂时这样处理 + return this.stopBackgroundScript(id); + }) + .catch((e) => { + this.logger.error("stop error", Logger.E(e)); + throw e; + }); + }); + // 监听脚本运行状态 + this.listenScriptRunStatus(); + + // 启动普通脚本 + this.scriptDAO.table.toArray((items) => { + items.forEach((item) => { + // 容错处理 + if (!item) { + this.logger.error("script is null"); + return; + } + if (item.type !== SCRIPT_TYPE_NORMAL) { + return; + } + // 加载所有的脚本 + if (item.status === SCRIPT_STATUS_ENABLE) { + this.enable(item); + } else { + // 只处理未开启的普通页面脚本 + this.disable(item); + } + }); + }); + + // 接受消息,注入脚本 + // 获取注入源码 + + // 监听菜单创建 + const scriptMenu: Map< + number | TargetTag, + Map< + number, + { + request: Request; + channel: Channel; + }[] + > + > = new Map(); + GMApi.hook.addListener( + "registerMenu", + (request: Request, channel: Channel) => { + let senderId: number | TargetTag; + if (!request.sender.tabId) { + // 非页面脚本 + senderId = request.sender.targetTag; + } else { + senderId = request.sender.tabId; + } + let tabMap = scriptMenu.get(senderId); + if (!tabMap) { + tabMap = new Map(); + scriptMenu.set(senderId, tabMap); + } + let menuArr = tabMap.get(request.scriptId); + if (!menuArr) { + menuArr = []; + tabMap.set(request.scriptId, menuArr); + } + // 查询菜单是否已经存在 + for (let i = 0; i < menuArr.length; i += 1) { + // id 相等 跳过,选第一个,并close链接 + if (menuArr[i].request.params[0] === request.params[0]) { + channel.disChannel(); + return; + } + } + menuArr.push({ request, channel }); + // 偷懒行为, 直接重新生成菜单 + genScriptMenu(senderId, scriptMenu); + } + ); + GMApi.hook.addListener("unregisterMenu", (id, request: Request) => { + let senderId: number | TargetTag; + if (!request.sender.tabId) { + // 非页面脚本 + senderId = request.sender.targetTag; + } else { + senderId = request.sender.tabId; + } + const tabMap = scriptMenu.get(senderId); + if (tabMap) { + const menuArr = tabMap.get(request.scriptId); + if (menuArr) { + // 从菜单数组中遍历删除 + for (let i = 0; i < menuArr.length; i += 1) { + if (menuArr[i].request.params[0] === id) { + menuArr.splice(i, 1); + break; + } + } + if (menuArr.length === 0) { + tabMap.delete(request.scriptId); + } + } + if (!tabMap.size) { + scriptMenu.delete(senderId); + } + } + // 偷懒行为 + genScriptMenu(senderId, scriptMenu); + }); + + // 监听页面切换加载菜单 + chrome.tabs.onActivated.addListener((activeInfo) => { + genScriptMenu(activeInfo.tabId, scriptMenu); + }); + + Runtime.hook.addListener("runStatus", async (scriptId: number) => { + const script = await this.scriptDAO.findById(scriptId); + if (!script) { + return; + } + if ( + script.status !== SCRIPT_STATUS_ENABLE && + script.runStatus !== "running" + ) { + // 没开启并且不是运行中的脚本,删除 + this.runBackScript.delete(scriptId); + } else { + // 否则进行一次更新 + this.runBackScript.set(scriptId, script); + } + }); + + // 记录运行次数与iframe运行 + const runScript = new Map< + number, + Map + >(); + const addRunScript = ( + tabId: number, + script: Script, + iframe: boolean, + num: number = 1 + ) => { + let scripts = runScript.get(tabId); + if (!scripts) { + scripts = new Map(); + runScript.set(tabId, scripts); + } + let scriptNum = scripts.get(script.id); + if (!scriptNum) { + scriptNum = { script, runNum: 0, runNumByIframe: 0 }; + scripts.set(script.id, scriptNum); + } + if (script.status === SCRIPT_STATUS_ENABLE) { + scriptNum.runNum += num; + if (iframe) { + scriptNum.runNumByIframe += num; + } + } + }; + chrome.tabs.onRemoved.addListener((tabId) => { + runScript.delete(tabId); + }); + // 给popup页面获取运行脚本,与菜单 + this.message.setHandler( + "queryPageScript", + async (action: string, { url, tabId }: any) => { + const tabMap = scriptMenu.get(tabId); + const run = runScript.get(tabId); + let matchScripts = []; + if (!run) { + matchScripts = this.matchUrl(url).map((item) => { + return { runNum: 0, runNumByIframe: 0, script: item }; + }); + } else { + matchScripts = Array.from(run.values()); + } + const allPromise: Promise[] = matchScripts.map( + async (item) => { + const menus: ScriptMenuItem[] = []; + if (tabMap) { + tabMap.get(item.script.id)?.forEach((scriptItem) => { + menus.push({ + name: scriptItem.request.params[1], + accessKey: scriptItem.request.params[2], + id: scriptItem.request.params[0], + sender: scriptItem.request.sender, + channelFlag: scriptItem.channel.flag, + }); + }); + } + const script = await this.scriptDAO.findById(item.script.id); + if (!script) { + return { + id: item.script.id, + name: i18nName(item.script), + enable: item.script.status === SCRIPT_STATUS_ENABLE, + updatetime: item.script.updatetime || item.script.createtime, + metadata: item.script.metadata, + hasUserConfig: !!item.script.config, + runNum: item.runNum, + runNumByIframe: item.runNumByIframe, + customExclude: + item.script.selfMetadata && item.script.selfMetadata.exclude, + menus, + }; + } + return { + id: script.id, + name: i18nName(script), + enable: script.status === SCRIPT_STATUS_ENABLE, + updatetime: script.updatetime || script.createtime, + metadata: item.script.metadata, + hasUserConfig: !!script?.config, + runNum: item.runNum, + runNumByIframe: item.runNumByIframe, + customExclude: script.selfMetadata && script.selfMetadata.exclude, + menus, + }; + } + ); + + const scriptList: ScriptMenu[] = await Promise.all(allPromise); + + const backScriptList: ScriptMenu[] = []; + const sandboxMenuMap = scriptMenu.get("sandbox"); + this.runBackScript.forEach((item) => { + const menus: ScriptMenuItem[] = []; + if (sandboxMenuMap) { + sandboxMenuMap?.get(item.id)?.forEach((scriptItem) => { + menus.push({ + name: scriptItem.request.params[1], + accessKey: scriptItem.request.params[2], + id: scriptItem.request.params[0], + sender: scriptItem.request.sender, + channelFlag: scriptItem.channel.flag, + }); + }); + } + + backScriptList.push({ + id: item.id, + name: item.name, + enable: item.status === SCRIPT_STATUS_ENABLE, + updatetime: item.updatetime || item.createtime, + metadata: item.metadata, + runStatus: item.runStatus, + hasUserConfig: !!item.config, + runNum: + item.runStatus && item.runStatus === SCRIPT_RUN_STATUS_RUNNING + ? 1 + : 0, + menus, + runNumByIframe: 0, + }); + }); + return Promise.resolve({ + scriptList, + backScriptList, + }); + } + ); + + // content页发送页面加载完成消息,注入脚本 + this.message.setHandler( + "pageLoad", + (_action: string, data: any, sender: MessageSender) => { + return new Promise((resolve) => { + if (!sender) { + return; + } + if (!(sender.url && sender.tabId)) { + return; + } + if (sender.frameId === undefined) { + // 清理之前的数据 + runScript.delete(sender.tabId); + } + // 未开启 + if (localStorage.enable_script === "false") { + return; + } + const exclude = this.customizeExclude.match(sender.url); + // 自定义排除的, buildScriptRunResource时会将selfMetadata合并,所以后续不需要再处理metadata.exclude,这算是一个隐性的坑,后面看看要不要处理 + exclude.forEach((val) => { + addRunScript(sender.tabId!, val, false, 0); + }); + const filter: ScriptRunResouce[] = this.matchUrl( + sender.url, + (script) => { + // 如果是iframe,判断是否允许在iframe里运行 + if (sender.frameId !== undefined) { + if (script.metadata.noframes) { + return true; + } + addRunScript(sender.tabId!, script, true); + return script.status !== SCRIPT_STATUS_ENABLE; + } + addRunScript(sender.tabId!, script, false); + return script.status !== SCRIPT_STATUS_ENABLE; + } + ); + + if (!filter.length) { + resolve({ scripts: [] }); + return; + } + + resolve({ scripts: filter }); + + // 注入脚本 + filter.forEach((script) => { + let runAt = "document_idle"; + if (script.metadata["run-at"]) { + [runAt] = script.metadata["run-at"]; + } + switch (runAt) { + case "document-body": + case "document-start": + runAt = "document_start"; + break; + case "document-end": + runAt = "document_end"; + break; + case "document-idle": + default: + runAt = "document_idle"; + break; + } + chrome.tabs.executeScript(sender.tabId!, { + frameId: sender.frameId, + code: `(function(){ + let temp = document.createElementNS("http://www.w3.org/1999/xhtml", "script"); + temp.setAttribute('type', 'text/javascript'); + temp.innerHTML = "${script.code}"; + temp.className = "injected-js"; + document.documentElement.appendChild(temp); + temp.remove(); + }())`, + runAt, + }); + }); + + // 角标和脚本 + chrome.browserAction.getBadgeText( + { + tabId: sender.tabId, + }, + (res: string) => { + chrome.browserAction.setBadgeText({ + text: (filter.length + (parseInt(res, 10) || 0)).toString(), + tabId: sender.tabId, + }); + } + ); + chrome.browserAction.setBadgeBackgroundColor({ + color: "#4e5969", + tabId: sender.tabId, + }); + }); + } + ); + } + + setMessageSandbox(messageSandbox: MessageSandbox) { + this.messageSandbox = messageSandbox; + } + + // 启动沙盒相关脚本 + startSandbox(messageSandbox: MessageSandbox) { + this.messageSandbox = messageSandbox; + this.scriptDAO.table.toArray((items) => { + items.forEach((item) => { + // 容错处理 + if (!item) { + this.logger.error("script is null"); + return; + } + if (item.type === SCRIPT_TYPE_NORMAL) { + return; + } + // 加载所有的脚本 + if (item.status === SCRIPT_STATUS_ENABLE) { + this.enable(item); + this.runBackScript.set(item.id, item); + } + }); + }); + } + + listenScriptRunStatus() { + // 监听沙盒发送的脚本运行状态消息 + this.message.setHandler( + "scriptRunStatus", + (action, [scriptId, runStatus, error, nextruntime]: any) => { + this.scriptDAO.update(scriptId, { + runStatus, + lastruntime: new Date().getTime(), + nextruntime, + error, + }); + Runtime.hook.trigger("runStatus", scriptId, runStatus); + } + ); + // 处理前台发送的脚本运行状态监听请求 + this.message.setHandlerWithChannel("watchRunStatus", (channel) => { + const hook = (scriptId: number, status: SCRIPT_RUN_STATUS) => { + channel.send([scriptId, status]); + }; + Runtime.hook.addListener("runStatus", hook); + channel.setDisChannelHandler(() => { + Runtime.hook.removeListener("runStatus", hook); + }); + }); + } + + // 脚本发生变动 + async scriptUpdate(script: Script): Promise { + // 脚本更新先更新资源 + await this.resourceManager.checkScriptResource(script); + if (script.status === SCRIPT_STATUS_ENABLE) { + return this.enable(script as ScriptRunResouce); + } + return this.disable(script); + } + + matchUrl(url: string, filterFunc?: (script: Script) => boolean) { + const scripts = this.match.match(url); + // 再include中匹配 + scripts.push(...this.include.match(url)); + const filter: { [key: string]: ScriptRunResouce } = {}; + // 去重 + scripts.forEach((script) => { + if (filterFunc && filterFunc(script)) { + return; + } + filter[script.id] = script; + }); + // 转换成数组 + return Object.keys(filter).map((key) => filter[key]); + } + + // 脚本删除 + async scriptDelete(script: Script): Promise { + // 清理匹配资源 + if (script.type === SCRIPT_TYPE_NORMAL) { + this.match.del(script); + this.include.del(script); + } else { + this.unloadBackgroundScript(script); + } + return Promise.resolve(true); + } + + // 脚本开启 + async enable(script: Script): Promise { + // 编译脚本运行资源 + const scriptRes = await this.buildScriptRunResource(script); + if (script.type !== SCRIPT_TYPE_NORMAL) { + return this.loadBackgroundScript(scriptRes); + } + return this.loadPageScript(scriptRes); + } + + // 脚本关闭 + disable(script: Script): Promise { + if (script.type !== SCRIPT_TYPE_NORMAL) { + return this.unloadBackgroundScript(script); + } + return this.unloadPageScript(script); + } + + // 加载页面脚本 + loadPageScript(script: ScriptRunResouce) { + // 重构code + const logger = this.logger.with({ + scriptId: script.id, + name: script.name, + }); + script.code = dealScript(compileInjectScript(script)); + + this.match.del(script); + this.include.del(script); + if (script.metadata.match) { + script.metadata.match.forEach((url) => { + try { + this.match.add(url, script); + } catch (e) { + logger.error("url load error", Logger.E(e)); + } + }); + } + if (script.metadata.include) { + script.metadata.include.forEach((url) => { + try { + this.include.add(url, script); + } catch (e) { + logger.error("url load error", Logger.E(e)); + } + }); + } + if (script.metadata.exclude) { + script.metadata.exclude.forEach((url) => { + try { + this.include.exclude(url, script); + this.match.exclude(url, script); + } catch (e) { + logger.error("url load error", Logger.E(e)); + } + }); + } + if (script.selfMetadata && script.selfMetadata.exclude) { + script.selfMetadata.exclude.forEach((url) => { + try { + this.customizeExclude.add(url, script); + } catch (e) { + logger.error("url load error", Logger.E(e)); + } + }); + } + return Promise.resolve(true); + } + + // 卸载页面脚本 + unloadPageScript(script: Script) { + return this.loadPageScript(script); + } + + // 加载并启动后台脚本 + loadBackgroundScript(script: ScriptRunResouce): Promise { + this.runBackScript.set(script.id, script); + return new Promise((resolve, reject) => { + // 清除重试数据 + script.nextruntime = 0; + this.messageSandbox + ?.syncSend("enable", script) + .then(() => { + resolve(true); + }) + .catch((err) => { + this.logger.error("backscript load error", Logger.E(err)); + reject(err); + }); + }); + } + + // 卸载并停止后台脚本 + unloadBackgroundScript(script: Script): Promise { + this.runBackScript.delete(script.id); + return new Promise((resolve, reject) => { + this.messageSandbox + ?.syncSend("disable", script.id) + .then(() => { + resolve(true); + }) + .catch((err) => { + this.logger.error("backscript stop error", Logger.E(err)); + reject(err); + }); + }); + } + + async startBackgroundScript(script: Script) { + const scriptRes = await this.buildScriptRunResource(script); + this.messageSandbox?.syncSend("start", scriptRes); + return Promise.resolve(true); + } + + stopBackgroundScript(scriptId: number) { + return new Promise((resolve, reject) => { + this.messageSandbox + ?.syncSend("stop", scriptId) + .then((resp) => { + resolve(resp); + }) + .catch((err) => { + this.logger.error("backscript stop error", Logger.E(err)); + reject(err); + }); + }); + } + + async buildScriptRunResource(script: Script): Promise { + const ret: ScriptRunResouce = Object.assign(script); + + // 自定义配置 + if (ret.selfMetadata) { + ret.metadata = { ...ret.metadata }; + Object.keys(ret.selfMetadata).forEach((key) => { + ret.metadata[key] = ret.selfMetadata![key]; + }); + } + + ret.value = await this.valueManager.getScriptValues(ret); + + ret.resource = await this.resourceManager.getScriptResources(ret); + + ret.flag = randomString(16); + ret.sourceCode = ret.code; + ret.code = compileScriptCode(ret); + + ret.grantMap = {}; + + ret.metadata.grant?.forEach((val: string) => { + ret.grantMap[val] = "ok"; + }); + + return Promise.resolve(ret); + } +} diff --git a/src/runtime/background/utils.ts b/src/runtime/background/utils.ts new file mode 100644 index 0000000..14a8366 --- /dev/null +++ b/src/runtime/background/utils.ts @@ -0,0 +1,535 @@ +import LoggerCore from "@App/app/logger/core"; +import Logger from "@App/app/logger/logger"; +import { Channel } from "@App/app/message/channel"; +import { SCRIPT_STATUS_ENABLE, Script } from "@App/app/repo/scripts"; +import { isFirefox } from "@App/pkg/utils/utils"; +import MessageCenter from "@App/app/message/center"; +import IoC from "@App/app/ioc"; +import { Request } from "./gm_api"; +import Runtime from "./runtime"; + +export const unsafeHeaders: { [key: string]: boolean } = { + // 部分浏览器中并未允许 + "user-agent": true, + // 这两个是前缀 + "proxy-": true, + "sec-": true, + // cookie已经特殊处理 + cookie: true, + "accept-charset": true, + "accept-encoding": true, + "access-control-request-headers": true, + "access-control-request-method": true, + connection: true, + "content-length": true, + date: true, + dnt: true, + expect: true, + "feature-policy": true, + host: true, + "keep-alive": true, + origin: true, + referer: true, + te: true, + trailer: true, + "transfer-encoding": true, + upgrade: true, + via: true, +}; + +export const responseHeaders: { [key: string]: boolean } = { + "set-cookie": true, +}; + +export function isUnsafeHeaders(header: string) { + return unsafeHeaders[header.toLocaleLowerCase()]; +} + +export function isExtensionRequest( + details: chrome.webRequest.ResourceRequest & { originUrl?: string } +): boolean { + return !!( + (details.initiator && + chrome.runtime.getURL("").startsWith(details.initiator)) || + (details.originUrl && + details.originUrl.startsWith(chrome.runtime.getURL(""))) + ); +} + +// 监听web请求,处理unsafeHeaders +export function listenerWebRequest(headerFlag: string) { + const reqOpt = ["blocking", "requestHeaders"]; + const respOpt = ["blocking", "responseHeaders"]; + if (!isFirefox()) { + reqOpt.push("extraHeaders"); + respOpt.push("extraHeaders"); + } + const maxRedirects = new Map(); + const isRedirects = new Map(); + // 处理发送请求的unsafeHeaders + chrome.webRequest.onBeforeSendHeaders.addListener( + (details) => { + if (!isExtensionRequest(details)) { + return {}; + } + // 处理unsafeHeaders + let cookie = ""; + let setCookie = ""; + let anonymous = false; + let isGmXhr = false; + const requestHeaders: chrome.webRequest.HttpHeader[] = []; + const preRequestHeaders: { [key: string]: string | null } = {}; + details.requestHeaders?.forEach((val) => { + const lowerCase = val.name.toLowerCase(); + if (lowerCase.startsWith(`${headerFlag}-`)) { + const headerKey = lowerCase.substring(headerFlag.length + 1); + // 处理unsafeHeaders + switch (headerKey) { + case "cookie": + setCookie = val.value || ""; + break; + case "max-redirects": + maxRedirects.set(details.requestId, [ + 0, + parseInt(val.value || "", 10), + ]); + break; + case "anonymous": + anonymous = true; + break; + case "gm-xhr": + isGmXhr = true; + break; + default: + preRequestHeaders[headerKey] = val.value || null; + break; + } + return; + } + // 原生header + switch (lowerCase) { + case "cookie": + cookie = val.value || ""; + break; + default: + // 如果是unsafeHeaders,则判断是否已经有值,有值则不进行处理 + if ( + unsafeHeaders[lowerCase] || + lowerCase.startsWith("sec-") || + lowerCase.startsWith("proxy-") + ) { + // null表示不发送此header + if (preRequestHeaders[lowerCase] !== null) { + preRequestHeaders[lowerCase] = + preRequestHeaders[lowerCase] || val.value || ""; + } + } else { + requestHeaders.push(val); + } + break; + } + }); + // 不是由GM XHR发起的请求,不处理 + if (!isGmXhr) { + return {}; + } + // 匿名移除掉cookie + if (anonymous) { + cookie = ""; + } + // 有设置cookie,则进行处理 + if (setCookie) { + // 判断结尾是否有分号,没有则添加,然后进行拼接 + if (!cookie || cookie.endsWith(";")) { + cookie += setCookie; + } else { + cookie += `;${setCookie}`; + } + } + // 有cookie,则进行处理 + if (cookie) { + requestHeaders.push({ + name: "Cookie", + value: cookie, + }); + } + Object.keys(preRequestHeaders).forEach((key) => { + // null表示不发送此header + if (preRequestHeaders[key] !== null) { + requestHeaders.push({ + name: key, + value: preRequestHeaders[key]!, + }); + } + }); + return { + requestHeaders, + }; + }, + { + urls: [""], + }, + reqOpt + ); + // 处理无法读取的responseHeaders + chrome.webRequest.onHeadersReceived.addListener( + (details) => { + if (!isExtensionRequest(details)) { + // 判断是否为页面请求 + if ( + !(details.type === "main_frame" || details.type === "sub_frame") || + !isFirefox() + ) { + return {}; + } + // 判断页面上是否有脚本会运行,如果有判断是否有csp,有则移除csp策略 + const runtime = IoC.instance(Runtime) as Runtime; + // 这块代码与runtime里的pageLoad一样,考虑后面要不要优化 + const result = runtime.matchUrl(details.url, (script) => { + // 如果是iframe,判断是否允许在iframe里运行 + if (details.type === "sub_frame") { + if (script.metadata.noframes) { + return true; + } + return script.status !== SCRIPT_STATUS_ENABLE; + } + return script.status !== SCRIPT_STATUS_ENABLE; + }); + if (result.length > 0 && details.responseHeaders) { + // 移除csp + for (let i = 0; i < details.responseHeaders.length; i += 1) { + if ( + details.responseHeaders[i].name.toLowerCase() === + "content-security-policy" + ) { + details.responseHeaders[i].value = ""; + } + } + return { + responseHeaders: details.responseHeaders, + }; + } + return {}; + } + const appendHeaders: chrome.webRequest.HttpHeader[] = []; + details.responseHeaders?.forEach((val) => { + const lowerCase = val.name.toLowerCase(); + if (responseHeaders[lowerCase]) { + const copy = { ...val }; + copy.name = `${headerFlag}-${val.name}`; + appendHeaders.push(copy); + } + // 处理最大重定向次数 + if (lowerCase === "location") { + isRedirects.set(details.requestId, true); + const nums = maxRedirects.get(details.requestId); + if (nums) { + nums[0] += 1; + // 当前重定向次数大于最大重定向次数时,修改掉locatin,防止重定向 + if (nums[0] > nums[1]) { + val.name = `${headerFlag}-${val.name}`; + } + } + } + }); + details.responseHeaders?.push(...appendHeaders); + // 判断是否为重定向请求,如果是,将url注入到finalUrl + if (isRedirects.has(details.requestId)) { + details.responseHeaders?.push({ + name: `${headerFlag}-final-url`, + value: details.url, + }); + } + return { + responseHeaders: details.responseHeaders, + }; + }, + { + urls: [""], + }, + respOpt + ); + chrome.webRequest.onCompleted.addListener( + (details) => { + if (!isExtensionRequest(details)) { + return; + } + // 删除最大重定向数缓存 + maxRedirects.delete(details.requestId); + isRedirects.delete(details.requestId); + }, + { urls: [""] } + ); +} + +// 给xhr添加headers,包括unsafeHeaders +export function setXhrHeader( + headerFlag: string, + config: GMSend.XHRDetails, + xhr: XMLHttpRequest +) { + xhr.setRequestHeader(`${headerFlag}-gm-xhr`, "true"); + if (config.headers) { + let hasOrigin = false; + Object.keys(config.headers).forEach((key) => { + const lowKey = key.toLowerCase(); + if (lowKey === "origin") { + hasOrigin = true; + } + try { + if ( + unsafeHeaders[lowKey] || + lowKey.startsWith("sec-") || + lowKey.startsWith("proxy-") + ) { + xhr.setRequestHeader( + `${headerFlag}-${lowKey}`, + config.headers![key]! + ); + } else { + // 直接设置header + xhr.setRequestHeader(key, config.headers![key]!); + } + } catch (e) { + LoggerCore.getLogger(Logger.E(e)).error( + "GM XHR setRequestHeader error" + ); + } + }); + if (!hasOrigin) { + xhr.setRequestHeader(`${headerFlag}-origin`, ""); + } + } + if (config.maxRedirects !== undefined) { + xhr.setRequestHeader( + `${headerFlag}-max-redirects`, + config.maxRedirects.toString() + ); + } + if (config.cookie) { + try { + xhr.setRequestHeader(`${headerFlag}-cookie`, config.cookie); + } catch (e) { + LoggerCore.getLogger(Logger.E(e)).error( + "GM XHR setRequestHeader cookie error" + ); + } + } + if (config.anonymous) { + xhr.setRequestHeader(`${headerFlag}-anonymous`, "true"); + } +} + +export function getFetchHeader( + headerFlag: string, + config: GMSend.XHRDetails +): any { + const headers: { [key: string]: string } = {}; + headers[`${headerFlag}-gm-xhr`] = "true"; + if (config.headers) { + Object.keys(config.headers).forEach((key) => { + const lowKey = key.toLowerCase(); + if ( + unsafeHeaders[lowKey] || + lowKey.startsWith("sec-") || + lowKey.startsWith("proxy-") + ) { + headers[`${headerFlag}-${lowKey}`] = config.headers![key]!; + } else { + // 直接设置header + headers[key] = config.headers![key]!; + } + }); + } + if (config.maxRedirects !== undefined) { + headers[`${headerFlag}-max-redirects`] = config.maxRedirects.toString(); + } + if (config.cookie) { + headers[`${headerFlag}-cookie`] = config.cookie; + } + if (config.anonymous) { + headers[`${headerFlag}-anonymous`] = "true"; + } + return headers; +} + +export async function dealXhr( + headerFlag: string, + config: GMSend.XHRDetails, + xhr: XMLHttpRequest +): Promise { + let finalUrl = xhr.responseURL || config.url; + // 判断是否有headerFlag-final-url,有则替换finalUrl + const finalUrlHeader = xhr.getResponseHeader(`${headerFlag}-final-url`); + if (finalUrlHeader) { + finalUrl = finalUrlHeader; + } + const removeXCat = new RegExp(`${headerFlag}-`, "g"); + const respond: GMTypes.XHRResponse = { + finalUrl, + readyState: xhr.readyState, + status: xhr.status, + statusText: xhr.statusText, + responseHeaders: xhr.getAllResponseHeaders().replace(removeXCat, ""), + responseType: config.responseType, + }; + if (xhr.readyState === 4) { + if ( + config.responseType?.toLowerCase() === "arraybuffer" || + config.responseType?.toLowerCase() === "blob" + ) { + let blob: Blob; + if (xhr.response instanceof ArrayBuffer) { + blob = new Blob([xhr.response]); + respond.response = URL.createObjectURL(blob); + } else { + blob = xhr.response; + respond.response = URL.createObjectURL(blob); + } + try { + if (xhr.getResponseHeader("Content-Type")?.indexOf("text") !== -1) { + // 如果是文本类型,则尝试转换为文本 + respond.responseText = await blob.text(); + } + } catch (e) { + LoggerCore.getLogger(Logger.E(e)).error( + "GM XHR getResponseHeader error" + ); + } + setTimeout(() => { + URL.revokeObjectURL(respond.response); + }, 60e3); + } else if (config.responseType === "json") { + try { + respond.response = JSON.parse(xhr.responseText); + } catch (e) { + LoggerCore.getLogger(Logger.E(e)).error("GM XHR JSON parse error"); + } + try { + respond.responseText = xhr.responseText; + } catch (e) { + LoggerCore.getLogger(Logger.E(e)).error("GM XHR getResponseText error"); + } + } else { + try { + respond.response = xhr.response; + } catch (e) { + LoggerCore.getLogger(Logger.E(e)).error("GM XHR response error"); + } + try { + respond.responseText = xhr.responseText || undefined; + } catch (e) { + LoggerCore.getLogger(Logger.E(e)).error("GM XHR getResponseText error"); + } + } + } + return Promise.resolve(respond); +} + +export function dealFetch( + headerFlag: string, + config: GMSend.XHRDetails, + response: Response, + readyState: 0 | 1 | 2 | 3 | 4 +) { + const removeXCat = new RegExp(`${headerFlag}-`, "g"); + let respHeader = ""; + response.headers && + response.headers.forEach((value, key) => { + respHeader += `${key.replace(removeXCat, "")}: ${value}\n`; + }); + const respond: GMTypes.XHRResponse = { + finalUrl: response.url || config.url, + readyState, + status: response.status, + statusText: response.statusText, + responseHeaders: respHeader, + responseType: config.responseType, + }; + return respond; +} + +export function getIcon(script: Script): string { + return ( + (script.metadata.icon && script.metadata.icon[0]) || + (script.metadata.iconurl && script.metadata.iconurl[0]) || + (script.metadata.defaulticon && script.metadata.defaulticon[0]) || + (script.metadata.icon64 && script.metadata.icon64[0]) || + (script.metadata.icon64url && script.metadata.icon64url[0]) + ); +} +function genScriptMenuByTabMap( + tabMap: Map +) { + tabMap.forEach((menuArr, scriptId) => { + // 创建脚本菜单 + chrome.contextMenus.create({ + id: `scriptMenu_${scriptId}`, + title: menuArr[0].request.script.name, + contexts: ["all"], + parentId: "scriptMenu", + }); + menuArr.forEach((menu) => { + // 创建菜单 + chrome.contextMenus.create({ + id: `scriptMenu_menu_${scriptId}_${menu.request.params[0]}`, + title: menu.request.params[1], + contexts: ["all"], + parentId: `scriptMenu_${scriptId}`, + onclick: () => { + (IoC.instance(MessageCenter) as MessageCenter).sendNative( + { + tag: menu.request.sender.targetTag, + id: [ + menu.request.sender.frameId || menu.request.sender.tabId || 0, + ], + }, + { + stream: menu.channel.flag, + channel: true, + data: "click", + } + ); + }, + }); + }); + }); +} + +// 生成chrome菜单 +export function genScriptMenu( + tabId: number | string, + scriptMenu: Map< + number | string, + Map< + number, + { + request: Request; + channel: Channel; + }[] + > + > +) { + // 移除之前所有的菜单 + chrome.contextMenus.removeAll(); + const tabMap = scriptMenu.get(tabId); + const backTabMap = scriptMenu.get("sandbox"); + if (!tabMap && !backTabMap) { + return; + } + // 创建根菜单 + chrome.contextMenus.create({ + id: "scriptMenu", + title: "ScriptCat", + contexts: ["all"], + }); + if (tabMap) { + genScriptMenuByTabMap(tabMap); + } + // 后台脚本的菜单 + if (tabId !== "sandbox") { + if (backTabMap) { + genScriptMenuByTabMap(backTabMap); + } + } +} diff --git a/src/runtime/content/content.ts b/src/runtime/content/content.ts new file mode 100644 index 0000000..ed57b9f --- /dev/null +++ b/src/runtime/content/content.ts @@ -0,0 +1,145 @@ +import { ExternalMessage } from "@App/app/const"; +import MessageContent from "@App/app/message/content"; +import MessageInternal from "@App/app/message/internal"; +import { MessageHander, MessageManager } from "@App/app/message/message"; +import { ScriptRunResouce } from "@App/app/repo/scripts"; + +// content页的处理 +export default class ContentRuntime { + contentMessage: MessageHander & MessageManager; + + internalMessage: MessageInternal; + + constructor( + contentMessage: MessageHander & MessageManager, + internalMessage: MessageInternal + ) { + this.contentMessage = contentMessage; + this.internalMessage = internalMessage; + } + + start(resp: { scripts: ScriptRunResouce[] }) { + // 由content到background + // 转发gmApi消息 + this.contentMessage.setHandler("gmApi", (action, data) => { + return this.internalMessage.syncSend(action, data); + }); + // 转发log消息 + this.contentMessage.setHandler("log", (action, data) => { + this.internalMessage.send(action, data); + }); + // 转发externalMessage消息 + this.contentMessage.setHandler(ExternalMessage, (action, data) => { + return this.internalMessage.syncSend(action, data); + }); + // 处理GM_addElement + // @ts-ignore + this.contentMessage.setHandler("GM_addElement", (action, data) => { + const parma = data.param; + let attr: { [x: string]: any; textContent?: any }; + let textContent = ""; + if (!parma[1]) { + attr = {}; + } else { + attr = { ...parma[1] }; + if (attr.textContent) { + textContent = attr.textContent; + delete attr.textContent; + } + } + const el = document.createElement(parma[0]); + Object.keys(attr).forEach((key) => { + el.setAttribute(key, attr[key]); + }); + if (textContent) { + el.innerHTML = textContent; + } + let parentNode; + if (data.relatedTarget) { + parentNode = (( + this.contentMessage + )).getAndDelRelatedTarget(data.relatedTarget); + } + ( + parentNode || + document.head || + document.body || + document.querySelector("*") + ).appendChild(el); + return { + relatedTarget: el, + }; + }); + + // 转发长连接的gmApi消息 + this.contentMessage.setHandlerWithChannel( + "gmApiChannel", + (inject, action, data) => { + const background = this.internalMessage.channel(); + // 转发inject->background + inject.setHandler((req) => { + background.send(req.data); + }); + inject.setCatch((err) => { + background.throw(err); + }); + inject.setDisChannelHandler(() => { + background.disChannel(); + }); + // 转发background->inject + background.setHandler((bgResp) => { + inject.send(bgResp); + }); + background.setCatch((err) => { + inject.throw(err); + }); + background.setDisChannelHandler(() => { + inject.disChannel(); + }); + // 建立连接 + background.channel(action, data); + } + ); + + this.listenCATApi(); + + // 由background到content + // 转发value更新事件 + this.internalMessage.setHandler("valueUpdate", (action, data) => { + this.contentMessage.send(action, data); + }); + + this.contentMessage.send("pageLoad", resp); + } + + listenCATApi() { + // 处理特殊的消息,不需要转发到background + this.contentMessage.setHandler("CAT_fetchBlob", (_action, data: string) => { + return fetch(data).then((res) => res.blob()); + }); + this.contentMessage.setHandler( + "CAT_createBlobUrl", + (_action, data: Blob) => { + const url = URL.createObjectURL(data); + setTimeout(() => { + URL.revokeObjectURL(url); + }, 60 * 1000); + return Promise.resolve(url); + } + ); + // 处理CAT_fetchDocument + this.contentMessage.setHandler("CAT_fetchDocument", (_action, data) => { + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.responseType = "document"; + xhr.open("GET", data); + xhr.onload = () => { + resolve({ + relatedTarget: xhr.response, + }); + }; + xhr.send(); + }); + }); + } +} diff --git a/src/runtime/content/exec_script.test.ts b/src/runtime/content/exec_script.test.ts new file mode 100644 index 0000000..fb12346 --- /dev/null +++ b/src/runtime/content/exec_script.test.ts @@ -0,0 +1,120 @@ +import initTestEnv from "@App/pkg/utils/test_utils"; +import { ScriptRunResouce } from "@App/app/repo/scripts"; +import ExecScript from "./exec_script"; +import { compileScript, compileScriptCode } from "./utils"; +import { ExtVersion } from "@App/app/const"; + +initTestEnv(); + +const scriptRes = { + id: 0, + name: "test", + metadata: { + version: ["1.0.0"], + }, + code: "console.log('test')", + sourceCode: "sourceCode", + value: {}, + grantMap: { + none: true, + }, +} as unknown as ScriptRunResouce; + +// @ts-ignore +const noneExec = new ExecScript(scriptRes); + +const scriptRes2 = { + id: 0, + name: "test", + metadata: { + version: ["1.0.0"], + }, + code: "console.log('test')", + sourceCode: "sourceCode", + value: {}, + grantMap: {}, +} as unknown as ScriptRunResouce; + +// @ts-ignore +const sandboxExec = new ExecScript(scriptRes2); + +describe("GM_info", () => { + it("none", async () => { + scriptRes.code = "return GM_info"; + noneExec.scriptFunc = compileScript(compileScriptCode(scriptRes)); + const ret = await noneExec.exec(); + expect(ret.version).toEqual(ExtVersion); + expect(ret.script.version).toEqual("1.0.0"); + }); + it("sandbox", async () => { + scriptRes2.code = "return GM_info"; + sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2)); + const ret = await sandboxExec.exec(); + expect(ret.version).toEqual(ExtVersion); + expect(ret.script.version).toEqual("1.0.0"); + }); +}); + +describe("unsafeWindow", () => { + it("sandbox", async () => { + // @ts-ignore + global.testUnsafeWindow = "ok"; + scriptRes2.code = "return unsafeWindow.testUnsafeWindow"; + sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2)); + const ret = await sandboxExec.exec(); + expect(ret).toEqual("ok"); + scriptRes2.code = "return window.testUnsafeWindow"; + sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2)); + const ret2 = await sandboxExec.exec(); + expect(ret2).toEqual(undefined); + }); +}); + +describe("sandbox", () => { + it("global", async () => { + scriptRes2.code = "window.testObj = 'ok';return window.testObj"; + sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2)); + let ret = await sandboxExec.exec(); + expect(ret).toEqual("ok"); + scriptRes2.code = "window.testObj = 'ok2';return testObj"; + sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2)); + ret = await sandboxExec.exec(); + expect(ret).toEqual("ok2"); + }); + it("this", async () => { + scriptRes2.code = "this.testObj='ok2';return testObj;"; + sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2)); + const ret = await sandboxExec.exec(); + expect(ret).toEqual("ok2"); + }); + it("this2", async () => { + scriptRes2.code = ` + !function(t, e) { + "object" == typeof exports ? module.exports = exports = e() : "function" == typeof define && define.amd ? define([], e) : t.CryptoJS = e() + console.log("object" == typeof exports,"function" == typeof define) + } (this, function () { + return { test: "ok3" } + }); + console.log(CryptoJS) + return CryptoJS.test;`; + sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2)); + const ret = await sandboxExec.exec(); + expect(ret).toEqual("ok3"); + }); + + // 沉浸式翻译, 常量值被改变 + it("NodeFilter #214", async () => { + scriptRes2.code = `return NodeFilter.FILTER_REJECT;`; + sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2)); + const ret = await sandboxExec.exec(); + expect(ret).toEqual(2); + }); + + // RegExp.$x 内容被覆盖 https://github.com/scriptscat/scriptcat/issues/293 + it("RegExp", async () => { + scriptRes2.code = `let ok = /12(3)/.test('123');return RegExp.$1;`; + sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2)); + const ret = await sandboxExec.exec(); + expect(ret).toEqual("3"); + }); +}); diff --git a/src/runtime/content/exec_script.ts b/src/runtime/content/exec_script.ts new file mode 100644 index 0000000..d9cc69a --- /dev/null +++ b/src/runtime/content/exec_script.ts @@ -0,0 +1,68 @@ +import LoggerCore from "@App/app/logger/core"; +import Logger from "@App/app/logger/logger"; +import { ScriptRunResouce } from "@App/app/repo/scripts"; +import { Value } from "@App/app/repo/value"; +import GMApi from "./gm_api"; +import { compileScript, createContext, proxyContext, ScriptFunc } from "./utils"; + +export type ValueUpdateData = { + oldValue: any; + value: Value; +}; + +// 执行脚本,控制脚本执行与停止 +export default class ExecScript { + scriptRes: ScriptRunResouce; + + scriptFunc: ScriptFunc; + + logger: Logger; + + proxyContent: any; + + sandboxContent?: GMApi; + + GM_info: any; + + constructor(scriptRes: ScriptRunResouce, scriptFunc?: ScriptFunc, thisContext?: { [key: string]: any }) { + this.scriptRes = scriptRes; + this.logger = LoggerCore.getInstance().logger({ + component: "exec", + uuid: this.scriptRes.uuid, + name: this.scriptRes.name, + }); + this.GM_info = GMApi.GM_info(this.scriptRes); + this.proxyMessage = new ProxyMessageManager(message); + if (scriptFunc) { + this.scriptFunc = scriptFunc; + } else { + // 构建脚本资源 + this.scriptFunc = compileScript(this.scriptRes.code); + } + if (scriptRes.grantMap.none) { + // 不注入任何GM api + this.proxyContent = global; + } else { + // 构建脚本GM上下文 + this.sandboxContent = createContext(scriptRes, this.GM_info, this.proxyMessage); + this.proxyContent = proxyContext(global, this.sandboxContent, thisContext); + } + } + + // 触发值更新 + valueUpdate(data: ValueUpdateData) { + this.sandboxContent?.valueUpdate(data); + } + + exec() { + this.logger.debug("script start"); + return this.scriptFunc.apply(this.proxyContent, [this.proxyContent, this.GM_info]); + } + + // TODO: 实现脚本的停止,资源释放 + stop() { + this.logger.debug("script stop"); + this.proxyMessage.cleanChannel(); + return true; + } +} diff --git a/src/runtime/content/exec_warp.ts b/src/runtime/content/exec_warp.ts new file mode 100644 index 0000000..5062ca7 --- /dev/null +++ b/src/runtime/content/exec_warp.ts @@ -0,0 +1,92 @@ +/* eslint-disable func-names */ +/* eslint-disable max-classes-per-file */ +import { MessageManager } from "@App/app/message/message"; +import { ScriptRunResouce } from "@App/app/repo/scripts"; +import ExecScript from "./exec_script"; + +export class CATRetryError { + msg: string; + + time: Date; + + constructor(msg: string, time: number | Date) { + this.msg = msg; + if (typeof time === "number") { + this.time = new Date(Date.now() + time * 1000); + } else { + this.time = time; + } + } +} + +export class BgExecScriptWarp extends ExecScript { + setTimeout: Map; + + setInterval: Map; + + constructor(scriptRes: ScriptRunResouce, message: MessageManager) { + const thisContext: { [key: string]: any } = {}; + const setTimeout = new Map(); + const setInterval = new Map(); + thisContext.setTimeout = function ( + handler: () => void, + timeout: number | undefined, + ...args: any + ) { + const t = global.setTimeout( + function () { + setTimeout.delete(t); + if (typeof handler === "function") { + handler(); + } + }, + timeout, + ...args + ); + setTimeout.set(t, true); + return t; + }; + thisContext.clearTimeout = function (t: number) { + setTimeout.delete(t); + global.clearTimeout(t); + }; + thisContext.setInterval = function ( + handler: () => void, + timeout: number | undefined, + ...args: any + ) { + const t = global.setInterval( + function () { + if (typeof handler === "function") { + handler(); + } + }, + timeout, + ...args + ); + setInterval.set(t, true); + return t; + }; + thisContext.clearInterval = function (t: number) { + setInterval.delete(t); + global.clearInterval(t); + }; + // @ts-ignore + thisContext.CATRetryError = CATRetryError; + super(scriptRes, message, undefined, thisContext); + this.setTimeout = setTimeout; + this.setInterval = setInterval; + } + + stop() { + this.setTimeout.forEach((_, t) => { + global.clearTimeout(t); + }); + this.setTimeout.clear(); + this.setInterval.forEach((_, t) => { + global.clearInterval(t); + }); + this.setInterval.clear(); + return super.stop(); + } +} diff --git a/src/runtime/content/gm_api.ts b/src/runtime/content/gm_api.ts new file mode 100644 index 0000000..51b485b --- /dev/null +++ b/src/runtime/content/gm_api.ts @@ -0,0 +1,913 @@ +/* eslint-disable camelcase */ +/* eslint-disable max-classes-per-file */ +import { ExtVersion } from "@App/app/const"; +import LoggerCore from "@App/app/logger/core"; +import { Channel, ChannelHandler } from "@App/app/message/channel"; +import MessageContent from "@App/app/message/content"; +import { MessageManager } from "@App/app/message/message"; +import { ScriptRunResouce } from "@App/app/repo/scripts"; +import { + base64ToBlob, + blobToBase64, + getMetadataStr, + getUserConfigStr, + parseUserConfig, +} from "@App/pkg/utils/script"; +import { v4 as uuidv4 } from "uuid"; +import { ValueUpdateData } from "./exec_script"; + +interface ApiParam { + depend?: string[]; + listener?: () => void; +} + +export interface ApiValue { + api: any; + param: ApiParam; +} + +export class GMContext { + static apis: Map = new Map(); + + public static API(param: ApiParam = {}) { + return ( + target: any, + propertyName: string, + descriptor: PropertyDescriptor + ) => { + const key = propertyName; + if (param.listener) { + param.listener(); + } + if (key === "GMdotXmlHttpRequest") { + GMContext.apis.set("GM.xmlHttpRequest", { + api: descriptor.value, + param, + }); + return; + } + GMContext.apis.set(key, { + api: descriptor.value, + param, + }); + // 兼容GM.* + const dot = key.replace("_", "."); + if (dot !== key) { + // 特殊处理GM.xmlHttpRequest + if (dot === "GM.xmlhttpRequest") { + return; + } + GMContext.apis.set(dot, { + api: descriptor.value, + param, + }); + } + }; + } +} + +export default class GMApi { + scriptRes!: ScriptRunResouce; + + message!: MessageManager; + + runFlag!: string; + + valueChangeListener = new Map< + number, + { name: string; listener: GMTypes.ValueChangeListener } + >(); + + // 单次回调使用 + public sendMessage(api: string, params: any[]) { + return this.message.syncSend("gmApi", { + api, + scriptId: this.scriptRes.id, + params, + runFlag: this.runFlag, + }); + } + + // 长连接使用,connect只用于接受消息,不能发送消息 + public connect(api: string, params: any[], handler: ChannelHandler): Channel { + const uuid = uuidv4(); + const channel = this.message.channel(uuid); + channel.setHandler(handler); + channel.channel("gmApiChannel", { + api, + scriptId: this.scriptRes.id, + params, + runFlag: this.runFlag, + }); + return channel; + } + + public valueUpdate(data: ValueUpdateData) { + const { storagename } = this.scriptRes.metadata; + if ( + data.value.scriptId === this.scriptRes.id || + (storagename && + data.value.storageName && + storagename[0] === data.value.storageName) + ) { + // 触发,并更新值 + if (data.value.value === undefined) { + delete this.scriptRes.value[data.value.key]; + } else { + this.scriptRes.value[data.value.key] = data.value; + } + this.valueChangeListener.forEach((item) => { + if (item.name === data.value.key) { + item.listener( + data.value.key, + data.oldValue, + data.value.value, + data.sender.runFlag !== this.runFlag, + data.sender.tabId + ); + } + }); + } + } + + // 获取脚本信息和管理器信息 + static GM_info(script: ScriptRunResouce) { + const metadataStr = getMetadataStr(script.sourceCode); + const userConfigStr = getUserConfigStr(script.sourceCode) || ""; + const options = { + description: + (script.metadata.description && script.metadata.description[0]) || null, + matches: script.metadata.match || [], + includes: script.metadata.include || [], + "run-at": + (script.metadata["run-at"] && script.metadata["run-at"][0]) || + "document-idle", + icon: (script.metadata.icon && script.metadata.icon[0]) || null, + icon64: (script.metadata.icon64 && script.metadata.icon64[0]) || null, + header: metadataStr, + grant: script.metadata.grant || [], + connects: script.metadata.connect || [], + }; + + return { + // downloadMode + // isIncognito + scriptWillUpdate: true, + scriptHandler: "ScriptCat", + scriptUpdateURL: script.downloadUrl, + scriptMetaStr: metadataStr, + userConfig: parseUserConfig(userConfigStr), + userConfigStr, + // scriptSource: script.sourceCode, + version: ExtVersion, + script: { + // TODO: 更多完整的信息(为了兼容Tampermonkey,后续待定) + name: script.name, + namespace: script.namespace, + version: script.metadata.version && script.metadata.version[0], + author: script.author, + ...options, + }, + }; + } + + // 获取脚本的值,可以通过@storageName让多个脚本共享一个储存空间 + @GMContext.API() + public GM_getValue(key: string, defaultValue?: any) { + const ret = this.scriptRes.value[key]; + if (ret) { + return ret.value; + } + return defaultValue; + } + + @GMContext.API() + public GM_setValue(key: string, value: any) { + // 对object的value进行一次转化 + if (typeof value === "object") { + value = JSON.parse(JSON.stringify(value)); + } + let ret = this.scriptRes.value[key]; + if (ret) { + ret.value = value; + } else { + ret = { + id: 0, + scriptId: this.scriptRes.id, + storageName: + (this.scriptRes.metadata.storagename && + this.scriptRes.metadata.storagename[0]) || + "", + key, + value, + createtime: new Date().getTime(), + updatetime: 0, + }; + } + if (value === undefined) { + delete this.scriptRes.value[key]; + } else { + this.scriptRes.value[key] = ret; + } + return this.sendMessage("GM_setValue", [key, value]); + } + + @GMContext.API({ depend: ["GM_setValue"] }) + public GM_deleteValue(name: string): void { + this.GM_setValue(name, undefined); + } + + @GMContext.API() + public GM_listValues(): string[] { + return Object.keys(this.scriptRes.value); + } + + @GMContext.API() + public GM_addValueChangeListener( + name: string, + listener: GMTypes.ValueChangeListener + ): number { + const id = Math.random() * 10000000; + this.valueChangeListener.set(id, { name, listener }); + return id; + } + + @GMContext.API() + public GM_removeValueChangeListener(listenerId: number): void { + this.valueChangeListener.delete(listenerId); + } + + // 辅助GM_xml获取blob数据 + @GMContext.API() + public CAT_fetchBlob(url: string): Promise { + return this.message.syncSend("CAT_fetchBlob", url); + } + + @GMContext.API() + public CAT_fetchDocument(url: string): Promise { + return new Promise((resolve) => { + let el: Document | undefined; + (this.message).sendCallback( + "CAT_fetchDocument", + url, + (resp) => { + el = ( + (( + (this.message).getAndDelRelatedTarget( + resp.relatedTarget + ) + )) + ); + resolve(el); + } + ); + }); + } + + // 辅助GM_xml发送blob数据 + @GMContext.API() + public CAT_createBlobUrl(blob: Blob): Promise { + return this.message.syncSend("CAT_createBlobUrl", blob); + } + + // 用于脚本跨域请求,需要@connect domain指定允许的域名 + @GMContext.API({ + depend: [ + "CAT_fetchBlob", + "CAT_createBlobUrl", + "CAT_fetchDocument", + "GM_xmlhttpRequest", + ], + }) + GMdotXmlHttpRequest(details: GMTypes.XHRDetails) { + let abort: any; + const ret = new Promise((resolve, reject) => { + const oldOnload = details.onload; + details.onload = (data) => { + resolve(data); + oldOnload && oldOnload(data); + }; + const oldOnerror = details.onerror; + details.onerror = (data) => { + reject(data); + oldOnerror && oldOnerror(data); + }; + // @ts-ignore + abort = this.GM_xmlhttpRequest(details); + }); + if (abort && abort.abort) { + // @ts-ignore + ret.abort = abort.abort; + } + return ret; + } + + // 用于脚本跨域请求,需要@connect domain指定允许的域名 + @GMContext.API({ + depend: ["CAT_fetchBlob", "CAT_createBlobUrl", "CAT_fetchDocument"], + }) + public GM_xmlhttpRequest(details: GMTypes.XHRDetails) { + let connect: Channel; + + const u = new URL(details.url, window.location.href); + if (details.headers) { + Object.keys(details.headers).forEach((key) => { + if (key.toLowerCase() === "cookie") { + details.cookie = details.headers![key]; + delete details.headers![key]; + } + }); + } + + const param: GMSend.XHRDetails = { + method: details.method, + timeout: details.timeout, + url: u.href, + headers: details.headers, + cookie: details.cookie, + context: details.context, + responseType: details.responseType, + overrideMimeType: details.overrideMimeType, + anonymous: details.anonymous, + user: details.user, + password: details.password, + maxRedirects: details.maxRedirects, + }; + if (!param.headers) { + param.headers = {}; + } + if (details.nocache) { + param.headers["Cache-Control"] = "no-cache"; + } + + const handler = async () => { + if (details.data) { + if (details.data instanceof FormData) { + param.dataType = "FormData"; + const data: Array = []; + const keys: { [key: string]: boolean } = {}; + details.data.forEach((val, key) => { + keys[key] = true; + }); + const asyncArr = Object.keys(keys).map((key) => { + const values = (details.data).getAll(key); + const asyncArr2 = values.map((val) => { + return new Promise((resolve) => { + if (val instanceof File) { + blobToBase64(val).then((base64) => { + data.push({ + key, + type: "file", + val: base64 || "", + filename: val.name, + }); + resolve(); + }); + } else { + data.push({ + key, + type: "text", + val, + }); + resolve(); + } + }); + }); + return Promise.all(asyncArr2); + }); + await Promise.all(asyncArr); + param.data = data; + } else if (details.data instanceof Blob) { + param.dataType = "Blob"; + param.data = await this.CAT_createBlobUrl(details.data); + } else { + param.data = details.data; + } + } + + let readerStream: ReadableStream | undefined; + // eslint-disable-next-line no-undef + let controller: ReadableStreamDefaultController | undefined; + // 如果返回类型是arraybuffer或者blob的情况下,需要将返回的数据转化为blob + // 在background通过URL.createObjectURL转化为url,然后在content页读取url获取blob对象 + const responseType = details.responseType?.toLocaleLowerCase(); + const warpResponse = (old: Function) => { + if (responseType === "stream") { + readerStream = new ReadableStream({ + start(ctrl) { + controller = ctrl; + }, + }); + } + return async (xhr: GMTypes.XHRResponse) => { + if (xhr.response) { + if (responseType === "document") { + xhr.response = await this.CAT_fetchDocument(xhr.response); + xhr.responseXML = xhr.response; + xhr.responseType = "document"; + } else { + const resp = await this.CAT_fetchBlob(xhr.response); + if (responseType === "arraybuffer") { + xhr.response = await resp.arrayBuffer(); + } else { + xhr.response = resp; + } + } + } + if (responseType === "stream") { + xhr.response = readerStream; + } + old(xhr); + }; + }; + if ( + responseType === "arraybuffer" || + responseType === "blob" || + responseType === "document" || + responseType === "stream" + ) { + if (details.onload) { + details.onload = warpResponse(details.onload); + } + if (details.onreadystatechange) { + details.onreadystatechange = warpResponse(details.onreadystatechange); + } + if (details.onloadend) { + details.onloadend = warpResponse(details.onloadend); + } + // document类型读取blob,然后在content页转化为document对象 + if (responseType === "document") { + param.responseType = "blob"; + } + if (responseType === "stream") { + if (details.onloadstart) { + details.onloadstart = warpResponse(details.onloadstart); + } + } + } + + connect = this.connect("GM_xmlhttpRequest", [param], (resp: any) => { + const data = resp.data || {}; + switch (resp.event) { + case "onload": + details.onload && details.onload(data); + break; + case "onloadend": + details.onloadend && details.onloadend(data); + if (readerStream) { + controller?.close(); + } + break; + case "onloadstart": + details.onloadstart && details.onloadstart(data); + break; + case "onprogress": + details.onprogress && details.onprogress(data); + break; + case "onreadystatechange": + details.onreadystatechange && details.onreadystatechange(data); + break; + case "ontimeout": + details.ontimeout && details.ontimeout(); + break; + case "onerror": + details.onerror && details.onerror(""); + break; + case "onabort": + details.onabort && details.onabort(); + break; + case "onstream": + controller?.enqueue(new Uint8Array(resp.data)); + break; + default: + LoggerCore.getLogger().warn("GM_xmlhttpRequest resp is error", { + resp, + }); + break; + } + }); + connect.setCatch((err) => { + details.onerror && details.onerror(err); + }); + }; + handler(); + + return { + abort: () => { + if (connect) { + connect.disChannel(); + } + }, + }; + } + + @GMContext.API() + public async GM_notification( + detail: GMTypes.NotificationDetails | string, + ondone?: GMTypes.NotificationOnDone | string, + image?: string, + onclick?: GMTypes.NotificationOnClick + ) { + let data: GMTypes.NotificationDetails = {}; + if (typeof detail === "string") { + data.text = detail; + switch (arguments.length) { + case 4: + data.onclick = onclick; + // eslint-disable-next-line no-fallthrough + case 3: + data.image = image; + // eslint-disable-next-line no-fallthrough + case 2: + data.title = ondone; + // eslint-disable-next-line no-fallthrough + default: + break; + } + } else { + data = detail; + data.ondone = data.ondone || ondone; + } + let click: GMTypes.NotificationOnClick; + let done: GMTypes.NotificationOnDone; + let create: GMTypes.NotificationOnClick; + if (data.onclick) { + click = data.onclick; + delete data.onclick; + } + if (data.ondone) { + done = data.ondone; + delete data.ondone; + } + if (data.oncreate) { + create = data.oncreate; + delete data.oncreate; + } + this.connect("GM_notification", [data], (resp: any) => { + switch (resp.event) { + case "click": { + click && click.apply({ id: resp.id }, [resp.id, resp.index]); + break; + } + case "done": { + done && done.apply({ id: resp.id }, [resp.user]); + break; + } + case "create": { + create && create.apply({ id: resp.id }, [resp.id]); + break; + } + default: + LoggerCore.getLogger().warn("GM_notification resp is error", { + resp, + }); + break; + } + }); + } + + @GMContext.API() + public GM_closeNotification(id: string) { + this.sendMessage("GM_closeNotification", [id]); + } + + @GMContext.API() + public GM_updateNotification( + id: string, + details: GMTypes.NotificationDetails + ): void { + this.sendMessage("GM_updateNotification", [id, details]); + } + + @GMContext.API() + GM_log( + message: string, + level?: GMTypes.LoggerLevel, + labels?: GMTypes.LoggerLabel + ) { + if (typeof message !== "string") { + message = JSON.stringify(message); + } + return this.sendMessage("GM_log", [message, level, labels]); + } + + @GMContext.API({ depend: ["GM_closeInTab"] }) + public GM_openInTab( + url: string, + options?: GMTypes.OpenTabOptions | boolean + ): GMTypes.Tab { + let option: GMTypes.OpenTabOptions = {}; + if (arguments.length === 1) { + option.active = true; + } else if (typeof options === "boolean") { + option.active = !options; + } else { + option = options; + } + if (option.active === undefined) { + option.active = true; + } + let tabid: any; + + const ret: GMTypes.Tab = { + close: () => { + this.GM_closeInTab(tabid); + }, + }; + + const connect = this.connect("GM_openInTab", [url, option], (data) => { + switch (data.event) { + case "oncreate": + tabid = data.tabId; + break; + case "onclose": + ret.onclose && ret.onclose(); + ret.closed = true; + connect.disChannel(); + break; + default: + break; + } + }); + return ret; + } + + @GMContext.API() + public GM_closeInTab(tabid: string) { + return this.sendMessage("GM_closeInTab", [tabid]); + } + + @GMContext.API() + GM_getResourceText(name: string): string | undefined { + if (!this.scriptRes.resource) { + return undefined; + } + const r = this.scriptRes.resource[name]; + if (r) { + return r.content; + } + return undefined; + } + + @GMContext.API() + GM_getResourceURL(name: string, isBlobUrl?: boolean): string | undefined { + if (!this.scriptRes.resource) { + return undefined; + } + const r = this.scriptRes.resource[name]; + if (r) { + if (isBlobUrl) { + return URL.createObjectURL(base64ToBlob(r.base64)); + } + return r.base64; + } + return undefined; + } + + @GMContext.API() + GM_addStyle(css: string) { + let el: Element | undefined; + // 与content页的消息通讯实际是同步,此方法不需要经过background + // 所以可以直接在then中赋值el再返回 + (this.message).sendCallback( + "GM_addElement", + { + param: [ + "style", + { + textContent: css, + }, + ], + }, + (resp) => { + el = (this.message).getAndDelRelatedTarget( + resp.relatedTarget + ); + } + ); + return el; + } + + @GMContext.API() + async GM_getTab(callback: (data: any) => void) { + const resp = await this.sendMessage("GM_getTab", []); + callback(resp); + } + + @GMContext.API() + GM_saveTab(obj: object) { + if (typeof obj === "object") { + obj = JSON.parse(JSON.stringify(obj)); + } + return this.sendMessage("GM_saveTab", [obj]); + } + + @GMContext.API() + async GM_getTabs( + callback: (objs: { [key: string | number]: object }) => any + ) { + const resp = await this.sendMessage("GM_getTabs", []); + callback(resp); + } + + @GMContext.API() + GM_download( + url: GMTypes.DownloadDetails | string, + filename?: string + ): GMTypes.AbortHandle { + let details: GMTypes.DownloadDetails; + if (typeof url === "string") { + details = { + name: filename || "", + url, + }; + } else { + details = url; + } + const connect = this.connect( + "GM_download", + [ + { + method: details.method, + url: details.url, + name: details.name, + headers: details.headers, + saveAs: details.saveAs, + timeout: details.timeout, + cookie: details.cookie, + anonymous: details.anonymous, + }, + ], + (resp: any) => { + const data = resp.data || {}; + switch (resp.event) { + case "onload": + details.onload && details.onload(data); + break; + case "onprogress": + details.onprogress && details.onprogress(data); + break; + case "ontimeout": + details.ontimeout && details.ontimeout(); + break; + case "onerror": + details.onerror && + details.onerror({ + error: "unknown", + }); + break; + default: + LoggerCore.getLogger().warn("GM_download resp is error", { + resp, + }); + break; + } + } + ); + + return { + abort: () => { + connect.disChannel(); + }, + }; + } + + @GMContext.API() + GM_setClipboard( + data: string, + info?: string | { type?: string; minetype?: string } + ) { + return this.sendMessage("GM_setClipboard", [data, info]); + } + + @GMContext.API() + GM_cookie( + action: string, + details: GMTypes.CookieDetails, + done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void + ) { + this.sendMessage("GM_cookie", [action, details]) + .then((resp: any) => { + done && done(resp, undefined); + }) + .catch((err) => { + done && done(undefined, err); + }); + } + + menuId: number | undefined; + + menuMap: Map | undefined; + + @GMContext.API() + GM_registerMenuCommand( + name: string, + listener: () => void, + accessKey?: string + ): number { + if (!this.menuMap) { + this.menuMap = new Map(); + } + let flag = 0; + this.menuMap.forEach((val, key) => { + if (val === name) { + flag = key; + } + }); + if (flag) { + return flag; + } + if (!this.menuId) { + this.menuId = 1; + } else { + this.menuId += 1; + } + const id = this.menuId; + this.connect("GM_registerMenuCommand", [id, name, accessKey], () => { + listener(); + }); + this.menuMap.set(id, name); + return id; + } + + @GMContext.API() + GM_unregisterMenuCommand(id: number): void { + if (!this.menuMap) { + this.menuMap = new Map(); + } + this.menuMap.delete(id); + this.sendMessage("GM_unregisterMenuCommand", [id]); + } + + @GMContext.API() + CAT_userConfig() { + return this.sendMessage("CAT_userConfig", []); + } + + // 此API在content页实现 + @GMContext.API() + GM_addElement(parentNode: Element | string, tagName: any, attrs?: any) { + let el: Element | undefined; + // 与content页的消息通讯实际是同步,此方法不需要经过background + // 所以可以直接在then中赋值el再返回 + (this.message).sendCallback( + "GM_addElement", + { + param: [ + typeof parentNode === "string" ? parentNode : tagName, + typeof parentNode === "string" ? tagName : attrs, + ], + relatedTarget: typeof parentNode === "string" ? null : parentNode, + }, + (resp) => { + el = (this.message).getAndDelRelatedTarget( + resp.relatedTarget + ); + } + ); + return el; + } + + @GMContext.API({ + depend: ["CAT_fetchBlob", "CAT_createBlobUrl"], + }) + async CAT_fileStorage( + action: "list" | "download" | "upload" | "delete" | "config", + details: any + ) { + if (action === "config") { + this.sendMessage("CAT_fileStorage", ["config"]); + return; + } + const sendDetails: { [key: string]: string } = { + baseDir: details.baseDir || "", + path: details.path || "", + filename: details.filename, + file: details.file, + }; + if (action === "upload") { + const url = await this.CAT_createBlobUrl(details.data); + sendDetails.data = url; + } + const channel = this.connect( + "CAT_fileStorage", + [action, sendDetails], + async (resp: any) => { + if (action === "download") { + // 读取blob + const blob = await this.CAT_fetchBlob(resp.data); + details.onload && details.onload(blob); + } else { + details.onload && details.onload(resp.data); + } + } + ); + channel.setCatch((err) => { + if (typeof err.code === "undefined") { + details.onerror && details.onerror({ code: -1, message: err.message }); + return; + } + details.onerror && details.onerror(err); + }); + } +} diff --git a/src/runtime/content/inject.ts b/src/runtime/content/inject.ts new file mode 100644 index 0000000..2326105 --- /dev/null +++ b/src/runtime/content/inject.ts @@ -0,0 +1,143 @@ +import { ExternalMessage, ExternalWhitelist } from "@App/app/const"; +import MessageContent from "@App/app/message/content"; +import { ScriptRunResouce } from "@App/app/repo/scripts"; +import ExecScript, { ValueUpdateData } from "./exec_script"; +import { addStyle, ScriptFunc } from "./utils"; + +// 注入脚本的沙盒环境 +export default class InjectRuntime { + scripts: ScriptRunResouce[]; + + flag: string; + + message: MessageContent; + + execList: ExecScript[] = []; + + constructor( + message: MessageContent, + scripts: ScriptRunResouce[], + flag: string + ) { + this.message = message; + this.scripts = scripts; + this.flag = flag; + } + + start() { + this.scripts.forEach((script) => { + // @ts-ignore + const scriptFunc = window[script.flag]; + if (scriptFunc) { + this.execScript(script, scriptFunc); + } else { + // 监听脚本加载,和屏蔽读取 + Object.defineProperty(window, script.flag, { + configurable: true, + set: (val: ScriptFunc) => { + this.execScript(script, val); + }, + }); + } + }); + // 监听值变化 + MessageContent.getInstance().setHandler( + "valueUpdate", + (_action, data: ValueUpdateData) => { + this.execList.forEach((exec) => { + exec.valueUpdate(data); + }); + } + ); + + // 注入允许外部调用 + this.externalMessage(); + } + + execScript(script: ScriptRunResouce, scriptFunc: ScriptFunc) { + // @ts-ignore + delete window[script.flag]; + const exec = new ExecScript( + script, + MessageContent.getInstance(), + scriptFunc + ); + this.execList.push(exec); + // 注入css + if (script.metadata["require-css"]) { + script.metadata["require-css"].forEach((val) => { + const res = script.resource[val]; + if (res) { + addStyle(res.content); + } + }); + } + if ( + script.metadata["run-at"] && + script.metadata["run-at"][0] === "document-body" + ) { + // 等待页面加载完成 + this.waitBody(() => { + exec.exec(); + }); + } else { + exec.exec(); + } + } + + // 参考了tm的实现 + waitBody(callback: () => void) { + if (document.body) { + callback(); + return; + } + const listen = () => { + document.removeEventListener("load", listen, false); + document.removeEventListener("DOMNodeInserted", listen, false); + document.removeEventListener("DOMContentLoaded", listen, false); + this.waitBody(callback); + }; + document.addEventListener("load", listen, false); + document.addEventListener("DOMNodeInserted", listen, false); + document.addEventListener("DOMContentLoaded", listen, false); + } + + externalMessage() { + const { message } = this; + // 对外接口白名单 + for (let i = 0; i < ExternalWhitelist.length; i += 1) { + if (window.location.host.endsWith(ExternalWhitelist[i])) { + // 注入 + (<{ external: any }>(window)).external = window.external || {}; + (< + { + external: { + Scriptcat: { + isInstalled: ( + name: string, + namespace: string, + callback: any + ) => void; + }; + }; + } + >(window)).external.Scriptcat = { + async isInstalled(name: string, namespace: string, callback: any) { + const resp = await message.syncSend(ExternalMessage, { + action: "isInstalled", + name, + namespace, + }); + callback(resp); + }, + }; + (<{ external: { Tampermonkey: any } }>( + (window) + )).external.Tampermonkey = (<{ external: { Scriptcat: any } }>( + (window) + )).external.Scriptcat; + break; + } + } + } +} diff --git a/src/runtime/content/runtime.ts b/src/runtime/content/runtime.ts new file mode 100644 index 0000000..03634d1 --- /dev/null +++ b/src/runtime/content/runtime.ts @@ -0,0 +1,51 @@ +import MessageInternal from "@App/app/message/internal"; +import Cache from "@App/app/cache"; +import { Script } from "@App/app/repo/scripts"; +import CacheKey from "@App/pkg/utils/cache_key"; +import IoC from "@App/app/ioc"; +import Runtime, { RuntimeEvent } from "../background/runtime"; + +@IoC.Singleton(MessageInternal) +export default class RuntimeController { + internal: MessageInternal; + + runtime!: Runtime; + + constructor(internal: MessageInternal) { + this.internal = internal; + try { + this.runtime = IoC.instance(Runtime) as Runtime; + } catch (e) { + // ignore + } + } + + public dispatchEvent(event: RuntimeEvent, data: any): Promise { + return this.internal.syncSend(`runtime-${event}`, data); + } + + // 调试脚本,需要先启动GM环境 + async debugScript(script: Script) { + // 清理脚本缓存,避免GMApi中的缓存影响 + Cache.getInstance().del(CacheKey.script(script.id)); + Cache.getInstance().del( + CacheKey.scriptValue(script.id, script.metadata.storagename) + ); + // 构建脚本代码 + return this.runtime.startBackgroundScript(script); + } + + watchRunStatus() { + const channel = this.internal.channel(); + channel.channel("watchRunStatus"); + return channel; + } + + startScript(id: number) { + return this.dispatchEvent("start", id); + } + + stopScript(id: number) { + return this.dispatchEvent("stop", id); + } +} diff --git a/src/runtime/content/sandbox.ts b/src/runtime/content/sandbox.ts new file mode 100644 index 0000000..a6f907a --- /dev/null +++ b/src/runtime/content/sandbox.ts @@ -0,0 +1,328 @@ +import MessageSandbox from "@App/app/message/sandbox"; +import LoggerCore from "@App/app/logger/core"; +import Logger from "@App/app/logger/logger"; +import { + SCRIPT_RUN_STATUS_COMPLETE, + SCRIPT_RUN_STATUS_ERROR, + SCRIPT_RUN_STATUS_RUNNING, + SCRIPT_TYPE_BACKGROUND, + SCRIPT_TYPE_CRONTAB, + ScriptRunResouce, +} from "@App/app/repo/scripts"; +import { CronJob } from "cron"; +import IoC from "@App/app/ioc"; +import ExecScript from "./exec_script"; +import { BgExecScriptWarp, CATRetryError } from "./exec_warp"; + +type SandboxEvent = "enable" | "disable" | "start" | "stop"; + +type Handler = (data: any) => Promise; + +// 沙盒运行环境, 后台脚本与定时脚本的运行环境 +@IoC.Singleton(MessageSandbox) +export default class SandboxRuntime { + message: MessageSandbox; + + logger: Logger; + + cronJob: Map> = new Map(); + + execScripts: Map = new Map(); + + retryList: { + script: ScriptRunResouce; + retryTime: number; + }[] = []; + + constructor(message: MessageSandbox) { + this.message = message; + this.logger = LoggerCore.getInstance().logger({ component: "sandbox" }); + // 重试队列,5s检查一次 + setInterval(() => { + if (!this.retryList.length) { + return; + } + const now = Date.now(); + const retryList = []; + for (let i = 0; i < this.retryList.length; i += 1) { + const item = this.retryList[i]; + if (item.retryTime < now) { + this.retryList.splice(i, 1); + i -= 1; + retryList.push(item.script); + } + } + retryList.forEach((script) => { + script.nextruntime = 0; + this.execScript(script); + }); + }, 5000); + } + + joinRetryList(script: ScriptRunResouce) { + if (script.nextruntime) { + this.retryList.push({ + script, + retryTime: script.nextruntime, + }); + this.retryList.sort((a, b) => a.retryTime - b.retryTime); + } + } + + removeRetryList(scriptId: number) { + for (let i = 0; i < this.retryList.length; i += 1) { + if (this.retryList[i].script.id === scriptId) { + this.retryList.splice(i, 1); + i -= 1; + } + } + } + + listenEvent(event: SandboxEvent, handler: Handler) { + this.message.setHandler(event, (_action, data) => { + return handler.bind(this)(data); + }); + } + + // 开启沙盒运行环境,监听background来的请求 + init() { + this.listenEvent("enable", this.enable); + this.listenEvent("disable", this.disable); + this.listenEvent("start", this.start); + this.listenEvent("stop", this.stop); + // 监听值更新 + this.message.setHandler("valueUpdate", (action, data) => { + this.execScripts.forEach((val) => { + val.valueUpdate(data); + }); + }); + } + + // 直接运行脚本 + start(script: ScriptRunResouce): Promise { + return this.execScript(script, true); + } + + stop(scriptId: number): Promise { + const exec = this.execScripts.get(scriptId); + if (!exec) { + this.message.send("scriptRunStatus", [ + scriptId, + SCRIPT_RUN_STATUS_COMPLETE, + ]); + return Promise.resolve(false); + } + this.execStop(exec); + return Promise.resolve(true); + } + + enable(script: ScriptRunResouce): Promise { + // 如果正在运行,先释放 + if (this.execScripts.has(script.id)) { + this.disable(script.id); + } + // 开启脚本在沙盒环境中运行 + switch (script.type) { + case SCRIPT_TYPE_CRONTAB: + // 定时脚本 + this.stopCronJob(script.id); + return this.crontabScript(script); + case SCRIPT_TYPE_BACKGROUND: + // 后台脚本, 直接执行脚本 + return this.execScript(script); + default: + throw new Error("不支持的脚本类型"); + } + } + + disable(id: number): Promise { + // 停止脚本运行,主要是停止定时器 + // 后续考虑停止正在运行的脚本的方法 + // 现期对于正在运行的脚本仅仅是在background中判断是否运行 + // 未运行的脚本不处理GMApi的请求 + this.stopCronJob(id); + // 移除重试队列 + this.removeRetryList(id); + return this.stop(id); + } + + // 停止计时器 + stopCronJob(id: number) { + const list = this.cronJob.get(id); + if (list) { + list.forEach((val) => { + val.stop(); + }); + this.cronJob.delete(id); + } + } + + // 执行脚本 + execScript(script: ScriptRunResouce, execOnce?: boolean) { + const logger = this.logger.with({ scriptId: script.id, name: script.name }); + if (this.execScripts.has(script.id)) { + // 释放掉资源 + // 暂未实现执行完成后立马释放,会在下一次执行时释放 + this.stop(script.id); + } + const exec = new BgExecScriptWarp(script, this.message); + this.execScripts.set(script.id, exec); + this.message.send("scriptRunStatus", [ + exec.scriptRes.id, + SCRIPT_RUN_STATUS_RUNNING, + ]); + // 修改掉脚本掉最后运行时间, 数据库也需要修改 + script.lastruntime = new Date().getTime(); + const ret = exec.exec(); + if (ret instanceof Promise) { + ret + .then((resp) => { + // 发送执行完成消息 + this.message.send("scriptRunStatus", [ + exec.scriptRes.id, + SCRIPT_RUN_STATUS_COMPLETE, + ]); + logger.info("exec script complete", { + value: resp, + }); + }) + .catch((err) => { + // 发送执行完成+错误消息 + let errMsg; + let nextruntime = 0; + if (err instanceof CATRetryError) { + errMsg = { error: err.msg }; + if (!execOnce) { + // 下一次执行时间 + nextruntime = err.time.getTime(); + script.nextruntime = nextruntime; + this.joinRetryList(script); + } + } else { + errMsg = Logger.E(err); + } + logger.error("exec script error", errMsg); + this.message.send("scriptRunStatus", [ + exec.scriptRes.id, + SCRIPT_RUN_STATUS_ERROR, + errMsg, + nextruntime, + ]); + // 错误还是抛出,方便排查 + throw err; + }); + } else { + logger.warn("backscript return not promise"); + } + return ret; + } + + crontabScript(script: ScriptRunResouce) { + // 执行定时脚本 运行表达式 + if (!script.metadata.crontab) { + throw new Error("错误的crontab表达式"); + } + // 如果有nextruntime,则加入重试队列 + this.joinRetryList(script); + let flag = false; + const cronJobList: Array = []; + script.metadata.crontab.forEach((val) => { + let oncePos = 0; + let crontab = val; + if (crontab.indexOf("once") !== -1) { + const vals = crontab.split(" "); + vals.forEach((item, index) => { + if (item === "once") { + oncePos = index; + } + }); + if (vals.length === 5) { + oncePos += 1; + } + crontab = crontab.replace(/once/g, "*"); + } + try { + const cron = new CronJob(crontab, this.crontabExec(script, oncePos)); + cron.start(); + cronJobList.push(cron); + } catch (e) { + flag = true; + this.logger.error("create cronjob failed", { + script: script.id, + crontab: val, + }); + } + }); + if (cronJobList.length !== script.metadata.crontab.length) { + // 有表达式失败了 + cronJobList.forEach((crontab) => { + crontab.stop(); + }); + } else { + this.cronJob.set(script.id, cronJobList); + } + return Promise.resolve(!flag); + } + + crontabExec(script: ScriptRunResouce, oncePos: number) { + if (oncePos) { + return () => { + // 没有最后一次执行时间表示之前都没执行过,直接执行 + if (!script.lastruntime) { + this.execScript(script); + return; + } + const now = new Date(); + const last = new Date(script.lastruntime); + let flag = false; + // 根据once所在的位置去判断执行 + switch (oncePos) { + case 1: // 每分钟 + flag = last.getMinutes() !== now.getMinutes(); + break; + case 2: // 每小时 + flag = last.getHours() !== now.getHours(); + break; + case 3: // 每天 + flag = last.getDay() !== now.getDay(); + break; + case 4: // 每月 + flag = last.getMonth() !== now.getMonth(); + break; + case 5: // 每周 + flag = this.getWeek(last) !== this.getWeek(now); + break; + default: + } + if (flag) { + this.execScript(script); + } + }; + } + return () => { + this.execScript(script); + }; + } + + execStop(exec: ExecScript) { + exec.stop(); + this.execScripts.delete(exec.scriptRes.id); + this.message.send("scriptRunStatus", [ + exec.scriptRes.id, + SCRIPT_RUN_STATUS_COMPLETE, + ]); + } + + // 获取本周是第几周 + getWeek(date: Date) { + const nowDate = new Date(date); + const firstDay = new Date(date); + firstDay.setMonth(0); // 设置1月 + firstDay.setDate(1); // 设置1号 + const diffDays = Math.ceil( + (nowDate.getTime() - firstDay.getTime()) / (24 * 60 * 60 * 1000) + ); + const week = Math.ceil(diffDays / 7); + return week === 0 ? 1 : week; + } +} diff --git a/src/runtime/content/utils.test.ts b/src/runtime/content/utils.test.ts new file mode 100644 index 0000000..3e53a42 --- /dev/null +++ b/src/runtime/content/utils.test.ts @@ -0,0 +1,101 @@ +import { init, proxyContext, writables } from "./utils"; + +describe("proxy context", () => { + const context: any = {}; + const global: any = { + gbok: "gbok", + onload: null, + eval: () => { + console.log("eval"); + }, + addEventListener: () => {}, + removeEventListener: () => {}, + location: "ok", + }; + init.set("onload", true); + init.set("location", true); + const _this = proxyContext(global, context); + + it("set contenxt", () => { + _this["md5"] = "ok"; + expect(_this["md5"]).toEqual("ok"); + expect(global["md5"]).toEqual(undefined); + }); + + it("set window null", () => { + _this["onload"] = "ok"; + expect(_this["onload"]).toEqual("ok"); + expect(global["onload"]).toEqual(null); + _this["onload"] = undefined; + expect(_this["onload"]).toEqual(undefined); + }); + + it("update", () => { + _this["okk"] = "ok"; + expect(_this["okk"]).toEqual("ok"); + expect(global["okk"]).toEqual(undefined); + _this["okk"] = "ok2"; + expect(_this["okk"]).toEqual("ok2"); + expect(global["okk"]).toEqual(undefined); + }); + + it("禁止穿透global对象", () => { + expect(_this["gbok"]).toBeUndefined(); + }); + + it("禁止修改window", () => { + expect(() => (_this["window"] = "ok")).toThrow(); + }); + + it("访问location", () => { + expect(_this.location).not.toBeUndefined(); + }); +}); + +// 只允许访问onxxxxx +describe("window", () => { + const _this = proxyContext({ onanimationstart: null }, {}); + it("window", () => { + expect(_this.onanimationstart).toBeNull(); + }); +}); + +describe("兼容问题", () => { + const _this = proxyContext({}, {}); + // https://github.com/xcanwin/KeepChatGPT 环境隔离得不够干净导致的 + it("Uncaught TypeError: Illegal invocation #189", () => { + return new Promise((resolve) => { + console.log(_this.setTimeout.prototype); + _this.setTimeout(resolve, 100); + }); + }); + // AC-baidu-重定向优化百度搜狗谷歌必应搜索_favicon_双列 + it("TypeError: Object.freeze is not a function #116", () => { + expect(() => _this.Object.freeze({})).not.toThrow(); + }); +}); + +describe("Symbol", () => { + const _this = proxyContext({}, {}); + // 允许往global写入Symbol属性,影响内容: https://bbs.tampermonkey.net.cn/thread-5509-1-1.html + it("Symbol", () => { + const s = Symbol("test"); + _this[s] = "ok"; + expect(_this[s]).toEqual("ok"); + }); + // toString.call(window)返回的是'[object Object]'而不是'[object Window]',影响内容: https://github.com/scriptscat/scriptcat/issues/260 + it("Window", () => { + expect(toString.call(_this)).toEqual("[object Window]"); + }); +}); + +// Object.hasOwnProperty穿透 https://github.com/scriptscat/scriptcat/issues/272 +describe("Object", () => { + const _this = proxyContext({}, {}); + it("hasOwnProperty", () => { + expect(_this.hasOwnProperty("test1")).toEqual(false); + _this.test1 = "ok"; + expect(_this.hasOwnProperty("test1")).toEqual(true); + expect(_this.hasOwnProperty("test")).toEqual(true); + }); +}); diff --git a/src/runtime/content/utils.ts b/src/runtime/content/utils.ts new file mode 100644 index 0000000..5f33766 --- /dev/null +++ b/src/runtime/content/utils.ts @@ -0,0 +1,349 @@ +import { ScriptRunResouce } from "@App/app/repo/scripts"; +import { v4 as uuidv4 } from "uuid"; +import GMApi, { ApiValue, GMContext } from "./gm_api"; + +// 构建脚本运行代码 +export function compileScriptCode(scriptRes: ScriptRunResouce): string { + let { code } = scriptRes; + let require = ""; + if (scriptRes.metadata.require) { + scriptRes.metadata.require.forEach((val) => { + const res = scriptRes.resource[val]; + if (res) { + require = `${require}\n${res.content}`; + } + }); + } + code = require + code; + return `with (context) return (async ()=>{\n${code}\n//# sourceURL=${chrome.runtime.getURL( + `/${encodeURI(scriptRes.name)}.user.js` + )}\n})()`; +} + +export type ScriptFunc = (context: any, GM_info: any) => any; + +// 通过脚本代码编译脚本函数 +export function compileScript(code: string): ScriptFunc { + return new Function("context", "GM_info", code); +} + +export function compileInjectScript(script: ScriptRunResouce): string { + return `window['${script.flag}']=function(context,GM_info){\n${script.code}\n}`; +} + +// 设置api依赖 +function setDepend(context: { [key: string]: any }, apiVal: ApiValue) { + if (apiVal.param.depend) { + for (let i = 0; i < apiVal.param.depend.length; i += 1) { + const value = apiVal.param.depend[i]; + const dependApi = GMContext.apis.get(value); + if (!dependApi) { + return; + } + if (value.startsWith("GM.")) { + const [, t] = value.split("."); + (<{ [key: string]: any }>context.GM)[t] = dependApi.api.bind(context); + } else { + context[value] = dependApi.api.bind(context); + } + setDepend(context, dependApi); + } + } +} + +// 构建沙盒上下文 +export function createContext(scriptRes: ScriptRunResouce, GMInfo: any, message: MessageManager): GMApi { + // 按照GMApi构建 + const context: { [key: string]: any } = { + scriptRes, + message, + valueChangeListener: new Map(), + sendMessage: GMApi.prototype.sendMessage, + connect: GMApi.prototype.connect, + runFlag: uuidv4(), + valueUpdate: GMApi.prototype.valueUpdate, + GM: { Info: GMInfo }, + GM_info: GMInfo, + }; + if (scriptRes.metadata.grant) { + scriptRes.metadata.grant.forEach((val) => { + const api = GMContext.apis.get(val); + if (!api) { + return; + } + if (val.startsWith("GM.")) { + const [, t] = val.split("."); + (<{ [key: string]: any }>context.GM)[t] = api.api.bind(context); + } else if (val === "GM_cookie") { + // 特殊处理GM_cookie.list之类 + context[val] = api.api.bind(context); + // eslint-disable-next-line func-names, camelcase + const GM_cookie = function (action: string) { + return ( + details: GMTypes.CookieDetails, + done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void + ) => { + return context[val](action, details, done); + }; + }; + context[val].list = GM_cookie("list"); + context[val].delete = GM_cookie("delete"); + context[val].set = GM_cookie("set"); + } else { + context[val] = api.api.bind(context); + } + setDepend(context, api); + }); + } + context.unsafeWindow = window; + return context; +} + +export const writables: { [key: string]: any } = { + addEventListener: global.addEventListener.bind(global), + removeEventListener: global.removeEventListener.bind(global), + dispatchEvent: global.dispatchEvent.bind(global), +}; + +// 记录初始的window字段 +export const init = new Map(); + +// 需要用到全局的 +export const unscopables: { [key: string]: boolean } = { + NodeFilter: true, + RegExp: true, +}; + +// 复制原有的,防止被前端网页复写 +const descs = Object.getOwnPropertyDescriptors(global); +Object.keys(descs).forEach((key) => { + const desc = descs[key]; + // 可写但不在特殊配置writables中 + if (desc && desc.writable && !writables[key]) { + if (typeof desc.value === "function") { + // 判断是否需要bind,例如Object、Function这些就不需要bind + if (desc.value.prototype) { + writables[key] = desc.value; + } else { + writables[key] = desc.value.bind(global); + } + } else { + writables[key] = desc.value; + } + } else { + init.set(key, true); + } +}); + +export function warpObject(thisContext: Object, ...context: Object[]) { + // 处理Object上的方法 + thisContext.hasOwnProperty = (name: PropertyKey) => { + return ( + Object.hasOwnProperty.call(thisContext, name) || context.some((val) => Object.hasOwnProperty.call(val, name)) + ); + }; + thisContext.isPrototypeOf = (name: Object) => { + return Object.isPrototypeOf.call(thisContext, name) || context.some((val) => Object.isPrototypeOf.call(val, name)); + }; + thisContext.propertyIsEnumerable = (name: PropertyKey) => { + return ( + Object.propertyIsEnumerable.call(thisContext, name) || + context.some((val) => Object.propertyIsEnumerable.call(val, name)) + ); + }; +} + +// 拦截上下文 +export function proxyContext(global: any, context: any, thisContext?: { [key: string]: any }) { + const special = Object.assign(writables); + // 处理某些特殊的属性 + // 后台脚本要不要考虑不能使用eval? + if (!thisContext) { + thisContext = {}; + } + thisContext.eval = global.eval; + thisContext.define = undefined; + warpObject(thisContext, special, global, context); + // keyword是与createContext时同步的,避免访问到context的内部变量 + const contextKeyword: { [key: string]: any } = { + message: 1, + valueChangeListener: 1, + connect: 1, + runFlag: 1, + valueUpdate: 1, + sendMessage: 1, + scriptRes: 1, + }; + // @ts-ignore + const proxy = new Proxy(context, { + defineProperty(_, name, desc) { + if (Object.defineProperty(thisContext, name, desc)) { + return true; + } + return false; + }, + get(_, name): any { + switch (name) { + case "window": + case "self": + case "globalThis": + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return proxy; + case "top": + case "parent": + if (global[name] === global.self) { + return special.global || proxy; + } + return global.top; + default: + break; + } + if (name !== "undefined") { + if (has(thisContext, name)) { + // @ts-ignore + return thisContext[name]; + } + if (typeof name === "string") { + if (has(context, name)) { + if (has(contextKeyword, name)) { + return undefined; + } + return context[name]; + } + if (has(special, name)) { + if (typeof special[name] === "function" && !(<{ prototype: any }>special[name]).prototype) { + return (<{ bind: any }>special[name]).bind(global); + } + return special[name]; + } + if (has(global, name)) { + // 特殊处理onxxxx的事件 + if (name.startsWith("on")) { + if (typeof global[name] === "function" && !(<{ prototype: any }>global[name]).prototype) { + return (<{ bind: any }>global[name]).bind(global); + } + return global[name]; + } + } + if (init.has(name)) { + const val = global[name]; + if (typeof val === "function" && !(<{ prototype: any }>val).prototype) { + return (<{ bind: any }>val).bind(global); + } + return val; + } + } else if (name === Symbol.unscopables) { + return unscopables; + } + } + return undefined; + }, + has(_, name) { + switch (name) { + case "window": + case "self": + case "globalThis": + return true; + case "top": + case "parent": + if (global[name] === global.self) { + return true; + } + return true; + default: + break; + } + if (name !== "undefined") { + if (typeof name === "string") { + if (has(unscopables, name)) { + return false; + } + if (has(thisContext, name)) { + return true; + } + if (has(context, name)) { + if (has(contextKeyword, name)) { + return false; + } + return true; + } + if (has(special, name)) { + return true; + } + // 只处理onxxxx的事件 + if (has(global[name], name)) { + if (name.startsWith("on")) { + return true; + } + } + } else if (typeof name === "symbol") { + return has(thisContext, name); + } + } + return false; + }, + set(_, name: string, val) { + switch (name) { + case "window": + case "self": + case "globalThis": + return false; + default: + } + if (has(special, name)) { + special[name] = val; + return true; + } + if (init.has(name)) { + const des = Object.getOwnPropertyDescriptor(global, name); + // 只读的return + if (des && des.get && !des.set && des.configurable) { + return true; + } + // 只处理onxxxx的事件 + if (has(global, name) && name.startsWith("on")) { + if (val === undefined) { + global.removeEventListener(name.slice(2), thisContext[name]); + } else { + if (thisContext[name]) { + global.removeEventListener(name.slice(2), thisContext[name]); + } + global.addEventListener(name.slice(2), val); + } + thisContext[name] = val; + return true; + } + } + // @ts-ignore + thisContext[name] = val; + return true; + }, + getOwnPropertyDescriptor(_, name) { + try { + let ret = Object.getOwnPropertyDescriptor(thisContext, name); + if (ret) { + return ret; + } + ret = Object.getOwnPropertyDescriptor(context, name); + if (ret) { + return ret; + } + ret = Object.getOwnPropertyDescriptor(global, name); + return ret; + } catch (e) { + return undefined; + } + }, + }); + proxy[Symbol.toStringTag] = "Window"; + return proxy; +} + +export function addStyle(css: string): HTMLElement { + const dom = document.createElement("style"); + dom.innerHTML = css; + if (document.head) { + return document.head.appendChild(dom); + } + return document.documentElement.appendChild(dom); +} diff --git a/src/service_worker.ts b/src/service_worker.ts index 3adb2a6..96154a3 100644 --- a/src/service_worker.ts +++ b/src/service_worker.ts @@ -48,9 +48,9 @@ async function main() { const loggerCore = new LoggerCore({ debug: process.env.NODE_ENV === "development", writer: new DBWriter(new LoggerDAO()), - labels: { env: "background" }, + labels: { env: "service_worker" }, }); - loggerCore.logger().debug("background start"); + loggerCore.logger().debug("service worker start"); // 初始化管理器 const manager = new ServiceWorkerManager(); manager.initManager();