Compare commits
73 Commits
7697c91d95
...
main
Author | SHA1 | Date | |
---|---|---|---|
ec28795dbb | |||
44041f4735 | |||
ffabe268b1 | |||
ddd3219bae | |||
14baa176d9 | |||
3c1e30182f | |||
1aaf1bbd4a | |||
8a216933ca | |||
51fe2a89e1 | |||
a26f1c5014 | |||
e1a890a400 | |||
79e8b8869a | |||
d761c62500 | |||
d200809fee | |||
67ba515b2c | |||
d9fdded7fb | |||
498d36567b | |||
d7adffcd9f | |||
44066d9543 | |||
9a53c4e4e9 | |||
1de1ba6373 | |||
185ba6e5cc | |||
07c4518cba | |||
e2832093f0 | |||
44e1449e03 | |||
1a531dfad5 | |||
071e728f06 | |||
44b6f11b19 | |||
2a0286e47d | |||
c7763227d0 | |||
b76a685988 | |||
3b2e72127f | |||
1965137191 | |||
d697928fb0 | |||
5c0d4a2560 | |||
7ca85801ef | |||
a2870eb18e | |||
239f961485 | |||
088f5ae68f | |||
e94045572d | |||
259917545e | |||
0d86dae710 | |||
9f70b7eb7a | |||
3e660a2ea8 | |||
d97a64c644 | |||
42975d47cf | |||
f26aecd10f | |||
c43afb0a94 | |||
a7620dd7e5 | |||
1a55bb348f | |||
a8054451ac | |||
651384f12c | |||
9ce1826a34 | |||
eea3b43e0b | |||
fc69019877 | |||
11c08f1b4c | |||
21899f0040 | |||
20124be0e4 | |||
db8c5ec7b5 | |||
315f5f148c | |||
48f1b1f33b | |||
2c62566696 | |||
57bef5a023 | |||
131f1bda40 | |||
fd2aba4286 | |||
c2219db73e | |||
d682b4d566 | |||
98c86d61f1 | |||
c8a8d136c8 | |||
0a025f1b50 | |||
fcd4682aff | |||
99e33c18f6 | |||
6d983ed0e4 |
18
.github/workflows/build.yaml
vendored
18
.github/workflows/build.yaml
vendored
@@ -12,12 +12,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Package with Node
|
||||
env:
|
||||
@@ -26,11 +28,11 @@ jobs:
|
||||
mkdir dist
|
||||
echo "$CHROME_PEM" > ./dist/scriptcat.pem
|
||||
chmod 600 ./dist/scriptcat.pem
|
||||
npm ci
|
||||
npm run pack
|
||||
pnpm i
|
||||
pnpm run pack
|
||||
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: all-artifacts
|
||||
path: |
|
||||
@@ -38,7 +40,7 @@ jobs:
|
||||
dist/*.crx
|
||||
|
||||
- name: Archive extension
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: scriptcat
|
||||
path: |
|
||||
|
61
.github/workflows/packageRelease.yml
vendored
61
.github/workflows/packageRelease.yml
vendored
@@ -11,12 +11,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Package with Node
|
||||
env:
|
||||
@@ -25,52 +27,9 @@ jobs:
|
||||
mkdir dist
|
||||
echo "$CHROME_PEM" > ./dist/scriptcat.pem
|
||||
chmod 600 ./dist/scriptcat.pem
|
||||
npm ci
|
||||
npm test
|
||||
npm run pack
|
||||
pnpm i
|
||||
pnpm 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
|
||||
- uses: ncipollo/release-action@v1
|
||||
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
|
||||
artifacts: "./dist/*.zip,./dist/*.crx"
|
||||
|
23
.github/workflows/test.yaml
vendored
23
.github/workflows/test.yaml
vendored
@@ -3,28 +3,27 @@ name: test
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release/*
|
||||
- dev
|
||||
- develop/*
|
||||
pull_request:
|
||||
- disable # 暂时禁用
|
||||
# pull_request:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
name: Run tests
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Unit Test
|
||||
run: |
|
||||
npm ci
|
||||
npm test
|
||||
pnpm i
|
||||
pnpm run coverage
|
||||
|
||||
- name: Upload coverage reports to Codecov with GitHub Action
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v5
|
||||
|
31
.gitignore
vendored
31
.gitignore
vendored
@@ -1,16 +1,31 @@
|
||||
# Local
|
||||
.DS_Store
|
||||
*.local
|
||||
*.log*
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Dist
|
||||
node_modules
|
||||
dist/
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
# Editor directories and files
|
||||
.vscode
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
coverage
|
||||
CHANGELOG.md
|
||||
|
||||
tailwind.config.js
|
||||
|
||||
.env
|
||||
|
@@ -3,11 +3,16 @@ import js from "@eslint/js";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactJsx from "eslint-plugin-react/configs/jsx-runtime.js";
|
||||
import react from "eslint-plugin-react/configs/recommended.js";
|
||||
import globals from "globals";
|
||||
import ts from "typescript-eslint";
|
||||
|
||||
export default [
|
||||
{ languageOptions: { globals: globals.browser } },
|
||||
{
|
||||
env: {
|
||||
browser: true,
|
||||
es2020: true,
|
||||
webextensions: true,
|
||||
},
|
||||
},
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...fixupConfigRules([
|
||||
|
@@ -1,5 +1,5 @@
|
||||
// ==UserScript==
|
||||
// @name New Userscript
|
||||
// @name GM cookie操作
|
||||
// @namespace https://bbs.tampermonkey.net.cn/
|
||||
// @version 0.1.0
|
||||
// @description 可以控制浏览器的cookie, 必须指定@connect, 并且每次一个新的域调用都需要用户确定
|
||||
@@ -9,8 +9,6 @@
|
||||
// @connect example.com
|
||||
// ==/UserScript==
|
||||
|
||||
// GM_cookie("store") 方法请看gm_value.js的例子, 可用于隐身窗口的操作
|
||||
|
||||
GM_cookie("set", {
|
||||
url: "http://example.com/cookie",
|
||||
name: "cookie1", value: "value"
|
||||
|
@@ -9,7 +9,7 @@
|
||||
// ==/UserScript==
|
||||
|
||||
GM_download({
|
||||
url: "https://scriptcat.org/api/v1/gm_crx/download/ScriptCat",
|
||||
url: "https://scriptcat.org/api/v2/open/crx-download/ndcooeababalnlpkfedmmbbbgkljhpjf",
|
||||
name: "scriptcat.crx",
|
||||
headers: {
|
||||
"referer": "http://www.example.com/",
|
||||
|
@@ -9,8 +9,20 @@
|
||||
// @grant GM_unregisterMenuCommand
|
||||
// ==/UserScript==
|
||||
|
||||
|
||||
const id = GM_registerMenuCommand("测试菜单", () => {
|
||||
const id = GM_registerMenuCommand(
|
||||
"测试菜单",
|
||||
() => {
|
||||
console.log(id);
|
||||
GM_unregisterMenuCommand(id);
|
||||
}, "h");
|
||||
},
|
||||
"h"
|
||||
);
|
||||
|
||||
const id2 = GM_registerMenuCommand(
|
||||
"测试菜单2",
|
||||
() => {
|
||||
console.log(id2);
|
||||
GM_unregisterMenuCommand(id2);
|
||||
},
|
||||
"j"
|
||||
);
|
||||
|
@@ -14,10 +14,8 @@
|
||||
// @grant GM_cookie
|
||||
// ==/UserScript==
|
||||
|
||||
GM_addValueChangeListener("test_set", function (name, oldval, newval, remote, tabid) {
|
||||
GM_cookie("store", tabid,(storeId) => {
|
||||
console.log("store",storeId);
|
||||
});
|
||||
GM_addValueChangeListener("test_set", function (name, oldval, newval, remote) {
|
||||
console.log("test_set change", name, oldval, newval, remote);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
@@ -30,3 +28,7 @@ setTimeout(() => {
|
||||
}, 3000);
|
||||
|
||||
GM_setValue("test_set", new Date().getTime());
|
||||
|
||||
console.log(GM_getValue("test_set2"));
|
||||
|
||||
GM_setValue("test_set2", new Date().getTime());
|
18
example/gm_value/gm_value_1.js
Normal file
18
example/gm_value/gm_value_1.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// ==UserScript==
|
||||
// @name gm value storage 设置方
|
||||
// @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_deleteValue
|
||||
// @storageName example
|
||||
// ==/UserScript==
|
||||
|
||||
setTimeout(() => {
|
||||
GM_deleteValue("test_set");
|
||||
}, 3000);
|
||||
|
||||
GM_setValue("test_set", new Date().getTime());
|
17
example/gm_value/gm_value_1_bg.js
Normal file
17
example/gm_value/gm_value_1_bg.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// ==UserScript==
|
||||
// @name gm value storage 设置方 - 定时脚本
|
||||
// @namespace https://bbs.tampermonkey.net.cn/
|
||||
// @version 0.1.0
|
||||
// @description 多个脚本之间共享数据 设置方 - 定时脚本
|
||||
// @author You
|
||||
// @run-at document-start
|
||||
// @grant GM_setValue
|
||||
// @grant GM_deleteValue
|
||||
// @storageName example
|
||||
// @crontab */5 * * * * *
|
||||
// ==/UserScript==
|
||||
|
||||
return new Promise((resolve) => {
|
||||
GM_setValue("test_set", new Date().getTime());
|
||||
resolve();
|
||||
});
|
23
example/gm_value/gm_value_2.js
Normal file
23
example/gm_value/gm_value_2.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// ==UserScript==
|
||||
// @name gm value storage 读取与监听方
|
||||
// @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_getValue
|
||||
// @grant GM_addValueChangeListener
|
||||
// @grant GM_listValues
|
||||
// @grant GM_cookie
|
||||
// @storageName example
|
||||
// ==/UserScript==
|
||||
|
||||
GM_addValueChangeListener("test_set", function (name, oldval, newval, remote) {
|
||||
console.log("test_set change", name, oldval, newval, remote);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
console.log("test_set: ", GM_getValue("test_set"));
|
||||
console.log("value list:", GM_listValues());
|
||||
}, 2000);
|
27
example/gm_value/gm_value_2_bg.js
Normal file
27
example/gm_value/gm_value_2_bg.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// ==UserScript==
|
||||
// @name gm value storage 读取与监听方 - 后台脚本
|
||||
// @namespace https://bbs.tampermonkey.net.cn/
|
||||
// @version 0.1.0
|
||||
// @description 多个脚本之间共享数据 读取与监听方 - 后台脚本
|
||||
// @author You
|
||||
// @run-at document-start
|
||||
// @grant GM_getValue
|
||||
// @grant GM_addValueChangeListener
|
||||
// @grant GM_listValues
|
||||
// @grant GM_cookie
|
||||
// @storageName example
|
||||
// @background
|
||||
// ==/UserScript==
|
||||
|
||||
return new Promise((resolve) => {
|
||||
GM_addValueChangeListener("test_set", function (name, oldval, newval, remote) {
|
||||
console.log("value change", name, oldval, newval, remote);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
console.log("test_set: ", GM_getValue("test_set"));
|
||||
console.log("value list:", GM_listValues());
|
||||
}, 2000);
|
||||
// 永不返回resolve表示永不结束
|
||||
// resolve()
|
||||
});
|
@@ -13,14 +13,21 @@ const data = new FormData();
|
||||
|
||||
data.append("username", "admin");
|
||||
|
||||
data.append(
|
||||
"file",
|
||||
new File(["foo"], "foo.txt", {
|
||||
type: "text/plain",
|
||||
})
|
||||
);
|
||||
|
||||
GM_xmlhttpRequest({
|
||||
url: "https://bbs.tampermonkey.net.cn/",
|
||||
method: "POST",
|
||||
responseType: "blob",
|
||||
data: data,
|
||||
headers: {
|
||||
"referer": "http://www.example.com/",
|
||||
"origin": "www.example.com",
|
||||
referer: "http://www.example.com/",
|
||||
origin: "www.example.com",
|
||||
// 为空将不会发送此header
|
||||
"sec-ch-ua-mobile": "",
|
||||
},
|
||||
|
44
package.json
44
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "scriptcat",
|
||||
"version": "0.17.0-alpha",
|
||||
"version": "0.17.0-alpha.4",
|
||||
"description": "脚本猫,一个可以执行用户脚本的浏览器扩展,万物皆可脚本化,让你的浏览器可以做更多的事情!",
|
||||
"author": "CodFrm",
|
||||
"license": "GPLv3",
|
||||
@@ -11,6 +11,7 @@
|
||||
"coverage": "vitest run --coverage",
|
||||
"build": "cross-env NODE_ENV=production rspack build",
|
||||
"dev": "cross-env NODE_ENV=development rspack",
|
||||
"pack": "node ./scripts/pack.js",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint .",
|
||||
"lint-fix": "eslint --fix ."
|
||||
@@ -25,37 +26,29 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dexie": "^4.0.10",
|
||||
"eslint-linter-browserify": "^7.32.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"i18next": "^23.16.4",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"pako": "^2.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^15.1.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-joyride": "^2.9.3",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"semver": "^7.6.3",
|
||||
"uuid": "^11.0.3",
|
||||
"webdav": "^5.8.0",
|
||||
"yaml": "^2.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.6",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@rspack/cli": "^1.2.3",
|
||||
"@rspack/core": "^1.2.3",
|
||||
"@eslint/compat": "^1.2.8",
|
||||
"@eslint/js": "^9.24.0",
|
||||
"@rspack/cli": "^1.3.2",
|
||||
"@rspack/core": "^1.3.2",
|
||||
"@rspack/plugin-react-refresh": "^1.0.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"globals": "^15.14.0",
|
||||
"prettier": "^3.4.2",
|
||||
"react-refresh": "^0.16.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.22.0",
|
||||
"@types/chrome": "^0.0.279",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/node": "^22.10.2",
|
||||
@@ -67,10 +60,27 @@
|
||||
"@unocss/postcss": "0.65.0-beta.2",
|
||||
"@vitest/coverage-v8": "2.1.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"crx": "^5.0.1",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-userscripts": "^0.2.12",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mock-xmlhttprequest": "^8.4.1",
|
||||
"node-polyfill-webpack-plugin": "^3.0.0",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"prettier": "^3.5.3",
|
||||
"react-refresh": "^0.16.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.29.0",
|
||||
"unocss": "0.65.0-beta.2",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.8.0"
|
||||
}
|
3
packages/chrome-extension-mock/README.md
Normal file
3
packages/chrome-extension-mock/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# mock一个chrome扩展环境
|
||||
> 只针对自己的项目做了一些简单的封装,如果有需要可以自己修改
|
||||
|
32
packages/chrome-extension-mock/cookies.ts
Normal file
32
packages/chrome-extension-mock/cookies.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export default class Cookies {
|
||||
getAllCookieStores(
|
||||
callback: (cookieStores: chrome.cookies.CookieStore[]) => void
|
||||
) {
|
||||
callback([
|
||||
{
|
||||
id: "0",
|
||||
tabIds: [1],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
mockGetAll?: (
|
||||
details: chrome.cookies.GetAllDetails,
|
||||
callback: (cookies: chrome.cookies.Cookie[]) => void
|
||||
) => void | undefined;
|
||||
|
||||
getAll(
|
||||
details: chrome.cookies.GetAllDetails,
|
||||
callback: (cookies: chrome.cookies.Cookie[]) => void
|
||||
): void {
|
||||
this.mockGetAll?.(details, callback);
|
||||
}
|
||||
|
||||
set(details: chrome.cookies.SetDetails, callback?: () => void): void {
|
||||
callback?.();
|
||||
}
|
||||
|
||||
remove(details: chrome.cookies.Details, callback?: () => void): void {
|
||||
callback?.();
|
||||
}
|
||||
}
|
38
packages/chrome-extension-mock/declarativ_net_request.ts
Normal file
38
packages/chrome-extension-mock/declarativ_net_request.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export default class DeclarativeNetRequest {
|
||||
HeaderOperation = {
|
||||
APPEND: "append",
|
||||
SET: "set",
|
||||
REMOVE: "remove",
|
||||
};
|
||||
|
||||
RuleActionType = {
|
||||
BLOCK: "block",
|
||||
REDIRECT: "redirect",
|
||||
ALLOW: "allow",
|
||||
UPGRADE_SCHEME: "upgradeScheme",
|
||||
MODIFY_HEADERS: "modifyHeaders",
|
||||
ALLOW_ALL_REQUESTS: "allowAllRequests",
|
||||
};
|
||||
|
||||
ResourceType = {
|
||||
MAIN_FRAME: "main_frame",
|
||||
SUB_FRAME: "sub_frame",
|
||||
STYLESHEET: "stylesheet",
|
||||
SCRIPT: "script",
|
||||
IMAGE: "image",
|
||||
FONT: "font",
|
||||
OBJECT: "object",
|
||||
XMLHTTPREQUEST: "xmlhttprequest",
|
||||
PING: "ping",
|
||||
CSP_REPORT: "csp_report",
|
||||
MEDIA: "media",
|
||||
WEBSOCKET: "websocket",
|
||||
OTHER: "other",
|
||||
};
|
||||
|
||||
updateSessionRules() {
|
||||
return new Promise<void>((resolve) => {
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
}
|
5
packages/chrome-extension-mock/downloads.ts
Normal file
5
packages/chrome-extension-mock/downloads.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default class Downloads {
|
||||
download(_: any, callback: Function) {
|
||||
callback && callback();
|
||||
}
|
||||
}
|
9
packages/chrome-extension-mock/i18n.ts
Normal file
9
packages/chrome-extension-mock/i18n.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default class I18n {
|
||||
getUILanguage() {
|
||||
return "zh-CN";
|
||||
}
|
||||
|
||||
getAcceptLanguages(callback: (lngs: string[]) => void) {
|
||||
callback(["zh-CN"]);
|
||||
}
|
||||
}
|
26
packages/chrome-extension-mock/index.ts
Normal file
26
packages/chrome-extension-mock/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import Cookies from "./cookies";
|
||||
import Downloads from "./downloads";
|
||||
import Notifications from "./notifications";
|
||||
import Runtime from "./runtime";
|
||||
import MockTab from "./tab";
|
||||
import WebRequest from "./web_reqeuest";
|
||||
import Storage from "./storage";
|
||||
import I18n from "./i18n";
|
||||
import DeclarativeNetRequest from "./declarativ_net_request";
|
||||
|
||||
const chromeMock = {
|
||||
tabs: new MockTab(),
|
||||
runtime: new Runtime(),
|
||||
webRequest: new WebRequest(),
|
||||
notifications: new Notifications(),
|
||||
downloads: new Downloads(),
|
||||
cookies: new Cookies(),
|
||||
storage: new Storage(),
|
||||
i18n: new I18n(),
|
||||
declarativeNetRequest: new DeclarativeNetRequest(),
|
||||
init() {},
|
||||
};
|
||||
// @ts-ignore
|
||||
global.chrome = chromeMock;
|
||||
|
||||
export default chromeMock;
|
64
packages/chrome-extension-mock/notifications.ts
Normal file
64
packages/chrome-extension-mock/notifications.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export default class Notifications {
|
||||
notification: Map<string, boolean> = new Map();
|
||||
|
||||
onClosedHandler?: (id: string, byUser: boolean) => void;
|
||||
|
||||
onClosed = {
|
||||
addListener: (
|
||||
callback: (notificationId: string, byUser: boolean) => void
|
||||
) => {
|
||||
this.onClosedHandler = callback;
|
||||
},
|
||||
};
|
||||
|
||||
onButtonClickedHandler?: (id: string, index: number) => void;
|
||||
|
||||
onButtonClicked = {
|
||||
addListener: (
|
||||
callback: (notificationId: string, buttonIndex: number) => void
|
||||
) => {
|
||||
this.onButtonClickedHandler = callback;
|
||||
},
|
||||
};
|
||||
|
||||
mockClickButton(id: string, index: number) {
|
||||
this.onButtonClickedHandler?.(id, index);
|
||||
}
|
||||
|
||||
onClickedHandler?: (id: string) => void;
|
||||
|
||||
onClicked = {
|
||||
addListener: (callback: (notificationId: string) => void) => {
|
||||
this.onClickedHandler = callback;
|
||||
},
|
||||
};
|
||||
|
||||
create(
|
||||
options: chrome.notifications.NotificationOptions,
|
||||
callback?: (id: string) => void
|
||||
) {
|
||||
const id = Math.random().toString();
|
||||
this.notification.set(id, true);
|
||||
if (callback) {
|
||||
callback(id);
|
||||
}
|
||||
}
|
||||
|
||||
clear(id: string) {
|
||||
if (!this.notification.has(id)) {
|
||||
throw new Error("notification not found");
|
||||
}
|
||||
this.notification.delete(id);
|
||||
}
|
||||
|
||||
update(id: string) {
|
||||
if (!this.notification.has(id)) {
|
||||
throw new Error("notification not found");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
mockClick(id: string) {
|
||||
this.onClickedHandler?.(id);
|
||||
}
|
||||
}
|
62
packages/chrome-extension-mock/runtime.ts
Normal file
62
packages/chrome-extension-mock/runtime.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
type Port = chrome.runtime.Port & {
|
||||
setTargetPort: (port: chrome.runtime.Port) => void;
|
||||
messageListener: Array<(message: any) => void>;
|
||||
};
|
||||
|
||||
export default class Runtime {
|
||||
connectListener: Array<(port: chrome.runtime.Port) => void> = [];
|
||||
|
||||
onConnect = {
|
||||
addListener: (callback: (port: chrome.runtime.Port) => void) => {
|
||||
this.connectListener.push(callback);
|
||||
},
|
||||
};
|
||||
|
||||
Port(connectInfo?: chrome.runtime.ConnectInfo) {
|
||||
const messageListener: Array<(message: any) => void> = [];
|
||||
let targetPort: Port;
|
||||
return {
|
||||
setTargetPort(port: Port) {
|
||||
targetPort = port;
|
||||
},
|
||||
messageListener,
|
||||
name: connectInfo?.name || "",
|
||||
sender: {
|
||||
tab: {
|
||||
id: 1,
|
||||
} as unknown as chrome.tabs.Tab,
|
||||
url: window.location.href,
|
||||
},
|
||||
postMessage(message: any) {
|
||||
messageListener.forEach((callback) => {
|
||||
callback(message);
|
||||
});
|
||||
},
|
||||
onMessage: {
|
||||
addListener(callback: (message: any) => void) {
|
||||
targetPort.messageListener.push(callback);
|
||||
},
|
||||
} as unknown as chrome.events.Event<(message: any) => void>,
|
||||
onDisconnect: {
|
||||
addListener() {
|
||||
// do nothing
|
||||
},
|
||||
} as unknown as chrome.events.Event<() => void>,
|
||||
} as unknown as Port;
|
||||
}
|
||||
|
||||
connect(connectInfo?: chrome.runtime.ConnectInfo) {
|
||||
const port = this.Port(connectInfo);
|
||||
const targetPort = this.Port(connectInfo);
|
||||
targetPort.setTargetPort(port);
|
||||
port.setTargetPort(targetPort);
|
||||
this.connectListener.forEach((callback) => {
|
||||
callback(targetPort);
|
||||
});
|
||||
return port;
|
||||
}
|
||||
|
||||
getURL(path: string) {
|
||||
return `${window.location.href}${path}`;
|
||||
}
|
||||
}
|
33
packages/chrome-extension-mock/storage.ts
Normal file
33
packages/chrome-extension-mock/storage.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export default class Storage {
|
||||
sync = new CrhomeStorage();
|
||||
local = new CrhomeStorage();
|
||||
session = new CrhomeStorage();
|
||||
}
|
||||
|
||||
export class CrhomeStorage {
|
||||
data: any = {};
|
||||
|
||||
get(key: string, callback: (data: any) => void) {
|
||||
if (key === null) {
|
||||
callback(this.data);
|
||||
return;
|
||||
}
|
||||
callback({ [key]: this.data[key] });
|
||||
}
|
||||
|
||||
set(data: any, callback: () => void) {
|
||||
this.data = Object.assign(this.data, data);
|
||||
callback();
|
||||
}
|
||||
|
||||
remove(keys: string | string[], callback: () => void) {
|
||||
if (typeof keys === "string") {
|
||||
delete this.data[keys];
|
||||
} else {
|
||||
keys.forEach((key) => {
|
||||
delete this.data[key];
|
||||
});
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}
|
28
packages/chrome-extension-mock/tab.ts
Normal file
28
packages/chrome-extension-mock/tab.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import EventEmitter from "eventemitter3";
|
||||
|
||||
export default class MockTab {
|
||||
hook = new EventEmitter();
|
||||
|
||||
query() {
|
||||
return new Promise((resolve) => {
|
||||
resolve([]);
|
||||
});
|
||||
}
|
||||
|
||||
create(createProperties: chrome.tabs.CreateProperties, callback?: (tab: chrome.tabs.Tab) => void) {
|
||||
this.hook.emit("create", createProperties);
|
||||
callback?.({
|
||||
id: 1,
|
||||
} as chrome.tabs.Tab);
|
||||
}
|
||||
|
||||
remove(tabId: number) {
|
||||
this.hook.emit("remove", tabId);
|
||||
}
|
||||
|
||||
onRemoved = {
|
||||
addListener: (callback: any) => {
|
||||
this.hook.addListener("remove", callback);
|
||||
},
|
||||
};
|
||||
}
|
59
packages/chrome-extension-mock/web_reqeuest.ts
Normal file
59
packages/chrome-extension-mock/web_reqeuest.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export default class WebRequest {
|
||||
sendHeader?: (
|
||||
details: chrome.webRequest.WebRequestHeadersDetails
|
||||
) => chrome.webRequest.BlockingResponse | void;
|
||||
|
||||
mockXhr(xhr: any): any {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const _this = this;
|
||||
// eslint-disable-next-line func-names
|
||||
return function () {
|
||||
// eslint-disable-next-line new-cap
|
||||
const ret = new xhr();
|
||||
const header: chrome.webRequest.HttpHeader[] = [];
|
||||
ret.setRequestHeader = (k: string, v: string) => {
|
||||
header.push({
|
||||
name: k,
|
||||
value: v,
|
||||
});
|
||||
};
|
||||
const oldSend = ret.send.bind(ret);
|
||||
ret.send = (data: any) => {
|
||||
header.push({
|
||||
name: "cookie",
|
||||
value: "website=example.com",
|
||||
});
|
||||
const resp = _this.sendHeader?.({
|
||||
method: ret.method,
|
||||
url: ret.url,
|
||||
requestHeaders: header,
|
||||
initiator: chrome.runtime.getURL(""),
|
||||
} as chrome.webRequest.WebRequestHeadersDetails) as chrome.webRequest.BlockingResponse;
|
||||
resp.requestHeaders?.forEach((h) => {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
ret._authorRequestHeaders!.addHeader(h.name, h.value);
|
||||
});
|
||||
oldSend(data);
|
||||
};
|
||||
return ret;
|
||||
};
|
||||
}
|
||||
|
||||
onBeforeSendHeaders = {
|
||||
addListener: (callback: any) => {
|
||||
this.sendHeader = callback;
|
||||
},
|
||||
};
|
||||
|
||||
onHeadersReceived = {
|
||||
addListener: () => {
|
||||
// TODO
|
||||
},
|
||||
};
|
||||
|
||||
onCompleted = {
|
||||
addListener: () => {
|
||||
// TODO
|
||||
},
|
||||
};
|
||||
}
|
@@ -14,6 +14,7 @@ const compatMap = {
|
||||
GM_addElement: [
|
||||
{ type: "tampermonkey", versionConstraint: ">=4.11.6113" },
|
||||
{ type: "violentmonkey", versionConstraint: ">=2.13.0-beta.3" },
|
||||
{ type: "scriptcat", versionConstraint: "*" },
|
||||
],
|
||||
"GM.addStyle": [
|
||||
{ type: "tampermonkey", versionConstraint: ">=4.5" },
|
||||
@@ -24,15 +25,19 @@ const compatMap = {
|
||||
{ type: "violentmonkey", versionConstraint: "*" },
|
||||
{ type: "greasemonkey", versionConstraint: ">=0.6.1.4 <4" },
|
||||
],
|
||||
"GM.addValueChangeListener": [
|
||||
{ type: "tampermonkey", versionConstraint: ">=4.5" },
|
||||
],
|
||||
"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.cookie": [
|
||||
{ type: "tampermonkey", versionConstraint: ">=4.8" },
|
||||
{ type: "scriptcat", versionConstraint: "*" },
|
||||
],
|
||||
GM_cookie: [
|
||||
{ type: "tampermonkey", versionConstraint: ">=4.8" },
|
||||
{ type: "scriptcat", versionConstraint: "*" },
|
||||
],
|
||||
"GM.deleteValue": [
|
||||
{ type: "tampermonkey", versionConstraint: ">=4.5" },
|
||||
{ type: "violentmonkey", versionConstraint: ">=2.12.0" },
|
||||
@@ -54,9 +59,7 @@ const compatMap = {
|
||||
{ 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: "violentmonkey", versionConstraint: ">=2.12.0 <2.13.0.10" }],
|
||||
GM_getResourceURL: [
|
||||
{ type: "tampermonkey", versionConstraint: "*" },
|
||||
{ type: "violentmonkey", versionConstraint: "*" },
|
||||
@@ -139,9 +142,7 @@ const compatMap = {
|
||||
{ type: "violentmonkey", versionConstraint: "*" },
|
||||
{ type: "greasemonkey", versionConstraint: ">=0.2.5 <4" },
|
||||
],
|
||||
"GM.removeValueChangeListener": [
|
||||
{ type: "tampermonkey", versionConstraint: ">=4.5" },
|
||||
],
|
||||
"GM.removeValueChangeListener": [{ type: "tampermonkey", versionConstraint: ">=4.5" }],
|
||||
GM_removeValueChangeListener: [
|
||||
{ type: "tampermonkey", versionConstraint: ">=2.3.2607" },
|
||||
{ type: "violentmonkey", versionConstraint: ">=2.12.0" },
|
||||
@@ -168,9 +169,7 @@ const compatMap = {
|
||||
{ type: "violentmonkey", versionConstraint: "*" },
|
||||
{ type: "greasemonkey", versionConstraint: ">=0.3-beta <4" },
|
||||
],
|
||||
"GM.unregisterMenuCommand": [
|
||||
{ type: "tampermonkey", versionConstraint: ">=4.5" },
|
||||
],
|
||||
"GM.unregisterMenuCommand": [{ type: "tampermonkey", versionConstraint: ">=4.5" }],
|
||||
GM_unregisterMenuCommand: [
|
||||
{ type: "tampermonkey", versionConstraint: ">=3.6.3737" },
|
||||
{ type: "violentmonkey", versionConstraint: ">=2.9.4" },
|
@@ -195,6 +195,7 @@ const compatMap = {
|
||||
exportValue: [],
|
||||
exportCookie: [],
|
||||
scriptUrl: [],
|
||||
storageName: [],
|
||||
},
|
||||
};
|
||||
|
@@ -126,6 +126,6 @@ const config = {
|
||||
};
|
||||
|
||||
// 以文本形式导出默认规则
|
||||
const defaultConfig = JSON.stringify(config);
|
||||
const defaultConfig = JSON.stringify(config, null, 2);
|
||||
|
||||
export { defaultConfig, userscriptsConfig, userscriptsRules };
|
8
packages/filesystem/README.md
Normal file
8
packages/filesystem/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# 文件系统
|
||||
|
||||
用于同步和备份至云端
|
||||
|
||||
- zip
|
||||
- webdav
|
||||
- 百度网盘
|
||||
- onedrive
|
144
packages/filesystem/auth.ts
Normal file
144
packages/filesystem/auth.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { ExtServer, ExtServerApi } from "@App/app/const";
|
||||
import { WarpTokenError } from "./error";
|
||||
import { LocalStorageDAO } from "@App/app/repo/localStorage";
|
||||
|
||||
type NetDiskType = "baidu" | "onedrive";
|
||||
|
||||
export function GetNetDiskToken(netDiskType: NetDiskType): Promise<{
|
||||
code: number;
|
||||
msg: string;
|
||||
data: { token: { access_token: string; refresh_token: string } };
|
||||
}> {
|
||||
return fetch(ExtServerApi + `auth/net-disk/token?netDiskType=${netDiskType}`).then((resp) => resp.json());
|
||||
}
|
||||
|
||||
export function RefreshToken(
|
||||
netDiskType: NetDiskType,
|
||||
refreshToken: string
|
||||
): Promise<{
|
||||
code: number;
|
||||
msg: string;
|
||||
data: { token: { access_token: string; refresh_token: string } };
|
||||
}> {
|
||||
return fetch(ExtServerApi + `auth/net-disk/token/refresh?netDiskType=${netDiskType}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
netDiskType,
|
||||
refreshToken,
|
||||
}),
|
||||
}).then((resp) => resp.json());
|
||||
}
|
||||
|
||||
export function NetDisk(netDiskType: NetDiskType) {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (globalThis.window) {
|
||||
const loginWindow = window.open(`${ExtServer}api/v1/auth/net-disk?netDiskType=${netDiskType}`);
|
||||
const t = setInterval(() => {
|
||||
try {
|
||||
if (loginWindow!.closed) {
|
||||
clearInterval(t);
|
||||
resolve();
|
||||
}
|
||||
} catch (e) {
|
||||
clearInterval(t);
|
||||
resolve();
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
chrome.tabs
|
||||
.create({
|
||||
url: `${ExtServer}api/v1/auth/net-disk?netDiskType=${netDiskType}`,
|
||||
})
|
||||
.then(({ id: tabId }) => {
|
||||
const t = setInterval(async () => {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId!);
|
||||
console.log("query tab", tab);
|
||||
if (!tab) {
|
||||
clearInterval(t);
|
||||
resolve();
|
||||
}
|
||||
} catch (e) {
|
||||
clearInterval(t);
|
||||
resolve();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export type Token = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
createtime: number;
|
||||
};
|
||||
|
||||
export async function AuthVerify(netDiskType: NetDiskType, invalid?: boolean) {
|
||||
let token: Token | undefined;
|
||||
const localStorageDao = new LocalStorageDAO();
|
||||
const key = `netdisk:token:${netDiskType}`;
|
||||
try {
|
||||
token = await localStorageDao.get(key).then((resp) => {
|
||||
if (resp) {
|
||||
return resp.value;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
// token不存在,或者没有accessToken,重新获取
|
||||
if (!token || !token.accessToken) {
|
||||
// 强制重新获取token
|
||||
await NetDisk(netDiskType);
|
||||
const resp = await GetNetDiskToken(netDiskType);
|
||||
if (resp.code !== 0) {
|
||||
return Promise.reject(new WarpTokenError(new Error(resp.msg)));
|
||||
}
|
||||
token = {
|
||||
accessToken: resp.data.token.access_token,
|
||||
refreshToken: resp.data.token.refresh_token,
|
||||
createtime: Date.now(),
|
||||
};
|
||||
invalid = false;
|
||||
await localStorageDao.save({
|
||||
key,
|
||||
value: token,
|
||||
});
|
||||
}
|
||||
// token过期或者失效
|
||||
if (Date.now() >= token.createtime + 3600000 || invalid) {
|
||||
// 大于一小时刷新token
|
||||
try {
|
||||
const resp = await RefreshToken(netDiskType, token.refreshToken);
|
||||
if (resp.code !== 0) {
|
||||
await localStorageDao.delete(key);
|
||||
// 刷新失败,并且标记失效,尝试重新获取token
|
||||
if (invalid) {
|
||||
return AuthVerify(netDiskType);
|
||||
}
|
||||
return Promise.reject(new WarpTokenError(new Error(resp.msg)));
|
||||
}
|
||||
token = {
|
||||
accessToken: resp.data.token.access_token,
|
||||
refreshToken: resp.data.token.refresh_token,
|
||||
createtime: Date.now(),
|
||||
};
|
||||
// 更新token
|
||||
await localStorageDao.save({
|
||||
key,
|
||||
value: token,
|
||||
});
|
||||
} catch (e) {
|
||||
// 报错返回原token
|
||||
return Promise.resolve(token.accessToken);
|
||||
}
|
||||
} else {
|
||||
return Promise.resolve(token.accessToken);
|
||||
}
|
||||
return Promise.resolve(token.accessToken);
|
||||
}
|
154
packages/filesystem/baidu/baidu.ts
Normal file
154
packages/filesystem/baidu/baidu.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { SystemConfig } from "@App/pkg/config/config";
|
||||
import { AuthVerify } from "../auth";
|
||||
import FileSystem, { File, FileReader, FileWriter } from "../filesystem";
|
||||
import { joinPath } from "../utils";
|
||||
import { BaiduFileReader, BaiduFileWriter } from "./rw";
|
||||
|
||||
export default class BaiduFileSystem implements FileSystem {
|
||||
accessToken?: string;
|
||||
|
||||
path: string;
|
||||
|
||||
constructor(path?: string, accessToken?: string) {
|
||||
this.path = path || "/apps";
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
async verify(): Promise<void> {
|
||||
const token = await AuthVerify("baidu");
|
||||
this.accessToken = token;
|
||||
return this.list().then();
|
||||
}
|
||||
|
||||
open(file: File): Promise<FileReader> {
|
||||
// 获取fsid
|
||||
return Promise.resolve(new BaiduFileReader(this, file));
|
||||
}
|
||||
|
||||
openDir(path: string): Promise<FileSystem> {
|
||||
return Promise.resolve(new BaiduFileSystem(joinPath(this.path, path), this.accessToken));
|
||||
}
|
||||
|
||||
create(path: string): Promise<FileWriter> {
|
||||
return Promise.resolve(new BaiduFileWriter(this, joinPath(this.path, path)));
|
||||
}
|
||||
|
||||
createDir(dir: string): Promise<void> {
|
||||
dir = joinPath(this.path, dir);
|
||||
const urlencoded = new URLSearchParams();
|
||||
urlencoded.append("path", dir);
|
||||
urlencoded.append("size", "0");
|
||||
urlencoded.append("isdir", "1");
|
||||
urlencoded.append("rtype", "3");
|
||||
const myHeaders = new Headers();
|
||||
myHeaders.append("Content-Type", "application/x-www-form-urlencoded");
|
||||
return this.request(`https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=${this.accessToken}`, {
|
||||
method: "POST",
|
||||
headers: myHeaders,
|
||||
body: urlencoded,
|
||||
redirect: "follow",
|
||||
}).then((data) => {
|
||||
if (data.errno) {
|
||||
throw new Error(JSON.stringify(data));
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async request(url: string, config?: RequestInit) {
|
||||
config = config || {};
|
||||
const headers = <Headers>config.headers || new Headers();
|
||||
// 处理请求匿名不发送cookie
|
||||
await chrome.declarativeNetRequest.updateDynamicRules({
|
||||
removeRuleIds: [100],
|
||||
addRules: [
|
||||
{
|
||||
id: 100,
|
||||
action: {
|
||||
type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS,
|
||||
responseHeaders: [{ operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE, header: "cookie" }],
|
||||
},
|
||||
condition: {
|
||||
urlFilter: url,
|
||||
resourceTypes: [chrome.declarativeNetRequest.ResourceType.XMLHTTPREQUEST],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
config.headers = headers;
|
||||
return fetch(url, config)
|
||||
.then((data) => data.json())
|
||||
.then(async (data) => {
|
||||
if (data.errno === 111 || data.errno === -6) {
|
||||
const token = await AuthVerify("baidu", true);
|
||||
this.accessToken = token;
|
||||
url = url.replace(/access_token=[^&]+/, `access_token=${token}`);
|
||||
return fetch(url, config)
|
||||
.then((data2) => data2.json())
|
||||
.then((data2) => {
|
||||
if (data2.errno === 111 || data2.errno === -6) {
|
||||
throw new Error(JSON.stringify(data2));
|
||||
}
|
||||
return data2;
|
||||
});
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
chrome.declarativeNetRequest.updateDynamicRules({
|
||||
removeRuleIds: [100],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
delete(path: string): Promise<void> {
|
||||
const filelist = [joinPath(this.path, path)];
|
||||
const myHeaders = new Headers();
|
||||
myHeaders.append("Content-Type", "application/x-www-form-urlencoded");
|
||||
return this.request(
|
||||
`https://pan.baidu.com/rest/2.0/xpan/file?method=filemanager&access_token=${this.accessToken}&opera=delete`,
|
||||
{
|
||||
method: "POST",
|
||||
body: `async=0&filelist=${encodeURIComponent(JSON.stringify(filelist))}`,
|
||||
headers: myHeaders,
|
||||
}
|
||||
).then((data) => {
|
||||
if (data.errno) {
|
||||
throw new Error(JSON.stringify(data));
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
list(): Promise<File[]> {
|
||||
return this.request(
|
||||
`https://pan.baidu.com/rest/2.0/xpan/file?method=list&dir=${encodeURIComponent(
|
||||
this.path
|
||||
)}&order=time&access_token=${this.accessToken}`
|
||||
).then((data) => {
|
||||
if (data.errno) {
|
||||
if (data.errno === -9) {
|
||||
return [];
|
||||
}
|
||||
throw new Error(JSON.stringify(data));
|
||||
}
|
||||
const list: File[] = [];
|
||||
data.list.forEach((val: any) => {
|
||||
list.push({
|
||||
fsid: val.fs_id,
|
||||
name: val.server_filename,
|
||||
path: this.path,
|
||||
size: val.size,
|
||||
digest: val.md5,
|
||||
createtime: val.server_ctime * 1000,
|
||||
updatetime: val.server_mtime * 1000,
|
||||
});
|
||||
});
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
getDirUrl(): Promise<string> {
|
||||
return Promise.resolve(`https://pan.baidu.com/disk/main#/index?category=all&path=${encodeURIComponent(this.path)}`);
|
||||
}
|
||||
}
|
142
packages/filesystem/baidu/rw.ts
Normal file
142
packages/filesystem/baidu/rw.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { calculateMd5 } from "@App/pkg/utils/utils";
|
||||
import { MD5 } from "crypto-js";
|
||||
import { File, FileReader, FileWriter } from "../filesystem";
|
||||
import BaiduFileSystem from "./baidu";
|
||||
|
||||
export class BaiduFileReader implements FileReader {
|
||||
file: File;
|
||||
|
||||
fs: BaiduFileSystem;
|
||||
|
||||
constructor(fs: BaiduFileSystem, file: File) {
|
||||
this.fs = fs;
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
async read(type?: "string" | "blob"): Promise<string | Blob> {
|
||||
// 查询文件信息获取dlink
|
||||
const data = await this.fs.request(
|
||||
`https://pan.baidu.com/rest/2.0/xpan/multimedia?method=filemetas&access_token=${
|
||||
this.fs.accessToken
|
||||
}&fsids=[${this.file.fsid!}]&dlink=1`
|
||||
);
|
||||
if (!data.list.length) {
|
||||
return Promise.reject(new Error("file not found"));
|
||||
}
|
||||
switch (type) {
|
||||
case "string":
|
||||
return fetch(
|
||||
`${data.list[0].dlink}&access_token=${this.fs.accessToken}`
|
||||
).then((resp) => resp.text());
|
||||
default: {
|
||||
return fetch(
|
||||
`${data.list[0].dlink}&access_token=${this.fs.accessToken}`
|
||||
).then((resp) => resp.blob());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class BaiduFileWriter implements FileWriter {
|
||||
path: string;
|
||||
|
||||
fs: BaiduFileSystem;
|
||||
|
||||
constructor(fs: BaiduFileSystem, path: string) {
|
||||
this.fs = fs;
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
size(content: string | Blob) {
|
||||
if (content instanceof Blob) {
|
||||
return content.size;
|
||||
}
|
||||
return new Blob([content]).size;
|
||||
}
|
||||
|
||||
async md5(content: string | Blob) {
|
||||
if (content instanceof Blob) {
|
||||
return calculateMd5(content);
|
||||
}
|
||||
return MD5(content).toString();
|
||||
}
|
||||
|
||||
async write(content: string | Blob): Promise<void> {
|
||||
// 预上传获取id
|
||||
const size = this.size(content).toString();
|
||||
const md5 = await this.md5(content);
|
||||
const blockList: string[] = [md5];
|
||||
let urlencoded = new URLSearchParams();
|
||||
urlencoded.append("path", this.path);
|
||||
urlencoded.append("size", size);
|
||||
urlencoded.append("isdir", "0");
|
||||
urlencoded.append("autoinit", "1");
|
||||
urlencoded.append("rtype", "3");
|
||||
urlencoded.append("block_list", JSON.stringify(blockList));
|
||||
const myHeaders = new Headers();
|
||||
myHeaders.append("Content-Type", "application/x-www-form-urlencoded");
|
||||
const uploadid = await this.fs
|
||||
.request(
|
||||
`http://pan.baidu.com/rest/2.0/xpan/file?method=precreate&access_token=${this.fs.accessToken}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: myHeaders,
|
||||
body: urlencoded,
|
||||
}
|
||||
)
|
||||
.then((data) => {
|
||||
if (data.errno) {
|
||||
throw new Error(JSON.stringify(data));
|
||||
}
|
||||
return data.uploadid;
|
||||
});
|
||||
const body = new FormData();
|
||||
if (content instanceof Blob) {
|
||||
// 分片上传
|
||||
body.append("file", content);
|
||||
} else {
|
||||
body.append("file", new Blob([content]));
|
||||
}
|
||||
|
||||
await this.fs
|
||||
.request(
|
||||
`${
|
||||
`https://d.pcs.baidu.com/rest/2.0/pcs/superfile2?method=upload&access_token=${this.fs.accessToken}` +
|
||||
`&type=tmpfile&path=`
|
||||
}${encodeURIComponent(this.path)}&uploadid=${uploadid}&partseq=0`,
|
||||
{
|
||||
method: "POST",
|
||||
body,
|
||||
}
|
||||
)
|
||||
.then((data) => {
|
||||
if (data.errno) {
|
||||
throw new Error(JSON.stringify(data));
|
||||
}
|
||||
return data;
|
||||
});
|
||||
// 创建文件
|
||||
urlencoded = new URLSearchParams();
|
||||
urlencoded.append("path", this.path);
|
||||
urlencoded.append("size", size);
|
||||
urlencoded.append("isdir", "0");
|
||||
urlencoded.append("block_list", JSON.stringify(blockList));
|
||||
urlencoded.append("uploadid", uploadid);
|
||||
urlencoded.append("rtype", "3");
|
||||
return this.fs
|
||||
.request(
|
||||
`https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=${this.fs.accessToken}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: myHeaders,
|
||||
body: urlencoded,
|
||||
}
|
||||
)
|
||||
.then((data) => {
|
||||
if (data.errno) {
|
||||
throw new Error(JSON.stringify(data));
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
23
packages/filesystem/error.ts
Normal file
23
packages/filesystem/error.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export class WarpTokenError {
|
||||
error: Error;
|
||||
|
||||
constructor(error: Error) {
|
||||
this.error = error;
|
||||
}
|
||||
}
|
||||
|
||||
export function isWarpTokenError(error: any): error is WarpTokenError {
|
||||
return error instanceof WarpTokenError;
|
||||
}
|
||||
|
||||
export class WarpNetworkError {
|
||||
error: Error;
|
||||
|
||||
constructor(error: Error) {
|
||||
this.error = error;
|
||||
}
|
||||
}
|
||||
|
||||
export function isNetworkError(error: any): error is WarpNetworkError {
|
||||
return error instanceof WarpNetworkError;
|
||||
}
|
84
packages/filesystem/factory.ts
Normal file
84
packages/filesystem/factory.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import i18next from "i18next";
|
||||
import BaiduFileSystem from "./baidu/baidu";
|
||||
import FileSystem from "./filesystem";
|
||||
import OneDriveFileSystem from "./onedrive/onedrive";
|
||||
import WebDAVFileSystem from "./webdav/webdav";
|
||||
import ZipFileSystem from "./zip/zip";
|
||||
|
||||
export type FileSystemType = "zip" | "webdav" | "baidu-netdsik" | "onedrive";
|
||||
|
||||
export type FileSystemParams = {
|
||||
[key: string]: {
|
||||
title: string;
|
||||
type?: "select" | "authorize" | "password";
|
||||
options?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export default class FileSystemFactory {
|
||||
static create(type: FileSystemType, params: any): Promise<FileSystem> {
|
||||
let fs: FileSystem;
|
||||
switch (type) {
|
||||
case "zip":
|
||||
fs = new ZipFileSystem(params);
|
||||
break;
|
||||
case "webdav":
|
||||
fs = new WebDAVFileSystem(
|
||||
params.authType,
|
||||
params.url,
|
||||
params.username,
|
||||
params.password
|
||||
);
|
||||
break;
|
||||
case "baidu-netdsik":
|
||||
fs = new BaiduFileSystem();
|
||||
break;
|
||||
case "onedrive":
|
||||
fs = new OneDriveFileSystem();
|
||||
break;
|
||||
default:
|
||||
throw new Error("not found filesystem");
|
||||
}
|
||||
return fs.verify().then(() => fs);
|
||||
}
|
||||
|
||||
static params(): { [key: string]: FileSystemParams } {
|
||||
return {
|
||||
webdav: {
|
||||
authType: {
|
||||
title: i18next.t("auth_type"),
|
||||
type: "select",
|
||||
options: ["password", "digest", "none", "token"],
|
||||
},
|
||||
url: { title: i18next.t("url") },
|
||||
username: { title: i18next.t("username") },
|
||||
password: { title: i18next.t("password"), type: "password" },
|
||||
},
|
||||
"baidu-netdsik": {},
|
||||
onedrive: {},
|
||||
};
|
||||
}
|
||||
|
||||
static async mkdirAll(fs: FileSystem, path: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const dirs = path.split("/");
|
||||
let i = 0;
|
||||
const mkdir = () => {
|
||||
if (i >= dirs.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const dir = dirs.slice(0, i + 1).join("/");
|
||||
fs.createDir(dir)
|
||||
.then(() => {
|
||||
i += 1;
|
||||
mkdir();
|
||||
})
|
||||
.catch(() => {
|
||||
reject();
|
||||
});
|
||||
};
|
||||
mkdir();
|
||||
});
|
||||
}
|
||||
}
|
48
packages/filesystem/filesystem.ts
Normal file
48
packages/filesystem/filesystem.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export interface File {
|
||||
fsid?: number;
|
||||
// 文件名
|
||||
name: string;
|
||||
// 文件路径
|
||||
path: string;
|
||||
// 文件大小
|
||||
size: number;
|
||||
// 文件摘要
|
||||
digest: string;
|
||||
// 文件创建时间
|
||||
createtime: number;
|
||||
// 文件修改时间
|
||||
updatetime: number;
|
||||
}
|
||||
|
||||
type ReadType = "string" | "blob";
|
||||
export interface FileReader {
|
||||
// 读取文件内容
|
||||
read(type?: ReadType): Promise<any>;
|
||||
}
|
||||
|
||||
export interface FileWriter {
|
||||
// 写入文件内容
|
||||
write(content: string | Blob): Promise<void>;
|
||||
}
|
||||
|
||||
export type FileReadWriter = FileReader & FileWriter;
|
||||
|
||||
// 文件读取
|
||||
export default interface FileSystem {
|
||||
// 授权验证
|
||||
verify(): Promise<void>;
|
||||
// 打开文件
|
||||
open(file: File): Promise<FileReader>;
|
||||
// 打开目录
|
||||
openDir(path: string): Promise<FileSystem>;
|
||||
// 创建文件
|
||||
create(path: string): Promise<FileWriter>;
|
||||
// 创建目录
|
||||
createDir(dir: string): Promise<void>;
|
||||
// 删除文件
|
||||
delete(path: string): Promise<void>;
|
||||
// 文件列表
|
||||
list(): Promise<File[]>;
|
||||
// getDirUrl 获取目录的url
|
||||
getDirUrl(): Promise<string>;
|
||||
}
|
149
packages/filesystem/onedrive/onedrive.ts
Normal file
149
packages/filesystem/onedrive/onedrive.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { AuthVerify } from "../auth";
|
||||
import FileSystem, { File, FileReader, FileWriter } from "../filesystem";
|
||||
import { joinPath } from "../utils";
|
||||
import { OneDriveFileReader, OneDriveFileWriter } from "./rw";
|
||||
|
||||
export default class OneDriveFileSystem implements FileSystem {
|
||||
accessToken?: string;
|
||||
|
||||
path: string;
|
||||
|
||||
constructor(path?: string, accessToken?: string) {
|
||||
this.path = path || "/";
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
async verify(): Promise<void> {
|
||||
const token = await AuthVerify("onedrive");
|
||||
this.accessToken = token;
|
||||
return this.list().then();
|
||||
}
|
||||
|
||||
open(file: File): Promise<FileReader> {
|
||||
return Promise.resolve(new OneDriveFileReader(this, file));
|
||||
}
|
||||
|
||||
openDir(path: string): Promise<FileSystem> {
|
||||
if (path.startsWith("ScriptCat")) {
|
||||
path = path.substring(9);
|
||||
}
|
||||
return Promise.resolve(new OneDriveFileSystem(joinPath(this.path, path), this.accessToken));
|
||||
}
|
||||
|
||||
create(path: string): Promise<FileWriter> {
|
||||
return Promise.resolve(new OneDriveFileWriter(this, joinPath(this.path, path)));
|
||||
}
|
||||
|
||||
createDir(dir: string): Promise<void> {
|
||||
if (dir && dir.startsWith("ScriptCat")) {
|
||||
dir = dir.substring(9);
|
||||
if (dir.startsWith("/")) {
|
||||
dir = dir.substring(1);
|
||||
}
|
||||
}
|
||||
if (!dir) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
dir = joinPath(this.path, dir);
|
||||
const dirs = dir.split("/");
|
||||
let parent = "";
|
||||
if (dirs.length > 2) {
|
||||
parent = dirs.slice(0, dirs.length - 1).join("/");
|
||||
}
|
||||
const myHeaders = new Headers();
|
||||
myHeaders.append("Content-Type", "application/json");
|
||||
if (parent !== "") {
|
||||
parent = `:${parent}:`;
|
||||
}
|
||||
return this.request(`https://graph.microsoft.com/v1.0/me/drive/special/approot${parent}/children`, {
|
||||
method: "POST",
|
||||
headers: myHeaders,
|
||||
body: JSON.stringify({
|
||||
name: dirs[dirs.length - 1],
|
||||
folder: {},
|
||||
"@microsoft.graph.conflictBehavior": "replace",
|
||||
}),
|
||||
}).then((data: any) => {
|
||||
if (data.errno) {
|
||||
throw new Error(JSON.stringify(data));
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
request(url: string, config?: RequestInit, nothen?: boolean) {
|
||||
config = config || {};
|
||||
const headers = <Headers>config.headers || new Headers();
|
||||
if (url.indexOf("uploadSession") === -1) {
|
||||
headers.append(`Authorization`, `Bearer ${this.accessToken}`);
|
||||
}
|
||||
config.headers = headers;
|
||||
const ret = fetch(url, config);
|
||||
if (nothen) {
|
||||
return <Promise<Response>>ret;
|
||||
}
|
||||
return ret
|
||||
.then((data) => data.json())
|
||||
.then(async (data) => {
|
||||
if (data.error) {
|
||||
if (data.error.code === "InvalidAuthenticationToken") {
|
||||
const token = await AuthVerify("onedrive", true);
|
||||
this.accessToken = token;
|
||||
headers.set(`Authorization`, `Bearer ${this.accessToken}`);
|
||||
return fetch(url, config)
|
||||
.then((retryData) => retryData.json())
|
||||
.then((retryData) => {
|
||||
if (retryData.error) {
|
||||
throw new Error(JSON.stringify(retryData));
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}
|
||||
throw new Error(JSON.stringify(data));
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
delete(path: string): Promise<void> {
|
||||
return this.request(
|
||||
`https://graph.microsoft.com/v1.0/me/drive/special/approot:${joinPath(this.path, path)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
true
|
||||
).then(async (resp) => {
|
||||
if (resp.status !== 204) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
|
||||
list(): Promise<File[]> {
|
||||
let { path } = this;
|
||||
if (path === "/") {
|
||||
path = "";
|
||||
} else {
|
||||
path = `:${path}:`;
|
||||
}
|
||||
return this.request(`https://graph.microsoft.com/v1.0/me/drive/special/approot${path}/children`).then((data) => {
|
||||
const list: File[] = [];
|
||||
data.value.forEach((val: any) => {
|
||||
list.push({
|
||||
name: val.name,
|
||||
path: this.path,
|
||||
size: val.size,
|
||||
digest: val.eTag,
|
||||
createtime: new Date(val.createdDateTime).getTime(),
|
||||
updatetime: new Date(val.lastModifiedDateTime).getTime(),
|
||||
});
|
||||
});
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
getDirUrl(): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
103
packages/filesystem/onedrive/rw.ts
Normal file
103
packages/filesystem/onedrive/rw.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { calculateMd5 } from "@App/pkg/utils/utils";
|
||||
import { MD5 } from "crypto-js";
|
||||
import { File, FileReader, FileWriter } from "../filesystem";
|
||||
import { joinPath } from "../utils";
|
||||
import OneDriveFileSystem from "./onedrive";
|
||||
|
||||
export class OneDriveFileReader implements FileReader {
|
||||
file: File;
|
||||
|
||||
fs: OneDriveFileSystem;
|
||||
|
||||
constructor(fs: OneDriveFileSystem, file: File) {
|
||||
this.fs = fs;
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
async read(type?: "string" | "blob"): Promise<string | Blob> {
|
||||
const data = await this.fs.request(
|
||||
`https://graph.microsoft.com/v1.0/me/drive/special/approot:${joinPath(
|
||||
this.file.path,
|
||||
this.file.name
|
||||
)}:/content`,
|
||||
{},
|
||||
true
|
||||
);
|
||||
if (data.status !== 200) {
|
||||
return Promise.reject(await data.text());
|
||||
}
|
||||
switch (type) {
|
||||
case "string":
|
||||
return data.text();
|
||||
default: {
|
||||
return data.blob();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class OneDriveFileWriter implements FileWriter {
|
||||
path: string;
|
||||
|
||||
fs: OneDriveFileSystem;
|
||||
|
||||
constructor(fs: OneDriveFileSystem, path: string) {
|
||||
this.fs = fs;
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
size(content: string | Blob) {
|
||||
if (content instanceof Blob) {
|
||||
return content.size;
|
||||
}
|
||||
return new Blob([content]).size;
|
||||
}
|
||||
|
||||
async md5(content: string | Blob) {
|
||||
if (content instanceof Blob) {
|
||||
return calculateMd5(content);
|
||||
}
|
||||
return MD5(content).toString();
|
||||
}
|
||||
|
||||
async write(content: string | Blob): Promise<void> {
|
||||
// 预上传获取id
|
||||
const size = this.size(content).toString();
|
||||
let myHeaders = new Headers();
|
||||
myHeaders.append("Content-Type", "application/json");
|
||||
const uploadUrl = await this.fs
|
||||
.request(
|
||||
`https://graph.microsoft.com/v1.0/me/drive/special/approot:${this.path}:/createUploadSession`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: myHeaders,
|
||||
body: JSON.stringify({
|
||||
item: {
|
||||
"@microsoft.graph.conflictBehavior": "replace",
|
||||
// description: "description",
|
||||
// fileSystemInfo: {
|
||||
// "@odata.type": "microsoft.graph.fileSystemInfo",
|
||||
// },
|
||||
// name: this.path.substring(this.path.lastIndexOf("/") + 1),
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
throw new Error(JSON.stringify(data));
|
||||
}
|
||||
return data.uploadUrl;
|
||||
});
|
||||
myHeaders = new Headers();
|
||||
myHeaders.append(
|
||||
"Content-Range",
|
||||
`bytes 0-${parseInt(size, 10) - 1}/${size}`
|
||||
);
|
||||
return this.fs.request(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: content,
|
||||
headers: myHeaders,
|
||||
});
|
||||
}
|
||||
}
|
18
packages/filesystem/utils.ts
Normal file
18
packages/filesystem/utils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export function joinPath(...paths: string[]): string {
|
||||
let path = "";
|
||||
paths.forEach((value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (!value.startsWith("/")) {
|
||||
value = `/${value}`;
|
||||
}
|
||||
if (value.endsWith("/")) {
|
||||
value = value.substring(0, value.length - 1);
|
||||
}
|
||||
path += value;
|
||||
});
|
||||
return path;
|
||||
}
|
52
packages/filesystem/webdav/rw.ts
Normal file
52
packages/filesystem/webdav/rw.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { WebDAVClient } from "webdav";
|
||||
import { FileReader, FileWriter } from "../filesystem";
|
||||
|
||||
export class WebDAVFileReader implements FileReader {
|
||||
client: WebDAVClient;
|
||||
|
||||
path: string;
|
||||
|
||||
constructor(client: WebDAVClient, path: string) {
|
||||
this.client = client;
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
async read(type?: "string" | "blob"): Promise<string | Blob> {
|
||||
switch (type) {
|
||||
case "string":
|
||||
return this.client.getFileContents(this.path, {
|
||||
format: "text",
|
||||
}) as Promise<string>;
|
||||
default: {
|
||||
const resp = (await this.client.getFileContents(this.path, {
|
||||
format: "binary",
|
||||
})) as ArrayBuffer;
|
||||
return Promise.resolve(new Blob([resp]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class WebDAVFileWriter implements FileWriter {
|
||||
client: WebDAVClient;
|
||||
|
||||
path: string;
|
||||
|
||||
constructor(client: WebDAVClient, path: string) {
|
||||
this.client = client;
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
async write(content: string | Blob): Promise<void> {
|
||||
let resp;
|
||||
if (content instanceof Blob) {
|
||||
resp = await this.client.putFileContents(this.path, await content.arrayBuffer());
|
||||
} else {
|
||||
resp = await this.client.putFileContents(this.path, content);
|
||||
}
|
||||
if (resp) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error("write error"));
|
||||
}
|
||||
}
|
91
packages/filesystem/webdav/webdav.ts
Normal file
91
packages/filesystem/webdav/webdav.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { AuthType, createClient, FileStat, WebDAVClient } from "webdav";
|
||||
import FileSystem, { File, FileReader, FileWriter } from "../filesystem";
|
||||
import { joinPath } from "../utils";
|
||||
import { WebDAVFileReader, WebDAVFileWriter } from "./rw";
|
||||
import { WarpTokenError } from "../error";
|
||||
|
||||
export default class WebDAVFileSystem implements FileSystem {
|
||||
client: WebDAVClient;
|
||||
|
||||
url: string;
|
||||
|
||||
basePath: string = "/";
|
||||
|
||||
constructor(authType: AuthType | WebDAVClient, url?: string, username?: string, password?: string) {
|
||||
if (typeof authType === "object") {
|
||||
this.client = authType;
|
||||
this.basePath = joinPath(url || "");
|
||||
this.url = username!;
|
||||
} else {
|
||||
this.url = url!;
|
||||
this.client = createClient(url!, {
|
||||
authType,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async verify(): Promise<void> {
|
||||
try {
|
||||
await this.client.getQuota();
|
||||
} catch (e: any) {
|
||||
if (e.response && e.response.status === 401) {
|
||||
throw new WarpTokenError(e);
|
||||
}
|
||||
throw new Error("verify failed");
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
open(file: File): Promise<FileReader> {
|
||||
return Promise.resolve(new WebDAVFileReader(this.client, joinPath(file.path, file.name)));
|
||||
}
|
||||
|
||||
openDir(path: string): Promise<FileSystem> {
|
||||
return Promise.resolve(new WebDAVFileSystem(this.client, joinPath(this.basePath, path), this.url));
|
||||
}
|
||||
|
||||
create(path: string): Promise<FileWriter> {
|
||||
return Promise.resolve(new WebDAVFileWriter(this.client, joinPath(this.basePath, path)));
|
||||
}
|
||||
|
||||
async createDir(path: string): Promise<void> {
|
||||
try {
|
||||
return Promise.resolve(await this.client.createDirectory(joinPath(this.basePath, path)));
|
||||
} catch (e: any) {
|
||||
// 如果是405错误,则忽略
|
||||
if (e.message.includes("405")) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(path: string): Promise<void> {
|
||||
return this.client.deleteFile(joinPath(this.basePath, path));
|
||||
}
|
||||
|
||||
async list(): Promise<File[]> {
|
||||
const dir = (await this.client.getDirectoryContents(this.basePath)) as FileStat[];
|
||||
const ret: File[] = [];
|
||||
dir.forEach((item: FileStat) => {
|
||||
if (item.type !== "file") {
|
||||
return;
|
||||
}
|
||||
ret.push({
|
||||
name: item.basename,
|
||||
path: this.basePath,
|
||||
digest: item.etag || "",
|
||||
size: item.size,
|
||||
createtime: new Date(item.lastmod).getTime(),
|
||||
updatetime: new Date(item.lastmod).getTime(),
|
||||
});
|
||||
});
|
||||
return Promise.resolve(ret);
|
||||
}
|
||||
|
||||
getDirUrl(): Promise<string> {
|
||||
return Promise.resolve(this.url + this.basePath);
|
||||
}
|
||||
}
|
32
packages/filesystem/zip/rw.ts
Normal file
32
packages/filesystem/zip/rw.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import JSZip, { JSZipObject } from "jszip";
|
||||
import { FileReader, FileWriter } from "../filesystem";
|
||||
|
||||
export class ZipFileReader implements FileReader {
|
||||
zipObject: JSZipObject;
|
||||
|
||||
constructor(zipObject: JSZipObject) {
|
||||
this.zipObject = zipObject;
|
||||
}
|
||||
|
||||
read(type?: "string" | "blob"): Promise<string | Blob> {
|
||||
return this.zipObject.async(type || "string");
|
||||
}
|
||||
}
|
||||
|
||||
export class ZipFileWriter implements FileWriter {
|
||||
zip: JSZip;
|
||||
|
||||
path: string;
|
||||
|
||||
constructor(zip: JSZip, path: string) {
|
||||
this.zip = zip;
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
write(content: string): Promise<void> {
|
||||
this.zip.file(this.path, content);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
64
packages/filesystem/zip/zip.ts
Normal file
64
packages/filesystem/zip/zip.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import JSZip from "jszip";
|
||||
import FileSystem, { File, FileReader, FileWriter } from "@Packages/filesystem/filesystem";
|
||||
import { ZipFileReader, ZipFileWriter } from "./rw";
|
||||
|
||||
export default class ZipFileSystem implements FileSystem {
|
||||
zip: JSZip;
|
||||
|
||||
basePath: string;
|
||||
|
||||
// zip为空时,创建一个空的zip
|
||||
constructor(zip?: JSZip, basePath?: string) {
|
||||
this.zip = zip || new JSZip();
|
||||
this.basePath = basePath || "";
|
||||
}
|
||||
|
||||
verify(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
open(info: File): Promise<FileReader> {
|
||||
const path = info.name;
|
||||
const file = this.zip.file(path);
|
||||
if (file) {
|
||||
return Promise.resolve(new ZipFileReader(file));
|
||||
}
|
||||
return Promise.reject(new Error("File not found"));
|
||||
}
|
||||
|
||||
openDir(path: string): Promise<FileSystem> {
|
||||
return Promise.resolve(new ZipFileSystem(this.zip, path));
|
||||
}
|
||||
|
||||
create(path: string): Promise<FileWriter> {
|
||||
return Promise.resolve(new ZipFileWriter(this.zip, path));
|
||||
}
|
||||
|
||||
createDir(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
delete(path: string): Promise<void> {
|
||||
this.zip.remove(path);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
list(): Promise<File[]> {
|
||||
const files: File[] = [];
|
||||
Object.keys(this.zip.files).forEach((key) => {
|
||||
files.push({
|
||||
name: key,
|
||||
path: key,
|
||||
size: 0,
|
||||
digest: "",
|
||||
createtime: this.zip.files[key].date.getTime(),
|
||||
updatetime: this.zip.files[key].date.getTime(),
|
||||
});
|
||||
});
|
||||
return Promise.resolve(files);
|
||||
}
|
||||
|
||||
getDirUrl(): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
@@ -6,5 +6,11 @@
|
||||
|
||||
- 从脚本发起的GM请求,需要层层传递到service_worker/offscreen进行处理,有的GM只需要进行一次调用获取一次结果,有的需要进行
|
||||
多次调用获取多次结果,使用connect的方式实现
|
||||
- 从service_woker/offscreen发起的请求,类似消息队列,其它页面进行监听,触发后广播给所有页面,使用connect方式实现
|
||||
- 从扩展页面发起的请求,需要传递到service_worker/offscreen进行处理,如果只是单次调用,获取一次结果,使用message方式实现
|
||||
- 从service_worker/offscreen发起的请求,类似消息队列,其它页面进行监听,触发后广播给所有页面,使用sendMessage方式实现
|
||||
- 从扩展页面发起的请求,需要传递到service_worker/offscreen进行处理,如果只是单次调用,获取一次结果,使用sendMessage方式实现,如果需要
|
||||
多次调用获取多次结果,使用connect方式实现
|
||||
|
||||
## 注意点
|
||||
|
||||
- service_worker和offscreen之间可以使用postMessage的方式进行通信,避免同时监听message与connect导致冲突的问题
|
||||
- service_worker会变为不活动的状态,尽量避免与service_worker建立长连接
|
||||
|
@@ -1,14 +1,21 @@
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import { MessageConnect, MessageSend } from "./server";
|
||||
import Logger from "@App/app/logger/logger";
|
||||
|
||||
export async function sendMessage(msg: MessageSend, action: string, data?: any): Promise<any> {
|
||||
const res = await msg.sendMessage({ action, data });
|
||||
LoggerCore.getInstance().logger().trace("sendMessage", { action, data, response: res });
|
||||
const logger = LoggerCore.getInstance().logger().with({ action, data, response: res });
|
||||
logger.trace("sendMessage");
|
||||
if (res && res.code) {
|
||||
console.error(res);
|
||||
return Promise.reject(res.message);
|
||||
throw res.message;
|
||||
} else {
|
||||
return Promise.resolve(res.data);
|
||||
try {
|
||||
return res.data;
|
||||
} catch (e) {
|
||||
logger.trace("Invalid response data", Logger.E(e));
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
157
packages/message/custom_event_message.ts
Normal file
157
packages/message/custom_event_message.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Message, MessageConnect } from "./server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { PostMessage, WindowMessageBody, WindowMessageConnect } from "./window_message";
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import EventEmitter from "eventemitter3";
|
||||
|
||||
export class CustomEventPostMessage implements PostMessage {
|
||||
constructor(private send: CustomEventMessage) {}
|
||||
|
||||
postMessage(message: any): void {
|
||||
this.send.nativeSend(message);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用CustomEvent来进行通讯, 可以在content与inject中传递一些dom对象
|
||||
export class CustomEventMessage implements Message {
|
||||
EE: EventEmitter = new EventEmitter();
|
||||
|
||||
// 关联dom目标
|
||||
relatedTarget: Map<number, EventTarget> = new Map();
|
||||
|
||||
constructor(
|
||||
protected flag: string,
|
||||
protected isContent: boolean
|
||||
) {
|
||||
window.addEventListener((isContent ? "ct" : "fd") + flag, (event) => {
|
||||
if (event instanceof MouseEvent) {
|
||||
this.relatedTarget.set(event.clientX, event.relatedTarget!);
|
||||
return;
|
||||
} else if (event instanceof CustomEvent) {
|
||||
this.messageHandle(event.detail, new CustomEventPostMessage(this));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
messageHandle(data: WindowMessageBody, target: PostMessage) {
|
||||
// 处理消息
|
||||
if (data.type === "sendMessage") {
|
||||
// 接收到消息
|
||||
this.EE.emit("message", data.data, (resp: any) => {
|
||||
// 发送响应消息
|
||||
// 无消息id则不发送响应消息
|
||||
if (!data.messageId) {
|
||||
return;
|
||||
}
|
||||
const body: WindowMessageBody = {
|
||||
messageId: data.messageId,
|
||||
type: "respMessage",
|
||||
data: resp,
|
||||
};
|
||||
target.postMessage(body);
|
||||
});
|
||||
} else if (data.type === "respMessage") {
|
||||
// 接收到响应消息
|
||||
this.EE.emit("response:" + data.messageId, data);
|
||||
} else if (data.type === "connect") {
|
||||
this.EE.emit("connect", data.data, new WindowMessageConnect(data.messageId, this.EE, target));
|
||||
} else if (data.type === "disconnect") {
|
||||
this.EE.emit("disconnect:" + data.messageId);
|
||||
} else if (data.type === "connectMessage") {
|
||||
this.EE.emit("connectMessage:" + data.messageId, data.data);
|
||||
}
|
||||
}
|
||||
|
||||
onConnect(callback: (data: any, con: MessageConnect) => void): void {
|
||||
this.EE.addListener("connect", callback);
|
||||
}
|
||||
|
||||
onMessage(callback: (data: any, sendResponse: (data: any) => void) => void): void {
|
||||
this.EE.addListener("message", callback);
|
||||
}
|
||||
|
||||
connect(data: any): Promise<MessageConnect> {
|
||||
return new Promise((resolve) => {
|
||||
const body: WindowMessageBody = {
|
||||
messageId: uuidv4(),
|
||||
type: "connect",
|
||||
data,
|
||||
};
|
||||
this.nativeSend(body);
|
||||
resolve(new WindowMessageConnect(body.messageId, this.EE, new CustomEventPostMessage(this)));
|
||||
});
|
||||
}
|
||||
|
||||
nativeSend(detail: any) {
|
||||
if (typeof cloneInto !== "undefined") {
|
||||
try {
|
||||
LoggerCore.logger().info("nativeSend");
|
||||
detail = cloneInto(detail, document.defaultView);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
LoggerCore.logger().info("error data");
|
||||
}
|
||||
}
|
||||
|
||||
const ev = new CustomEvent((this.isContent ? "fd" : "ct") + this.flag, {
|
||||
detail,
|
||||
});
|
||||
window.dispatchEvent(ev);
|
||||
}
|
||||
|
||||
sendMessage(data: any): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
const body: WindowMessageBody = {
|
||||
messageId: uuidv4(),
|
||||
type: "sendMessage",
|
||||
data,
|
||||
};
|
||||
const callback = (body: WindowMessageBody) => {
|
||||
this.EE.removeListener("response:" + body.messageId, callback);
|
||||
resolve(body.data);
|
||||
};
|
||||
this.EE.addListener("response:" + body.messageId, callback);
|
||||
this.nativeSend(body);
|
||||
});
|
||||
}
|
||||
|
||||
// 同步发送消息
|
||||
// 与content页的消息通讯实际是同步,此方法不需要经过background
|
||||
// 但是请注意中间不要有promise
|
||||
syncSendMessage(data: any): any {
|
||||
const body: WindowMessageBody = {
|
||||
messageId: uuidv4(),
|
||||
type: "sendMessage",
|
||||
data,
|
||||
};
|
||||
let ret: any;
|
||||
const callback = (body: WindowMessageBody) => {
|
||||
this.EE.removeListener("response:" + body.messageId, callback);
|
||||
ret = body.data;
|
||||
};
|
||||
this.EE.addListener("response:" + body.messageId, callback);
|
||||
this.nativeSend(body);
|
||||
return ret;
|
||||
}
|
||||
|
||||
relateId = 0;
|
||||
|
||||
sendRelatedTarget(target: EventTarget): number {
|
||||
// 特殊处理relatedTarget,返回id进行关联
|
||||
// 先将relatedTarget转换成id发送过去
|
||||
const id = ++this.relateId;
|
||||
// 可以使用此种方式交互element
|
||||
const ev = new MouseEvent((this.isContent ? "fd" : "ct") + this.flag, {
|
||||
clientX: id,
|
||||
relatedTarget: target,
|
||||
});
|
||||
window.dispatchEvent(ev);
|
||||
return id;
|
||||
}
|
||||
|
||||
getAndDelRelatedTarget(id: number) {
|
||||
const target = this.relatedTarget.get(id);
|
||||
this.relatedTarget.delete(id);
|
||||
return target;
|
||||
}
|
||||
}
|
@@ -1,4 +1,6 @@
|
||||
import { Message, MessageConnect, MessageSend } from "./server";
|
||||
import Logger from "@App/app/logger/logger";
|
||||
import { Message, MessageConnect, MessageSend, MessageSender } from "./server";
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
|
||||
export class ExtensionMessageSend implements MessageSend {
|
||||
constructor() {}
|
||||
@@ -21,8 +23,9 @@ export class ExtensionMessageSend implements MessageSend {
|
||||
}
|
||||
}
|
||||
|
||||
export class ExtensionMessage extends ExtensionMessageSend implements Message {
|
||||
onConnect(callback: (data: any, con: MessageConnect) => void) {
|
||||
// 由于service worker的限制,特殊处理chrome.runtime.onConnect/Message
|
||||
export class ServiceWorkerMessage extends ExtensionMessageSend implements Message {
|
||||
onConnect(callback: (data: any, con: MessageConnect) => void): void {
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
const handler = (msg: any) => {
|
||||
port.onMessage.removeListener(handler);
|
||||
@@ -32,14 +35,61 @@ export class ExtensionMessage extends ExtensionMessageSend implements Message {
|
||||
});
|
||||
}
|
||||
|
||||
// 注意chrome.runtime.onMessage.addListener的回调函数需要返回true才能处理异步请求
|
||||
onMessage(callback: (data: any, sendResponse: (data: any) => void) => void) {
|
||||
onMessage(callback: (data: any, sendResponse: (data: any) => void, sender: MessageSender) => void): void {
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
return callback(msg, sendResponse);
|
||||
if (msg.action === "messageQueue") {
|
||||
return false;
|
||||
}
|
||||
return callback(msg, sendResponse, sender);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ExtensionMessage extends ExtensionMessageSend implements Message {
|
||||
constructor(private onUserScript = false) {
|
||||
super();
|
||||
}
|
||||
|
||||
onConnect(callback: (data: any, con: MessageConnect) => void) {
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
const handler = (msg: any) => {
|
||||
port.onMessage.removeListener(handler);
|
||||
callback(msg, new ExtensionMessageConnect(port));
|
||||
};
|
||||
port.onMessage.addListener(handler);
|
||||
});
|
||||
if (this.onUserScript) {
|
||||
// 监听用户脚本的连接
|
||||
chrome.runtime.onUserScriptConnect.addListener((port) => {
|
||||
const handler = (msg: any) => {
|
||||
port.onMessage.removeListener(handler);
|
||||
callback(msg, new ExtensionMessageConnect(port));
|
||||
};
|
||||
port.onMessage.addListener(handler);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 注意chrome.runtime.onMessage.addListener的回调函数需要返回true才能处理异步请求
|
||||
onMessage(callback: (data: any, sendResponse: (data: any) => void, sender: MessageSender) => void): void {
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.action === "messageQueue") {
|
||||
return false;
|
||||
}
|
||||
return callback(msg, sendResponse, sender);
|
||||
});
|
||||
if (this.onUserScript) {
|
||||
// 监听用户脚本的消息
|
||||
chrome.runtime.onUserScriptMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.action === "messageQueue") {
|
||||
return false;
|
||||
}
|
||||
return callback(msg, sendResponse, sender);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ExtensionMessageConnect implements MessageConnect {
|
||||
constructor(private con: chrome.runtime.Port) {}
|
||||
|
||||
@@ -59,3 +109,38 @@ export class ExtensionMessageConnect implements MessageConnect {
|
||||
this.con.onDisconnect.addListener(callback);
|
||||
}
|
||||
}
|
||||
|
||||
export class ExtensionContentMessageSend extends ExtensionMessageSend {
|
||||
constructor(
|
||||
private tabId: number,
|
||||
private options?: {
|
||||
frameId?: number;
|
||||
documentId?: string;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
sendMessage(data: any): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.options?.documentId || this.options?.frameId) {
|
||||
// 发送给指定的tab
|
||||
chrome.tabs.sendMessage(this.tabId, data, (resp) => {
|
||||
resolve(resp);
|
||||
});
|
||||
} else {
|
||||
chrome.tabs.sendMessage(this.tabId, data, this.options, (resp) => {
|
||||
resolve(resp);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
connect(data: any): Promise<MessageConnect> {
|
||||
return new Promise((resolve) => {
|
||||
const con = chrome.tabs.connect(this.tabId, this.options);
|
||||
con.postMessage(data);
|
||||
resolve(new ExtensionMessageConnect(con));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,98 +1,56 @@
|
||||
import EventEmitter from "eventemitter3";
|
||||
import { ApiFunction, MessageConnect, MessageSend, Server } from "./server";
|
||||
import { sendMessage } from "./client";
|
||||
import Logger from "@App/app/logger/logger";
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
|
||||
export type SubscribeCallback = (message: any) => void;
|
||||
|
||||
export class Broker {
|
||||
constructor(private msg: MessageSend) {}
|
||||
|
||||
// 订阅
|
||||
async subscribe(topic: string, handler: SubscribeCallback): Promise<MessageConnect> {
|
||||
LoggerCore.getInstance().logger({ service: "messageQueue" }).debug("subscribe", { topic });
|
||||
const con = await this.msg.connect({ action: "messageQueue", data: { action: "subscribe", topic } });
|
||||
con.onMessage((msg: { action: string; topic: string; message: any }) => {
|
||||
if (msg.action === "message") {
|
||||
handler(msg.message);
|
||||
}
|
||||
});
|
||||
return con;
|
||||
}
|
||||
|
||||
// 发布
|
||||
publish(topic: string, message: any) {
|
||||
sendMessage(this.msg, "messageQueue", { action: "publish", topic, message });
|
||||
}
|
||||
}
|
||||
// 释放订阅
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
// 消息队列
|
||||
export class MessageQueue {
|
||||
topicConMap: Map<string, { name: string; con: MessageConnect }[]> = new Map();
|
||||
|
||||
private EE: EventEmitter = new EventEmitter();
|
||||
|
||||
logger: Logger;
|
||||
constructor(api: Server) {
|
||||
api.on("messageQueue", this.handler());
|
||||
this.logger = LoggerCore.getInstance().logger({ service: "messageQueue" });
|
||||
constructor() {
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (msg.action === "messageQueue") {
|
||||
this.handler(msg.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handler(): ApiFunction {
|
||||
return ({ action, topic, message }: { action: string; topic: string; message: any }, con) => {
|
||||
this.logger.trace("messageQueueHandler", { action, topic, message });
|
||||
if (!con) {
|
||||
throw new Error("con is required");
|
||||
}
|
||||
if (!topic) {
|
||||
throw new Error("topic is required");
|
||||
}
|
||||
switch (action) {
|
||||
case "subscribe":
|
||||
this.subscribe(topic, con as MessageConnect);
|
||||
break;
|
||||
case "publish":
|
||||
this.publish(topic, message);
|
||||
break;
|
||||
default:
|
||||
throw new Error("action not found");
|
||||
}
|
||||
handler({ action, topic, message }: { action: string; topic: string; message: any }) {
|
||||
LoggerCore.getInstance()
|
||||
.logger({ service: "messageQueue" })
|
||||
.trace("messageQueueHandler", { action, topic, message });
|
||||
if (!topic) {
|
||||
throw new Error("topic is required");
|
||||
}
|
||||
switch (action) {
|
||||
case "message":
|
||||
this.EE.emit(topic, message);
|
||||
break;
|
||||
default:
|
||||
throw new Error("action not found");
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(topic: string, handler: SubscribeCallback): Unsubscribe {
|
||||
this.EE.on(topic, handler);
|
||||
return () => {
|
||||
this.EE.off(topic, handler);
|
||||
};
|
||||
}
|
||||
|
||||
private subscribe(topic: string, con: MessageConnect) {
|
||||
let list = this.topicConMap.get(topic);
|
||||
if (!list) {
|
||||
list = [];
|
||||
this.topicConMap.set(topic, list);
|
||||
}
|
||||
list.push({ name: topic, con });
|
||||
con.onDisconnect(() => {
|
||||
let list = this.topicConMap.get(topic);
|
||||
// 移除断开连接的con
|
||||
list = list!.filter((item) => item.con !== con);
|
||||
this.topicConMap.set(topic, list);
|
||||
this.logger.debug("disconnect", { topic });
|
||||
});
|
||||
}
|
||||
|
||||
publish(topic: string, message: any) {
|
||||
const list = this.topicConMap.get(topic);
|
||||
list?.forEach((item) => {
|
||||
item.con.sendMessage({ action: "message", topic, message });
|
||||
chrome.runtime.sendMessage({
|
||||
action: "messageQueue",
|
||||
data: { action: "message", topic, message },
|
||||
});
|
||||
this.EE.emit(topic, message);
|
||||
this.logger.trace("publish", { topic, message, list: list?.length });
|
||||
LoggerCore.getInstance().logger({ service: "messageQueue" }).trace("publish", { topic, message });
|
||||
}
|
||||
|
||||
// 只发布给当前环境
|
||||
emit(topic: string, message: any) {
|
||||
this.EE.emit(topic, message);
|
||||
}
|
||||
|
||||
// 同环境下使用addListener
|
||||
addListener(topic: string, handler: (message: any) => void) {
|
||||
this.EE.on(topic, handler);
|
||||
}
|
||||
}
|
||||
|
62
packages/message/mock_message.ts
Normal file
62
packages/message/mock_message.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import EventEmitter from "eventemitter3";
|
||||
import { Message, MessageConnect, MessageSend } from "./server";
|
||||
import { sleep } from "@App/pkg/utils/utils";
|
||||
|
||||
export class MockMessageConnect implements MessageConnect {
|
||||
constructor(protected EE: EventEmitter) {}
|
||||
|
||||
onMessage(callback: (data: any) => void): void {
|
||||
this.EE.on("message", (data: any) => {
|
||||
callback(data);
|
||||
});
|
||||
}
|
||||
|
||||
sendMessage(data: any): void {
|
||||
this.EE.emit("message", data);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.EE.emit("disconnect");
|
||||
}
|
||||
|
||||
onDisconnect(callback: () => void): void {
|
||||
this.EE.on("disconnect", callback);
|
||||
}
|
||||
}
|
||||
|
||||
export class MockMessageSend implements MessageSend {
|
||||
constructor(protected EE: EventEmitter) {}
|
||||
|
||||
connect(data: any): Promise<MessageConnect> {
|
||||
return new Promise((resolve) => {
|
||||
const EE = new EventEmitter();
|
||||
const con = new MockMessageConnect(EE);
|
||||
resolve(con);
|
||||
sleep(1).then(() => {
|
||||
this.EE.emit("connect", data, con);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
sendMessage(data: any): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
this.EE.emit("message", data, (resp: any) => {
|
||||
resolve(resp);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class MockMessage extends MockMessageSend implements Message {
|
||||
onConnect(callback: (data: any, con: MessageConnect) => void): void {
|
||||
this.EE.on("connect", (data: any, con: MessageConnect) => {
|
||||
callback(data, con);
|
||||
});
|
||||
}
|
||||
|
||||
onMessage(callback: (data: any, sendResponse: (data: any) => void) => void): void {
|
||||
this.EE.on("message", (data: any, sendResponse: (data: any) => void) => {
|
||||
callback(data, sendResponse);
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,8 +1,9 @@
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import { connect, sendMessage } from "./client";
|
||||
|
||||
export interface Message extends MessageSend {
|
||||
onConnect(callback: (data: any, con: MessageConnect) => void): void;
|
||||
onMessage(callback: (data: any, sendResponse: (data: any) => void) => void): void;
|
||||
onMessage(callback: (data: any, sendResponse: (data: any) => void, sender?: MessageSender) => void): void;
|
||||
}
|
||||
|
||||
export interface MessageSend {
|
||||
@@ -17,26 +18,58 @@ export interface MessageConnect {
|
||||
onDisconnect(callback: () => void): void;
|
||||
}
|
||||
|
||||
export type MessageSender = {
|
||||
export type MessageSender = chrome.runtime.MessageSender;
|
||||
|
||||
export type ExtMessageSender = {
|
||||
tabId: number;
|
||||
frameId?: number;
|
||||
documentId?: string;
|
||||
};
|
||||
|
||||
export type ApiFunction = (params: any, con: MessageConnect | null) => Promise<any> | void;
|
||||
export class GetSender {
|
||||
constructor(private sender: MessageConnect | MessageSender) {}
|
||||
|
||||
getSender(): MessageSender {
|
||||
return this.sender as MessageSender;
|
||||
}
|
||||
|
||||
getExtMessageSender(): ExtMessageSender {
|
||||
const sender = this.sender as MessageSender;
|
||||
return {
|
||||
tabId: sender.tab?.id || -1, // -1表示后台脚本
|
||||
frameId: sender.frameId,
|
||||
documentId: sender.documentId,
|
||||
};
|
||||
}
|
||||
|
||||
getConnect(): MessageConnect {
|
||||
return this.sender as MessageConnect;
|
||||
}
|
||||
}
|
||||
|
||||
export type ApiFunction = (params: any, con: GetSender) => Promise<any> | void;
|
||||
export type ApiFunctionSync = (params: any, con: GetSender) => any;
|
||||
|
||||
export class Server {
|
||||
private apiFunctionMap: Map<string, ApiFunction> = new Map();
|
||||
|
||||
private logger = LoggerCore.getInstance().logger({ service: "messageServer" });
|
||||
|
||||
constructor(message: Message) {
|
||||
constructor(prefix: string, message: Message) {
|
||||
message.onConnect((msg: any, con: MessageConnect) => {
|
||||
this.logger.trace("server onConnect", { msg });
|
||||
this.connectHandle(msg.action, msg.data, con);
|
||||
if (msg.action.startsWith(prefix)) {
|
||||
return this.connectHandle(msg.action.slice(prefix.length + 1), msg.data, con);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
message.onMessage((msg, sendResponse) => {
|
||||
this.logger.trace("server onMessage", { msg });
|
||||
return this.messageHandle(msg.action, msg.data, sendResponse);
|
||||
message.onMessage((msg: { action: string; data: any }, sendResponse, sender) => {
|
||||
this.logger.trace("server onMessage", { msg: msg as any });
|
||||
if (msg.action.startsWith(prefix)) {
|
||||
return this.messageHandle(msg.action.slice(prefix.length + 1), msg.data, sendResponse, sender);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,15 +84,15 @@ export class Server {
|
||||
private connectHandle(msg: string, params: any, con: MessageConnect) {
|
||||
const func = this.apiFunctionMap.get(msg);
|
||||
if (func) {
|
||||
func(params, con);
|
||||
func(params, new GetSender(con));
|
||||
}
|
||||
}
|
||||
|
||||
private messageHandle(msg: string, params: any, sendResponse: (response: any) => void) {
|
||||
const func = this.apiFunctionMap.get(msg);
|
||||
private messageHandle(action: string, params: any, sendResponse: (response: any) => void, sender?: MessageSender) {
|
||||
const func = this.apiFunctionMap.get(action);
|
||||
if (func) {
|
||||
try {
|
||||
const ret = func(params, null);
|
||||
const ret = func(params, new GetSender(sender!));
|
||||
if (ret instanceof Promise) {
|
||||
ret.then((data) => {
|
||||
sendResponse({ code: 0, data });
|
||||
@@ -72,8 +105,8 @@ export class Server {
|
||||
sendResponse({ code: -1, message: e.message });
|
||||
}
|
||||
} else {
|
||||
sendResponse({ code: -1, message: "no such api" });
|
||||
this.logger.error("no such api", { msg });
|
||||
sendResponse({ code: -1, message: "no such api " + action });
|
||||
this.logger.error("no such api", { action: action });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,25 +131,49 @@ export class Group {
|
||||
}
|
||||
|
||||
// 转发消息
|
||||
export function forwardMessage(path: string, from: Server, to: MessageSend) {
|
||||
from.on(path, (params, fromCon) => {
|
||||
if (fromCon) {
|
||||
to.connect({ action: path, data: params }).then((toCon) => {
|
||||
fromCon.onMessage((data) => {
|
||||
export function forwardMessage(
|
||||
prefix: string,
|
||||
path: string,
|
||||
from: Server,
|
||||
to: MessageSend,
|
||||
middleware?: ApiFunctionSync
|
||||
) {
|
||||
const handler = (params: any, fromCon: GetSender) => {
|
||||
const fromConnect = fromCon.getConnect();
|
||||
if (fromConnect) {
|
||||
connect(to, prefix + "/" + path, params).then((toCon) => {
|
||||
fromConnect.onMessage((data) => {
|
||||
toCon.sendMessage(data);
|
||||
});
|
||||
toCon.onMessage((data) => {
|
||||
fromCon.sendMessage(data);
|
||||
fromConnect.sendMessage(data);
|
||||
});
|
||||
fromCon.onDisconnect(() => {
|
||||
fromConnect.onDisconnect(() => {
|
||||
toCon.disconnect();
|
||||
});
|
||||
toCon.onDisconnect(() => {
|
||||
fromCon.disconnect();
|
||||
fromConnect.disconnect();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return to.sendMessage({ action: path, data: params });
|
||||
return sendMessage(to, prefix + "/" + path, params);
|
||||
}
|
||||
};
|
||||
from.on(path, (params, sender) => {
|
||||
if (middleware) {
|
||||
// 此处是为了处理CustomEventMessage的同步消息情况
|
||||
const resp = middleware(params, sender) as any;
|
||||
if (resp instanceof Promise) {
|
||||
return resp.then((data) => {
|
||||
if (data !== false) {
|
||||
return data;
|
||||
}
|
||||
return handler(params, sender);
|
||||
});
|
||||
} else if (resp !== false) {
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
return handler(params, sender);
|
||||
});
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ import { Message, MessageConnect, MessageSend } from "./server";
|
||||
|
||||
import EventEmitter from "eventemitter3";
|
||||
|
||||
interface PostMessage {
|
||||
export interface PostMessage {
|
||||
postMessage(message: any): void;
|
||||
}
|
||||
|
||||
@@ -168,12 +168,13 @@ export class ServiceWorkerMessageSend implements MessageSend {
|
||||
constructor() {}
|
||||
|
||||
async init() {
|
||||
const list = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
|
||||
this.target = list[0];
|
||||
self.addEventListener("message", (e) => {
|
||||
console.log("serviceWorker", e);
|
||||
this.messageHandle(e.data);
|
||||
});
|
||||
if (!this.target) {
|
||||
const list = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
|
||||
this.target = list[0];
|
||||
self.addEventListener("message", (e) => {
|
||||
this.messageHandle(e.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
messageHandle(data: WindowMessageBody) {
|
||||
@@ -188,20 +189,20 @@ export class ServiceWorkerMessageSend implements MessageSend {
|
||||
}
|
||||
}
|
||||
|
||||
connect(data: any): Promise<MessageConnect> {
|
||||
return new Promise((resolve) => {
|
||||
const body: WindowMessageBody = {
|
||||
messageId: uuidv4(),
|
||||
type: "connect",
|
||||
data,
|
||||
};
|
||||
this.target!.postMessage(body);
|
||||
resolve(new WindowMessageConnect(body.messageId, this.EE, this.target!));
|
||||
});
|
||||
async connect(data: any): Promise<MessageConnect> {
|
||||
await this.init();
|
||||
const body: WindowMessageBody = {
|
||||
messageId: uuidv4(),
|
||||
type: "connect",
|
||||
data,
|
||||
};
|
||||
this.target!.postMessage(body);
|
||||
return new WindowMessageConnect(body.messageId, this.EE, this.target!);
|
||||
}
|
||||
|
||||
// 发送消息 注意不进行回调的内存泄漏
|
||||
sendMessage(data: any): Promise<any> {
|
||||
async sendMessage(data: any): Promise<any> {
|
||||
await this.init();
|
||||
return new Promise((resolve) => {
|
||||
const body: WindowMessageBody = {
|
||||
messageId: uuidv4(),
|
||||
|
2287
pnpm-lock.yaml
generated
2287
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ import { defineConfig } from "@rspack/cli";
|
||||
import { rspack } from "@rspack/core";
|
||||
import { version } from "./package.json";
|
||||
|
||||
const NodePolyfillPlugin = require("node-polyfill-webpack-plugin");
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const isBeta = version.includes("-");
|
||||
|
||||
@@ -20,15 +22,25 @@ export default defineConfig({
|
||||
mode: "development",
|
||||
devtool: "inline-source-map",
|
||||
}
|
||||
: {}),
|
||||
: {
|
||||
mode: "production",
|
||||
devtool: false,
|
||||
}),
|
||||
context: __dirname,
|
||||
entry: {
|
||||
service_worker: `${src}/service_worker.ts`,
|
||||
offscreen: `${src}/offscreen.ts`,
|
||||
sandbox: `${src}/sandbox.ts`,
|
||||
content: `${src}/content.ts`,
|
||||
inject: `${src}/inject.ts`,
|
||||
popup: `${src}/pages/popup/main.tsx`,
|
||||
install: `${src}/pages/install/main.tsx`,
|
||||
confirm: `${src}/pages/confirm/main.tsx`,
|
||||
import: `${src}/pages/import/main.tsx`,
|
||||
options: `${src}/pages/options/main.tsx`,
|
||||
"editor.worker": "monaco-editor/esm/vs/editor/editor.worker.js",
|
||||
"ts.worker": "monaco-editor/esm/vs/language/typescript/ts.worker.js",
|
||||
"linter.worker": `${src}/linter.worker.ts`,
|
||||
},
|
||||
output: {
|
||||
path: `${dist}/ext/src`,
|
||||
@@ -40,6 +52,9 @@ export default defineConfig({
|
||||
alias: {
|
||||
"@App": path.resolve(__dirname, "src/"),
|
||||
"@Packages": path.resolve(__dirname, "packages/"),
|
||||
// 改写eslint-plugin-userscripts以适配脚本猫,打包时重定义模块路径
|
||||
"../data/compat-grant": path.resolve(__dirname, "./packages/eslint/compat-grant"),
|
||||
"../data/compat-headers": path.resolve(__dirname, "./packages/eslint/compat-headers"),
|
||||
},
|
||||
fallback: {
|
||||
child_process: false,
|
||||
@@ -92,12 +107,12 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "asset",
|
||||
type: "asset/source",
|
||||
test: /\.d\.ts$/,
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
{
|
||||
type: "asset",
|
||||
type: "asset/source",
|
||||
test: /\.tpl$/,
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
@@ -112,15 +127,15 @@ export default defineConfig({
|
||||
// 将manifest.json内版本号替换为package.json中版本号
|
||||
transform(content: Buffer) {
|
||||
const manifest = JSON.parse(content.toString());
|
||||
if (isDev) {
|
||||
manifest.name = "ScriptCat - Dev";
|
||||
if (isDev || isBeta) {
|
||||
manifest.name = "__MSG_scriptcat_beta__";
|
||||
// manifest.content_security_policy = "script-src 'self' https://cdn.crowdin.com; object-src 'self'";
|
||||
}
|
||||
return JSON.stringify(manifest);
|
||||
},
|
||||
},
|
||||
{
|
||||
from: `${assets}/logo${isDev ? "-beta" : ""}.png`,
|
||||
from: `${assets}/logo${isDev || isBeta ? "-beta" : ""}.png`,
|
||||
to: `${dist}/ext/assets/logo.png`,
|
||||
},
|
||||
{ from: `${assets}/logo`, to: `${dist}/ext/assets/logo` },
|
||||
@@ -138,6 +153,22 @@ export default defineConfig({
|
||||
minify: true,
|
||||
chunks: ["install"],
|
||||
}),
|
||||
new rspack.HtmlRspackPlugin({
|
||||
filename: `${dist}/ext/src/confirm.html`,
|
||||
template: `${src}/pages/template.html`,
|
||||
inject: "head",
|
||||
title: "Confirm - ScriptCat",
|
||||
minify: true,
|
||||
chunks: ["confirm"],
|
||||
}),
|
||||
new rspack.HtmlRspackPlugin({
|
||||
filename: `${dist}/ext/src/import.html`,
|
||||
template: `${src}/pages/template.html`,
|
||||
inject: "head",
|
||||
title: "Import - ScriptCat",
|
||||
minify: true,
|
||||
chunks: ["import"],
|
||||
}),
|
||||
new rspack.HtmlRspackPlugin({
|
||||
filename: `${dist}/ext/src/options.html`,
|
||||
template: `${src}/pages/options.html`,
|
||||
@@ -148,7 +179,7 @@ export default defineConfig({
|
||||
}),
|
||||
new rspack.HtmlRspackPlugin({
|
||||
filename: `${dist}/ext/src/popup.html`,
|
||||
template: `${src}/pages/popup/index.html`,
|
||||
template: `${src}/pages/popup.html`,
|
||||
inject: "head",
|
||||
title: "Home - ScriptCat",
|
||||
minify: true,
|
||||
@@ -168,15 +199,25 @@ export default defineConfig({
|
||||
minify: true,
|
||||
chunks: ["sandbox"],
|
||||
}),
|
||||
new NodePolyfillPlugin(),
|
||||
].filter(Boolean),
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new rspack.SwcJsMinimizerRspackPlugin(),
|
||||
new rspack.SwcJsMinimizerRspackPlugin({}),
|
||||
new rspack.LightningCssMinimizerRspackPlugin({
|
||||
minimizerOptions: { targets },
|
||||
}),
|
||||
],
|
||||
realContentHash: true,
|
||||
splitChunks: {
|
||||
chunks: (chunk) => {
|
||||
// 排除这些文件,不进行分离
|
||||
return !["editor.worker", "ts.worker", "linter.worker", "service_worker", "content", "inject"].includes(
|
||||
chunk.name || ""
|
||||
);
|
||||
},
|
||||
minSize: 307200,
|
||||
maxSize: 4194304,
|
||||
},
|
||||
},
|
||||
experiments: {
|
||||
css: true,
|
||||
|
124
scripts/pack.js
Normal file
124
scripts/pack.js
Normal file
@@ -0,0 +1,124 @@
|
||||
const fs = require("fs");
|
||||
const JSZip = require("jszip");
|
||||
const ChromeExtension = require("crx");
|
||||
const { execSync } = require("child_process");
|
||||
const semver = require("semver");
|
||||
const manifest = require("../src/manifest.json");
|
||||
const package = require("../package.json");
|
||||
|
||||
// 判断是否为beta版本
|
||||
const version = semver.parse(package.version);
|
||||
if (version.prerelease.length) {
|
||||
// 替换manifest中的版本
|
||||
let betaVersion = 1000;
|
||||
switch (version.prerelease[0]) {
|
||||
case "alpha":
|
||||
// 第一位进1
|
||||
betaVersion += parseInt(version.prerelease[1] || "0", 10) + 1 || 1;
|
||||
break;
|
||||
case "beta":
|
||||
// 第二位进1
|
||||
betaVersion += 10 * (parseInt(version.prerelease[1] || "0", 10) + 1 || 1);
|
||||
break;
|
||||
default:
|
||||
throw new Error("未知的版本类型");
|
||||
}
|
||||
manifest.version = `${version.major.toString()}.${version.minor.toString()}.${version.patch.toString()}.${betaVersion.toString()}`;
|
||||
manifest.name = `__MSG_scriptcat_beta__`;
|
||||
} else {
|
||||
manifest.name = `__MSG_scriptcat__`;
|
||||
manifest.version = package.version;
|
||||
}
|
||||
|
||||
// 处理manifest version
|
||||
let str = fs.readFileSync("./src/manifest.json").toString();
|
||||
str = str.replace(/"version": "(.*?)"/, `"version": "${manifest.version}"`);
|
||||
fs.writeFileSync("./src/manifest.json", str);
|
||||
|
||||
// 处理configSystem version
|
||||
let configSystem = fs.readFileSync("./src/app/const.ts").toString();
|
||||
// 如果是由github action的分支触发的构建,在版本中再加上commit id
|
||||
if (process.env.GITHUB_REF_TYPE === "branch") {
|
||||
configSystem = configSystem.replace(
|
||||
"ExtVersion = version;",
|
||||
`ExtVersion = \`\${version}+${process.env.GITHUB_SHA.substring(0, 7)}\`;`
|
||||
);
|
||||
fs.writeFileSync("./src/app/const.ts", configSystem);
|
||||
}
|
||||
|
||||
execSync("npm run build", { stdio: "inherit" });
|
||||
|
||||
// 处理firefox和chrome的zip压缩包
|
||||
|
||||
const firefoxManifest = { ...manifest };
|
||||
const chromeManifest = { ...manifest };
|
||||
|
||||
delete chromeManifest.content_security_policy;
|
||||
|
||||
delete firefoxManifest.sandbox;
|
||||
// firefoxManifest.content_security_policy =
|
||||
// "script-src 'self' blob:; object-src 'self' blob:";
|
||||
firefoxManifest.browser_specific_settings = {
|
||||
gecko: { strict_min_version: "91.1.0" },
|
||||
};
|
||||
|
||||
const chrome = new JSZip();
|
||||
const firefox = new JSZip();
|
||||
|
||||
function addDir(zip, localDir, toDir, filters) {
|
||||
const files = fs.readdirSync(localDir);
|
||||
files.forEach((file) => {
|
||||
const localPath = `${localDir}/${file}`;
|
||||
const toPath = `${toDir}${file}`;
|
||||
const stats = fs.statSync(localPath);
|
||||
if (stats.isDirectory()) {
|
||||
addDir(zip, localPath, `${toPath}/`, filters);
|
||||
} else {
|
||||
if (filters && filters.includes(file)) {
|
||||
return;
|
||||
}
|
||||
zip.file(toPath, fs.readFileSync(localPath));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
chrome.file("manifest.json", JSON.stringify(chromeManifest));
|
||||
firefox.file("manifest.json", JSON.stringify(firefoxManifest));
|
||||
|
||||
addDir(chrome, "./dist/ext", "", ["manifest.json"]);
|
||||
addDir(firefox, "./dist/ext", "", ["manifest.json", "ts.worker.js"]);
|
||||
// 添加ts.worker.js名字为gz
|
||||
firefox.file("src/ts.worker.js.gz", fs.readFileSync("./dist/ext/src/ts.worker.js"));
|
||||
|
||||
// 导出zip包
|
||||
chrome
|
||||
.generateNodeStream({
|
||||
type: "nodebuffer",
|
||||
streamFiles: true,
|
||||
compression: "DEFLATE",
|
||||
})
|
||||
.pipe(fs.createWriteStream(`./dist/${package.name}-v${package.version}-chrome.zip`));
|
||||
|
||||
firefox
|
||||
.generateNodeStream({
|
||||
type: "nodebuffer",
|
||||
streamFiles: true,
|
||||
compression: "DEFLATE",
|
||||
})
|
||||
.pipe(fs.createWriteStream(`./dist/${package.name}-v${package.version}-firefox.zip`));
|
||||
|
||||
// 处理crx
|
||||
const crx = new ChromeExtension({
|
||||
privateKey: fs.readFileSync("./dist/scriptcat.pem"),
|
||||
});
|
||||
|
||||
crx
|
||||
.load("./dist/ext")
|
||||
.then((crxFile) => crxFile.pack())
|
||||
.then((crxBuffer) => {
|
||||
fs.writeFileSync(`./dist/${package.name}-v${package.version}-chrome.crx`, crxBuffer);
|
||||
})
|
||||
.catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
});
|
@@ -9,7 +9,7 @@ export interface CacheStorage {
|
||||
export class ExtCache implements CacheStorage {
|
||||
get(key: string): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(key, (value) => {
|
||||
chrome.storage.session.get(key, (value) => {
|
||||
resolve(value[key]);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ export class ExtCache implements CacheStorage {
|
||||
|
||||
set(key: string, value: any): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.set(
|
||||
chrome.storage.session.set(
|
||||
{
|
||||
[key]: value,
|
||||
},
|
||||
@@ -30,7 +30,7 @@ export class ExtCache implements CacheStorage {
|
||||
|
||||
has(key: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(key, (value) => {
|
||||
chrome.storage.session.get(key, (value) => {
|
||||
resolve(value[key] !== undefined);
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,7 @@ export class ExtCache implements CacheStorage {
|
||||
|
||||
del(key: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.remove(key, () => {
|
||||
chrome.storage.session.remove(key, () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ export class ExtCache implements CacheStorage {
|
||||
|
||||
list(): Promise<string[]> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(null, (value) => {
|
||||
chrome.storage.session.get(null, (value) => {
|
||||
resolve(Object.keys(value));
|
||||
});
|
||||
});
|
||||
@@ -90,11 +90,11 @@ export class MapCache {
|
||||
}
|
||||
|
||||
export async function incr(cache: Cache, key: string, increase: number): Promise<number> {
|
||||
const value = await cache.get(key);
|
||||
let num = value || 0;
|
||||
num += increase;
|
||||
await cache.set(key, num);
|
||||
return num;
|
||||
return cache.tx<number>(key, async (value) => {
|
||||
let num = value || 0;
|
||||
num += increase;
|
||||
return num;
|
||||
});
|
||||
}
|
||||
|
||||
export default class Cache {
|
||||
@@ -116,7 +116,7 @@ export default class Cache {
|
||||
ret = await set();
|
||||
this.set(key, ret);
|
||||
}
|
||||
return Promise.resolve(ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
public set(key: string, value: any): Promise<void> {
|
||||
@@ -134,4 +134,50 @@ export default class Cache {
|
||||
public list(): Promise<string[]> {
|
||||
return this.storage.list();
|
||||
}
|
||||
|
||||
private txLock: Map<string, ((unlock: () => void) => void)[]> = new Map();
|
||||
|
||||
lock(key: string): Promise<() => void> | (() => void) {
|
||||
let hasLock = this.txLock.has(key);
|
||||
|
||||
const unlock = () => {
|
||||
let waitFunc = this.txLock.get(key)?.shift();
|
||||
if (waitFunc) {
|
||||
waitFunc(unlock);
|
||||
} else {
|
||||
this.txLock.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
if (hasLock) {
|
||||
let lock = this.txLock.get(key);
|
||||
if (!lock) {
|
||||
lock = [];
|
||||
this.txLock.set(key, lock);
|
||||
}
|
||||
return new Promise<() => void>((resolve) => {
|
||||
lock.push(resolve);
|
||||
});
|
||||
}
|
||||
this.txLock.set(key, []);
|
||||
return unlock;
|
||||
}
|
||||
|
||||
// 事务处理,如果有事务正在进行,则等待
|
||||
public async tx<T>(key: string, set: (result: T) => Promise<T>): Promise<T> {
|
||||
const unlock = await this.lock(key);
|
||||
let newValue: T;
|
||||
await this.get(key)
|
||||
.then((result) => set(result))
|
||||
.then((value) => {
|
||||
if (value) {
|
||||
newValue = value;
|
||||
return this.set(key, value);
|
||||
} else if (value === undefined) {
|
||||
return this.del(key);
|
||||
}
|
||||
});
|
||||
unlock();
|
||||
return newValue!;
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { ConfirmParam } from "@App/runtime/service_worker/permission_verify";
|
||||
import { ConfirmParam } from "./service/service_worker/permission_verify";
|
||||
|
||||
export default class CacheKey {
|
||||
// 加载脚本信息时的缓存
|
||||
@@ -9,4 +9,9 @@ export default class CacheKey {
|
||||
static permissionConfirm(scriptUuid: string, confirm: ConfirmParam): string {
|
||||
return `permission:${scriptUuid}:${confirm.permissionValue || ""}:${confirm.permission || ""}`;
|
||||
}
|
||||
|
||||
// importFile 导入文件
|
||||
static importFile(uuid: string): string {
|
||||
return `importFile:${uuid}`;
|
||||
}
|
||||
}
|
||||
|
@@ -3,12 +3,8 @@ import { version } from "../../package.json";
|
||||
export const ExtVersion = version;
|
||||
|
||||
export const ExtServer = "https://ext.scriptcat.org/";
|
||||
export const ExtServerApi = ExtServer + "api/v1/";
|
||||
|
||||
export const ExternalWhitelist = [
|
||||
"greasyfork.org",
|
||||
"scriptcat.org",
|
||||
"tampermonkey.net.cn",
|
||||
"openuserjs.org",
|
||||
];
|
||||
export const ExternalWhitelist = ["greasyfork.org", "scriptcat.org", "tampermonkey.net.cn", "openuserjs.org"];
|
||||
|
||||
export const ExternalMessage = "externalMessage";
|
||||
|
@@ -3,7 +3,7 @@ import Logger from "./logger";
|
||||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface LogLabel {
|
||||
[key: string]: string | string[] | boolean | number | undefined;
|
||||
[key: string]: string | string[] | boolean | number | object | undefined;
|
||||
component?: string;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ export interface Writer {
|
||||
write(level: LogLevel, message: string, label: LogLabel): void;
|
||||
}
|
||||
|
||||
export class EmptyWriter implements Writer {
|
||||
write(): void {}
|
||||
}
|
||||
|
||||
export default class LoggerCore {
|
||||
static instance: LoggerCore;
|
||||
|
||||
@@ -37,9 +41,9 @@ export default class LoggerCore {
|
||||
this.writer = config.writer;
|
||||
this.level = config.level || this.level;
|
||||
this.labels = config.labels || {};
|
||||
// 获取日志debug等级, 如果是开发环境, 则默认为trace
|
||||
// 获取日志debug等级, 如果是开发环境, 则默认为debug
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
this.debug = "trace";
|
||||
this.debug = "debug";
|
||||
}
|
||||
if (!LoggerCore.instance) {
|
||||
LoggerCore.instance = this;
|
||||
|
@@ -9,13 +9,17 @@ export default class DBWriter implements Writer {
|
||||
this.dao = dao;
|
||||
}
|
||||
|
||||
write(level: LogLevel, message: string, label: LogLabel): void {
|
||||
this.dao.save({
|
||||
id: 0,
|
||||
level,
|
||||
message,
|
||||
label,
|
||||
createtime: new Date().getTime(),
|
||||
});
|
||||
async write(level: LogLevel, message: string, label: LogLabel): Promise<void> {
|
||||
try {
|
||||
await this.dao.save({
|
||||
id: 0,
|
||||
level,
|
||||
message,
|
||||
label,
|
||||
createtime: new Date().getTime(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("DBWriter error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -32,28 +32,27 @@ export default class Logger {
|
||||
}
|
||||
|
||||
log(level: LogLevel, message: string, ...label: LogLabel[]) {
|
||||
const newLabel = buildLabel(this.label, label);
|
||||
if (levelNumber[level] >= levelNumber[this.core.level]) {
|
||||
this.core.writer.write(level, message, buildLabel(this.label, label));
|
||||
this.core.writer.write(level, message, newLabel);
|
||||
}
|
||||
if (this.core.debug !== "none" && levelNumber[level] >= levelNumber[this.core.debug]) {
|
||||
if (typeof message === "object") {
|
||||
message = JSON.stringify(message);
|
||||
}
|
||||
const msg = `${dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss")} [${level}] msg=${message} label=${JSON.stringify(
|
||||
buildLabel(this.label, label)
|
||||
)}`;
|
||||
const msg = `${dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss")} [${level}] ${message}`;
|
||||
switch (level) {
|
||||
case "error":
|
||||
console.error(msg);
|
||||
console.error(msg, newLabel);
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(msg);
|
||||
console.warn(msg, newLabel);
|
||||
break;
|
||||
case "trace":
|
||||
console.trace(msg);
|
||||
console.info(msg, newLabel);
|
||||
break;
|
||||
default:
|
||||
console.info(msg);
|
||||
console.info(msg, newLabel);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@@ -1,17 +1,20 @@
|
||||
import { WindowMessage } from "@Packages/message/window_message";
|
||||
import { LogLabel, LogLevel, Writer } from "./core";
|
||||
import { MessageSend } from "@Packages/message/server";
|
||||
|
||||
// 通过通讯机制写入日志
|
||||
export default class MessageWriter implements Writer {
|
||||
connect: WindowMessage;
|
||||
send: MessageSend;
|
||||
|
||||
constructor(connect: WindowMessage) {
|
||||
this.connect = connect;
|
||||
constructor(
|
||||
connect: MessageSend,
|
||||
private action: string = "logger"
|
||||
) {
|
||||
this.send = connect;
|
||||
}
|
||||
|
||||
write(level: LogLevel, message: string, label: LogLabel): void {
|
||||
this.connect.sendMessage({
|
||||
action: "logger",
|
||||
this.send.sendMessage({
|
||||
action: this.action,
|
||||
data: {
|
||||
id: 0,
|
||||
level,
|
||||
|
@@ -1,8 +1,12 @@
|
||||
import { getStorageName } from "@App/pkg/utils/utils";
|
||||
import { db } from "./repo/dao";
|
||||
import { Script } from "./repo/scripts";
|
||||
import { Script, ScriptAndCode, ScriptCodeDAO, ScriptDAO } from "./repo/scripts";
|
||||
import { Subscribe, SubscribeDAO } from "./repo/subscribe";
|
||||
import { Value, ValueDAO } from "./repo/value";
|
||||
import { Permission, PermissionDAO } from "./repo/permission";
|
||||
|
||||
// 0.10.0重构,重命名字段,统一使用小峰驼
|
||||
function renameField(): void {
|
||||
function renameField() {
|
||||
db.version(16)
|
||||
.stores({
|
||||
scripts:
|
||||
@@ -33,9 +37,196 @@ function renameField(): void {
|
||||
export: "++id,&scriptId",
|
||||
});
|
||||
// 将脚本数据迁移到chrome.storage
|
||||
// db.version(18)
|
||||
// .stores({})
|
||||
// .upgrade((tx) => {});
|
||||
db.version(18).upgrade(() => {
|
||||
// 默认使用的事务,这里加个延时,用db.open()打开数据库后,再执行
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// 迁移脚本
|
||||
const scripts = await db.table("scripts").toArray();
|
||||
const scriptDAO = new ScriptDAO();
|
||||
const scriptCodeDAO = new ScriptCodeDAO();
|
||||
console.log("开始迁移脚本数据", scripts.length);
|
||||
await Promise.all(
|
||||
scripts.map(async (script: ScriptAndCode) => {
|
||||
const {
|
||||
uuid,
|
||||
name,
|
||||
namespace,
|
||||
author,
|
||||
originDomain,
|
||||
subscribeUrl,
|
||||
type,
|
||||
sort,
|
||||
status,
|
||||
runStatus,
|
||||
metadata,
|
||||
createtime,
|
||||
checktime,
|
||||
code,
|
||||
checkUpdateUrl,
|
||||
downloadUrl,
|
||||
selfMetadata,
|
||||
config,
|
||||
error,
|
||||
updatetime,
|
||||
lastruntime,
|
||||
nextruntime,
|
||||
} = script;
|
||||
const s = await scriptDAO.save({
|
||||
uuid,
|
||||
name,
|
||||
namespace,
|
||||
author,
|
||||
originDomain,
|
||||
origin,
|
||||
checkUpdateUrl,
|
||||
downloadUrl,
|
||||
metadata,
|
||||
selfMetadata,
|
||||
subscribeUrl,
|
||||
config,
|
||||
type,
|
||||
status,
|
||||
sort,
|
||||
runStatus,
|
||||
error,
|
||||
createtime,
|
||||
updatetime,
|
||||
checktime,
|
||||
lastruntime,
|
||||
nextruntime,
|
||||
});
|
||||
return scriptCodeDAO
|
||||
.save({
|
||||
uuid: s.uuid,
|
||||
code,
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log("脚本代码迁移失败", e);
|
||||
return Promise.reject(e);
|
||||
});
|
||||
})
|
||||
);
|
||||
// 迁移订阅
|
||||
const subscribe = await db.table("subscribe").toArray();
|
||||
const subscribeDAO = new SubscribeDAO();
|
||||
if (subscribe.length) {
|
||||
await Promise.all(
|
||||
subscribe.map((s: Subscribe) => {
|
||||
const { url, name, code, author, scripts, metadata, status, createtime, updatetime, checktime } = s;
|
||||
return subscribeDAO.save({
|
||||
url,
|
||||
name,
|
||||
code,
|
||||
author,
|
||||
scripts,
|
||||
metadata,
|
||||
status,
|
||||
createtime,
|
||||
updatetime,
|
||||
checktime,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
console.log("订阅数据迁移完成", subscribe.length);
|
||||
// 迁移value
|
||||
interface MV2Value {
|
||||
id: number;
|
||||
scriptId: number;
|
||||
storageName?: string;
|
||||
key: string;
|
||||
value: any;
|
||||
createtime: number;
|
||||
updatetime: number;
|
||||
}
|
||||
const values = await db.table("value").toArray();
|
||||
const valueDAO = new ValueDAO();
|
||||
const valueMap = new Map<string, Value>();
|
||||
await Promise.all(
|
||||
values.map((v: MV2Value) => {
|
||||
const { scriptId, storageName, key, value, createtime } = v;
|
||||
return db
|
||||
.table("scripts")
|
||||
.where("id")
|
||||
.equals(scriptId)
|
||||
.first((script: Script) => {
|
||||
if (script) {
|
||||
let data: { [key: string]: any } = {};
|
||||
if (!valueMap.has(script.uuid)) {
|
||||
valueMap.set(script.uuid, {
|
||||
uuid: script.uuid,
|
||||
storageName: getStorageName(script),
|
||||
data: data,
|
||||
createtime,
|
||||
updatetime: 0,
|
||||
});
|
||||
} else {
|
||||
data = valueMap.get(script.uuid)!.data;
|
||||
}
|
||||
data[key] = value;
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
// 保存到数据库
|
||||
await Promise.all(
|
||||
Array.from(valueMap.keys()).map((uuid) => {
|
||||
const { storageName, data, createtime } = valueMap.get(uuid)!;
|
||||
return valueDAO.save(storageName!, {
|
||||
uuid,
|
||||
storageName,
|
||||
data,
|
||||
createtime,
|
||||
updatetime: 0,
|
||||
});
|
||||
})
|
||||
);
|
||||
console.log("脚本value数据迁移完成", values.length);
|
||||
// 迁移permission
|
||||
const permissions = await db.table("permission").toArray();
|
||||
const permissionDAO = new PermissionDAO();
|
||||
await Promise.all(
|
||||
permissions.map((p: Permission & { scriptId: number }) => {
|
||||
const { scriptId, permission, permissionValue, createtime, updatetime, allow } = p;
|
||||
return db
|
||||
.table("scripts")
|
||||
.where("id")
|
||||
.equals(scriptId)
|
||||
.first((script: Script) => {
|
||||
if (script) {
|
||||
return permissionDAO.save({
|
||||
uuid: script.uuid,
|
||||
permission,
|
||||
permissionValue,
|
||||
createtime,
|
||||
updatetime,
|
||||
allow,
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
console.log("脚本permission数据迁移完成", permissions.length);
|
||||
// 打开页面,告知数据储存+升级至了mv3,重启一次扩展
|
||||
setTimeout(async () => {
|
||||
const scripts = await scriptDAO.all();
|
||||
console.log("脚本数据迁移完成", scripts.length);
|
||||
if (scripts.length > 0) {
|
||||
chrome.tabs.create({
|
||||
url: "https://docs.scriptcat.org/docs/change/v0.17/",
|
||||
});
|
||||
setTimeout(() => {
|
||||
chrome.runtime.reload();
|
||||
}, 1000);
|
||||
}
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
console.error("脚本数据迁移失败", e);
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
return db.open();
|
||||
}
|
||||
|
||||
export default function migrate() {
|
||||
@@ -90,7 +281,8 @@ export default function migrate() {
|
||||
value: "++id,scriptId,storageName,key,createtime",
|
||||
})
|
||||
.upgrade((tx) => {
|
||||
tx.table("value")
|
||||
return tx
|
||||
.table("value")
|
||||
.toCollection()
|
||||
.modify((value) => {
|
||||
if (value.namespace) {
|
||||
@@ -112,5 +304,5 @@ export default function migrate() {
|
||||
permission: "++id,scriptId,[scriptId+permission+permissionValue],createtime,updatetime",
|
||||
});
|
||||
// 使用小峰驼统一命名规范
|
||||
renameField();
|
||||
return renameField();
|
||||
}
|
||||
|
@@ -1,62 +0,0 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { DAO, db } from "./dao";
|
||||
import { LoggerDAO } from "./logger";
|
||||
import migrate from "../migrate";
|
||||
|
||||
migrate();
|
||||
|
||||
interface Test {
|
||||
id: number;
|
||||
data: string;
|
||||
}
|
||||
|
||||
db.version(17).stores({ test: "++id,data" });
|
||||
|
||||
class testDAO extends DAO<Test> {
|
||||
public tableName = "test";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.table = db.table(this.tableName);
|
||||
}
|
||||
}
|
||||
|
||||
describe("dao", () => {
|
||||
const dao = new testDAO();
|
||||
it("测试save", async () => {
|
||||
expect(await dao.save({ id: 0, data: "ok1" })).toEqual(1);
|
||||
|
||||
expect(await dao.save({ id: 0, data: "ok" })).toEqual(2);
|
||||
|
||||
expect(await dao.save({ id: 2, data: "ok2" })).toEqual(2);
|
||||
});
|
||||
|
||||
it("测试find", async () => {
|
||||
expect(await dao.findOne({ id: 1 })).toEqual({ id: 1, data: "ok1" });
|
||||
expect(await dao.findById(2)).toEqual({ id: 2, data: "ok2" });
|
||||
});
|
||||
|
||||
it("测试list", async () => {
|
||||
expect(await dao.list({ id: 1 })).toEqual([{ id: 1, data: "ok1" }]);
|
||||
});
|
||||
|
||||
it("测试delete", async () => {
|
||||
expect(await dao.delete({ id: 1 })).toEqual(1);
|
||||
expect(await dao.findById(1)).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("model", () => {
|
||||
const logger = new LoggerDAO();
|
||||
it("save", async () => {
|
||||
expect(
|
||||
await logger.save({
|
||||
id: 0,
|
||||
level: "info",
|
||||
message: "ok",
|
||||
label: {},
|
||||
createtime: new Date().getTime(),
|
||||
})
|
||||
).toEqual(1);
|
||||
});
|
||||
});
|
@@ -75,9 +75,9 @@ export abstract class DAO<T> {
|
||||
}
|
||||
const resp = await this.table.update(id, <any>val);
|
||||
if (resp) {
|
||||
return Promise.resolve(id);
|
||||
return id;
|
||||
}
|
||||
return Promise.reject(ErrSaveError);
|
||||
throw ErrSaveError;
|
||||
}
|
||||
|
||||
public findById(id: number) {
|
||||
|
17
src/app/repo/localStorage.ts
Normal file
17
src/app/repo/localStorage.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Repo } from "./repo";
|
||||
|
||||
export interface LocalStorageItem {
|
||||
key: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
// 由于service worker不能使用localStorage,这里新建一个类来实现localStorage的功能
|
||||
export class LocalStorageDAO extends Repo<LocalStorageItem> {
|
||||
constructor() {
|
||||
super("localStorage");
|
||||
}
|
||||
|
||||
save(value: LocalStorageItem) {
|
||||
return super._save(value.key, value);
|
||||
}
|
||||
}
|
@@ -9,7 +9,7 @@ export abstract class Repo<T> {
|
||||
return this.prefix + key;
|
||||
}
|
||||
|
||||
protected async _save(key: string, val: T):Promise<T> {
|
||||
protected async _save(key: string, val: T): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
const data = {
|
||||
[this.joinKey(key)]: val,
|
||||
|
@@ -91,6 +91,10 @@ export class ScriptDAO extends Repo<Script> {
|
||||
return super._save(val.uuid, val);
|
||||
}
|
||||
|
||||
findByUUID(uuid: string) {
|
||||
return this.get(uuid);
|
||||
}
|
||||
|
||||
getAndCode(uuid: string): Promise<ScriptAndCode | undefined> {
|
||||
return Promise.all([this.get(uuid), this.scriptCodeDAO.get(uuid)]).then(([script, code]) => {
|
||||
if (!script || !code) {
|
||||
@@ -131,6 +135,10 @@ export class ScriptCodeDAO extends Repo<ScriptCode> {
|
||||
super("scriptCode");
|
||||
}
|
||||
|
||||
findByUUID(uuid: string) {
|
||||
return this.get(uuid);
|
||||
}
|
||||
|
||||
public save(val: ScriptCode) {
|
||||
return super._save(val.uuid, val);
|
||||
}
|
||||
|
@@ -12,7 +12,6 @@ export interface SubscribeScript {
|
||||
}
|
||||
|
||||
export interface Subscribe {
|
||||
id: number;
|
||||
url: string;
|
||||
name: string;
|
||||
code: string;
|
||||
@@ -31,6 +30,10 @@ export class SubscribeDAO extends Repo<Subscribe> {
|
||||
}
|
||||
|
||||
public findByUrl(url: string) {
|
||||
return this.findOne((key, value) => value.url === url);
|
||||
return this.get(url);
|
||||
}
|
||||
|
||||
public save(val: Subscribe) {
|
||||
return super._save(val.url, val);
|
||||
}
|
||||
}
|
||||
|
108
src/app/service/content/content.ts
Normal file
108
src/app/service/content/content.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import Logger from "@App/app/logger/logger";
|
||||
import { ScriptRunResouce } from "@App/app/repo/scripts";
|
||||
import { Client, sendMessage } from "@Packages/message/client";
|
||||
import { CustomEventMessage } from "@Packages/message/custom_event_message";
|
||||
import { forwardMessage, Message, MessageSend, Server } from "@Packages/message/server";
|
||||
|
||||
// content页的处理
|
||||
export default class ContentRuntime {
|
||||
constructor(
|
||||
private extServer: Server,
|
||||
private server: Server,
|
||||
private extSend: MessageSend,
|
||||
private msg: Message
|
||||
) {}
|
||||
|
||||
start(scripts: ScriptRunResouce[]) {
|
||||
this.extServer.on("runtime/emitEvent", (data) => {
|
||||
// 转发给inject
|
||||
return sendMessage(this.msg, "inject/runtime/emitEvent", data);
|
||||
});
|
||||
this.extServer.on("runtime/valueUpdate", (data) => {
|
||||
// 转发给inject
|
||||
return sendMessage(this.msg, "inject/runtime/valueUpdate", data);
|
||||
});
|
||||
forwardMessage("serviceWorker", "script/isInstalled", this.server, this.extSend);
|
||||
forwardMessage(
|
||||
"serviceWorker",
|
||||
"runtime/gmApi",
|
||||
this.server,
|
||||
this.extSend,
|
||||
(data: { api: string; params: any }) => {
|
||||
// 拦截关注的api
|
||||
switch (data.api) {
|
||||
case "CAT_createBlobUrl": {
|
||||
const file = data.params[0] as File;
|
||||
const url = URL.createObjectURL(file);
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 60 * 1000);
|
||||
return url;
|
||||
}
|
||||
case "CAT_fetchBlob": {
|
||||
return fetch(data.params[0]).then((res) => res.blob());
|
||||
}
|
||||
case "CAT_fetchDocument": {
|
||||
return new Promise((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.responseType = "document";
|
||||
xhr.open("GET", data.params[0]);
|
||||
xhr.onload = () => {
|
||||
resolve({
|
||||
relatedTarget: xhr.response,
|
||||
});
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
case "GM_addElement": {
|
||||
let [parentNodeId, tagName, attr] = data.params;
|
||||
let parentNode: EventTarget | undefined;
|
||||
if (parentNodeId) {
|
||||
parentNode = (this.msg as CustomEventMessage).getAndDelRelatedTarget(parentNodeId);
|
||||
}
|
||||
const el = <Element>document.createElement(tagName);
|
||||
|
||||
let textContent = "";
|
||||
if (attr) {
|
||||
if (attr.textContent) {
|
||||
textContent = attr.textContent;
|
||||
delete attr.textContent;
|
||||
}
|
||||
} else {
|
||||
attr = {};
|
||||
}
|
||||
Object.keys(attr).forEach((key) => {
|
||||
el.setAttribute(key, attr[key]);
|
||||
});
|
||||
if (textContent) {
|
||||
el.innerHTML = textContent;
|
||||
}
|
||||
(<Element>parentNode || document.head || document.body || document.querySelector("*")).appendChild(el);
|
||||
const nodeId = (this.msg as CustomEventMessage).sendRelatedTarget(el);
|
||||
return nodeId;
|
||||
}
|
||||
case "GM_log":
|
||||
// 拦截GM_log,打印到控制台
|
||||
// 由于某些页面会处理掉console.log,所以丢到这里来打印
|
||||
switch (data.params.length) {
|
||||
case 1:
|
||||
console.log(data.params[0]);
|
||||
break;
|
||||
case 2:
|
||||
console.log("[" + data.params[1] + "]", data.params[0]);
|
||||
break;
|
||||
case 3:
|
||||
console.log("[" + data.params[1] + "]", data.params[0], data.params[2]);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
const client = new Client(this.msg, "inject");
|
||||
client.do("pageLoad", { scripts });
|
||||
}
|
||||
}
|
@@ -1,8 +1,9 @@
|
||||
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";
|
||||
import { initTestEnv } from "@Tests/utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
initTestEnv();
|
||||
|
@@ -4,17 +4,20 @@ import { ScriptRunResouce } from "@App/app/repo/scripts";
|
||||
import GMApi from "./gm_api";
|
||||
import { compileScript, createContext, proxyContext, ScriptFunc } from "./utils";
|
||||
import { Message } from "@Packages/message/server";
|
||||
import { EmitEventRequest } from "../service_worker/runtime";
|
||||
|
||||
export type ValueUpdateSender = {
|
||||
runFlag: string;
|
||||
tabId?: number;
|
||||
};
|
||||
|
||||
export type ValueUpdateData = {
|
||||
oldValue: any;
|
||||
value: any;
|
||||
key: string; // 值key
|
||||
uuid: string;
|
||||
storageKey: string; // 储存key
|
||||
sender: {
|
||||
runFlag: string;
|
||||
tabId?: number;
|
||||
};
|
||||
storageName: string; // 储存name
|
||||
sender: ValueUpdateSender;
|
||||
};
|
||||
|
||||
export class RuntimeMessage {}
|
||||
@@ -33,16 +36,26 @@ export default class ExecScript {
|
||||
|
||||
GM_info: any;
|
||||
|
||||
constructor(scriptRes: ScriptRunResouce, message: Message, thisContext?: { [key: string]: any }) {
|
||||
constructor(
|
||||
scriptRes: ScriptRunResouce,
|
||||
envPrefix: "content" | "offscreen",
|
||||
message: Message,
|
||||
code: string | ScriptFunc,
|
||||
thisContext?: { [key: string]: any }
|
||||
) {
|
||||
this.scriptRes = scriptRes;
|
||||
this.logger = LoggerCore.getInstance().logger({
|
||||
component: "exec",
|
||||
script: this.scriptRes.uuid,
|
||||
uuid: this.scriptRes.uuid,
|
||||
name: this.scriptRes.name,
|
||||
});
|
||||
this.GM_info = GMApi.GM_info(this.scriptRes);
|
||||
// 构建脚本资源
|
||||
this.scriptFunc = compileScript(this.scriptRes.code);
|
||||
if (typeof code === "string") {
|
||||
this.scriptFunc = compileScript(code);
|
||||
} else {
|
||||
this.scriptFunc = code;
|
||||
}
|
||||
const grantMap: { [key: string]: boolean } = {};
|
||||
scriptRes.metadata.grant?.forEach((key) => {
|
||||
grantMap[key] = true;
|
||||
@@ -52,12 +65,16 @@ export default class ExecScript {
|
||||
this.proxyContent = global;
|
||||
} else {
|
||||
// 构建脚本GM上下文
|
||||
this.sandboxContent = createContext(scriptRes, this.GM_info, message);
|
||||
this.sandboxContent = createContext(scriptRes, this.GM_info, envPrefix, message);
|
||||
this.proxyContent = proxyContext(global, this.sandboxContent, thisContext);
|
||||
}
|
||||
}
|
||||
|
||||
// 触发值更新
|
||||
emitEvent(event: string, eventId: string, data: any) {
|
||||
this.logger.debug("emit event", { event, eventId, data });
|
||||
this.sandboxContent?.emitEvent(event, eventId, data);
|
||||
}
|
||||
|
||||
valueUpdate(data: ValueUpdateData) {
|
||||
this.sandboxContent?.valueUpdate(data);
|
||||
}
|
||||
@@ -67,7 +84,6 @@ export default class ExecScript {
|
||||
return this.scriptFunc.apply(this.proxyContent, [this.proxyContent, this.GM_info]);
|
||||
}
|
||||
|
||||
// TODO: 实现脚本的停止,资源释放
|
||||
stop() {
|
||||
this.logger.debug("script stop");
|
||||
return true;
|
@@ -63,7 +63,7 @@ export class BgExecScriptWarp extends ExecScript {
|
||||
};
|
||||
// @ts-ignore
|
||||
thisContext.CATRetryError = CATRetryError;
|
||||
super(scriptRes, message, thisContext);
|
||||
super(scriptRes, "offscreen", message, scriptRes.code, thisContext);
|
||||
this.setTimeout = setTimeout;
|
||||
this.setInterval = setInterval;
|
||||
}
|
795
src/app/service/content/gm_api.ts
Normal file
795
src/app/service/content/gm_api.ts
Normal file
@@ -0,0 +1,795 @@
|
||||
import { ScriptRunResouce } from "@App/app/repo/scripts";
|
||||
import { base64ToBlob, getMetadataStr, getUserConfigStr, parseUserConfig } from "@App/pkg/utils/script";
|
||||
import { ValueUpdateData } from "./exec_script";
|
||||
import { ExtVersion } from "@App/app/const";
|
||||
import { Message, MessageConnect } from "@Packages/message/server";
|
||||
import { CustomEventMessage } from "@Packages/message/custom_event_message";
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import { connect, sendMessage } from "@Packages/message/client";
|
||||
import EventEmitter from "eventemitter3";
|
||||
import { getStorageName } from "@App/pkg/utils/utils";
|
||||
|
||||
interface ApiParam {
|
||||
depend?: string[];
|
||||
}
|
||||
|
||||
export interface ApiValue {
|
||||
api: any;
|
||||
param: ApiParam;
|
||||
}
|
||||
|
||||
export class GMContext {
|
||||
static apis: Map<string, ApiValue> = new Map();
|
||||
|
||||
public static API(param: ApiParam = {}) {
|
||||
return (target: any, propertyName: string, descriptor: PropertyDescriptor) => {
|
||||
const key = propertyName;
|
||||
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;
|
||||
|
||||
runFlag!: string;
|
||||
|
||||
valueChangeListener = new Map<number, { name: string; listener: GMTypes.ValueChangeListener }>();
|
||||
|
||||
constructor(
|
||||
private prefix: string,
|
||||
private message: Message
|
||||
) {}
|
||||
|
||||
// 单次回调使用
|
||||
public sendMessage(api: string, params: any[]) {
|
||||
return sendMessage(this.message, this.prefix + "/runtime/gmApi", {
|
||||
uuid: this.scriptRes.uuid,
|
||||
api,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 长连接使用,connect只用于接受消息,不发送消息
|
||||
public connect(api: string, params: any[]) {
|
||||
return connect(this.message, this.prefix + "/runtime/gmApi", {
|
||||
uuid: this.scriptRes.uuid,
|
||||
api,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
public valueUpdate(data: ValueUpdateData) {
|
||||
if (data.uuid === this.scriptRes.uuid || data.storageName === getStorageName(this.scriptRes)) {
|
||||
// 触发,并更新值
|
||||
if (data.value === undefined) {
|
||||
if (this.scriptRes.value[data.key] !== undefined) {
|
||||
delete this.scriptRes.value[data.key];
|
||||
}
|
||||
} else {
|
||||
this.scriptRes.value[data.key] = data.value;
|
||||
}
|
||||
this.valueChangeListener.forEach((item) => {
|
||||
if (item.name === data.key) {
|
||||
item.listener(data.key, data.oldValue, data.value, data.sender.runFlag !== this.runFlag, data.sender.tabId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
emitEvent(event: string, eventId: string, data: any) {
|
||||
this.EE.emit(event + ":" + eventId, data);
|
||||
}
|
||||
|
||||
// 获取脚本信息和管理器信息
|
||||
static GM_info(script: ScriptRunResouce) {
|
||||
const metadataStr = getMetadataStr(script.code);
|
||||
const userConfigStr = getUserConfigStr(script.code) || "";
|
||||
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;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
public GM_setValue(key: string, value: any) {
|
||||
// 对object的value进行一次转化
|
||||
if (typeof value === "object") {
|
||||
value = JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
if (value === undefined) {
|
||||
delete this.scriptRes.value[key];
|
||||
return this.sendMessage("GM_setValue", [key]);
|
||||
} else {
|
||||
this.scriptRes.value[key] = value;
|
||||
return this.sendMessage("GM_setValue", [key, value]);
|
||||
}
|
||||
}
|
||||
|
||||
@GMContext.API({ depend: ["GM_setValue"] })
|
||||
public GM_deleteValue(name: string): void {
|
||||
this.GM_setValue(name, undefined);
|
||||
}
|
||||
|
||||
eventId: number = 0;
|
||||
|
||||
menuMap: Map<number, string> | undefined;
|
||||
|
||||
EE: EventEmitter = new EventEmitter();
|
||||
|
||||
@GMContext.API()
|
||||
public GM_addValueChangeListener(name: string, listener: GMTypes.ValueChangeListener): number {
|
||||
this.eventId += 1;
|
||||
this.valueChangeListener.set(this.eventId, { name, listener });
|
||||
return this.eventId;
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
public GM_removeValueChangeListener(listenerId: number): void {
|
||||
this.valueChangeListener.delete(listenerId);
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
public GM_listValues(): string[] {
|
||||
return Object.keys(this.scriptRes.value);
|
||||
}
|
||||
|
||||
@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()
|
||||
public CAT_createBlobUrl(blob: Blob): Promise<string> {
|
||||
return this.sendMessage("CAT_createBlobUrl", [blob]);
|
||||
}
|
||||
|
||||
// 辅助GM_xml获取blob数据
|
||||
@GMContext.API()
|
||||
public CAT_fetchBlob(url: string): Promise<Blob> {
|
||||
return this.sendMessage("CAT_fetchBlob", [url]);
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
public async CAT_fetchDocument(url: string): Promise<Document | undefined> {
|
||||
const data = await this.sendMessage("CAT_fetchDocument", [url]);
|
||||
return (<CustomEventMessage>this.message).getAndDelRelatedTarget(data.relatedTarget) as Document;
|
||||
}
|
||||
|
||||
@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);
|
||||
});
|
||||
}
|
||||
|
||||
@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, menuId) => {
|
||||
if (val === name) {
|
||||
flag = menuId;
|
||||
}
|
||||
});
|
||||
if (flag) {
|
||||
return flag;
|
||||
}
|
||||
this.eventId += 1;
|
||||
const id = this.eventId;
|
||||
this.menuMap.set(id, name);
|
||||
this.EE.addListener("menuClick:" + id, listener);
|
||||
this.sendMessage("GM_registerMenuCommand", [id, name, accessKey]);
|
||||
return id;
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
GM_addStyle(css: string) {
|
||||
// 与content页的消息通讯实际是同步,此方法不需要经过background
|
||||
// 这里直接使用同步的方式去处理, 不要有promise
|
||||
const resp = (<CustomEventMessage>this.message).syncSendMessage({
|
||||
action: this.prefix + "/runtime/gmApi",
|
||||
data: {
|
||||
uuid: this.scriptRes.uuid,
|
||||
api: "GM_addElement",
|
||||
params: [
|
||||
null,
|
||||
"style",
|
||||
{
|
||||
textContent: css,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
if (resp.code !== 0) {
|
||||
throw new Error(resp.message);
|
||||
}
|
||||
return (<CustomEventMessage>this.message).getAndDelRelatedTarget(resp.data);
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
GM_addElement(parentNode: EventTarget | string, tagName: any, attrs?: any) {
|
||||
// 与content页的消息通讯实际是同步,此方法不需要经过background
|
||||
// 这里直接使用同步的方式去处理, 不要有promise
|
||||
let parentNodeId: any = parentNode;
|
||||
if (typeof parentNodeId !== "string") {
|
||||
const id = (<CustomEventMessage>this.message).sendRelatedTarget(parentNodeId);
|
||||
parentNodeId = id;
|
||||
} else {
|
||||
parentNodeId = null;
|
||||
}
|
||||
const resp = (<CustomEventMessage>this.message).syncSendMessage({
|
||||
action: this.prefix + "/runtime/gmApi",
|
||||
data: {
|
||||
uuid: this.scriptRes.uuid,
|
||||
api: "GM_addElement",
|
||||
params: [
|
||||
parentNodeId,
|
||||
typeof parentNode === "string" ? parentNode : tagName,
|
||||
typeof parentNode === "string" ? tagName : attrs,
|
||||
],
|
||||
},
|
||||
});
|
||||
if (resp.code !== 0) {
|
||||
throw new Error(resp.message);
|
||||
}
|
||||
return (<CustomEventMessage>this.message).getAndDelRelatedTarget(resp.data);
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
GM_unregisterMenuCommand(id: number): void {
|
||||
if (!this.menuMap) {
|
||||
this.menuMap = new Map();
|
||||
}
|
||||
this.menuMap.delete(id);
|
||||
this.EE.removeAllListeners("menuClick:" + id);
|
||||
this.sendMessage("GM_unregisterMenuCommand", [id]);
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
CAT_userConfig() {
|
||||
return this.sendMessage("CAT_userConfig", []);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
this.sendMessage("CAT_fileStorage", [action, sendDetails]).then(async (resp: { action: string; data: any }) => {
|
||||
switch (resp.action) {
|
||||
case "onload": {
|
||||
if (action === "download") {
|
||||
// 读取blob
|
||||
const blob = await this.CAT_fetchBlob(resp.data);
|
||||
details.onload && details.onload(blob);
|
||||
} else {
|
||||
details.onload && details.onload(resp.data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
if (typeof resp.data.code === "undefined") {
|
||||
details.onerror && details.onerror({ code: -1, message: resp.data.message });
|
||||
return;
|
||||
}
|
||||
details.onerror && details.onerror(resp.data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 用于脚本跨域请求,需要@connect domain指定允许的域名
|
||||
@GMContext.API({
|
||||
depend: ["CAT_fetchBlob", "CAT_createBlobUrl", "CAT_fetchDocument"],
|
||||
})
|
||||
public GM_xmlhttpRequest(details: GMTypes.XHRDetails) {
|
||||
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,
|
||||
redirect: details.redirect,
|
||||
};
|
||||
if (!param.headers) {
|
||||
param.headers = {};
|
||||
}
|
||||
if (details.nocache) {
|
||||
param.headers["Cache-Control"] = "no-cache";
|
||||
}
|
||||
let connect: MessageConnect;
|
||||
const handler = async () => {
|
||||
// 处理数据
|
||||
if (details.data instanceof FormData) {
|
||||
// 处理FormData
|
||||
param.dataType = "FormData";
|
||||
const data: Array<GMSend.XHRFormData> = [];
|
||||
const keys: { [key: string]: boolean } = {};
|
||||
details.data.forEach((val, key) => {
|
||||
keys[key] = true;
|
||||
});
|
||||
// 处理FormData中的数据
|
||||
await Promise.all(
|
||||
Object.keys(keys).map((key) => {
|
||||
const values = (<FormData>details.data).getAll(key);
|
||||
return Promise.all(
|
||||
values.map(async (val) => {
|
||||
if (val instanceof File) {
|
||||
const url = await this.CAT_createBlobUrl(val);
|
||||
data.push({
|
||||
key,
|
||||
type: "file",
|
||||
val: url,
|
||||
filename: val.name,
|
||||
});
|
||||
} else {
|
||||
data.push({
|
||||
key,
|
||||
type: "text",
|
||||
val,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
param.data = data;
|
||||
} else if (details.data instanceof Blob) {
|
||||
// 处理blob
|
||||
param.dataType = "Blob";
|
||||
param.data = await this.CAT_createBlobUrl(details.data);
|
||||
} else {
|
||||
param.data = details.data;
|
||||
}
|
||||
|
||||
// 处理返回数据
|
||||
let readerStream: ReadableStream<Uint8Array> | undefined;
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
|
||||
// 如果返回类型是arraybuffer或者blob的情况下,需要将返回的数据转化为blob
|
||||
// 在background通过URL.createObjectURL转化为url,然后在content页读取url获取blob对象
|
||||
const responseType = details.responseType?.toLocaleLowerCase();
|
||||
const warpResponse = (old: (xhr: GMTypes.XHRResponse) => void) => {
|
||||
if (responseType === "stream") {
|
||||
readerStream = new ReadableStream<Uint8Array>({
|
||||
start(ctrl) {
|
||||
controller = ctrl;
|
||||
},
|
||||
});
|
||||
}
|
||||
return async (xhr: GMTypes.XHRResponse) => {
|
||||
if (xhr.response) {
|
||||
if (responseType === "document") {
|
||||
xhr.response = await this.CAT_fetchDocument(<string>xhr.response);
|
||||
xhr.responseXML = xhr.response;
|
||||
xhr.responseType = "document";
|
||||
} else {
|
||||
const resp = await this.CAT_fetchBlob(<string>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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送信息
|
||||
this.connect("GM_xmlhttpRequest", [param]).then((con) => {
|
||||
connect = con;
|
||||
con.onMessage((data: { action: string; data: any }) => {
|
||||
// 处理返回
|
||||
switch (data.action) {
|
||||
case "onload":
|
||||
details.onload?.(data.data);
|
||||
break;
|
||||
case "onloadend":
|
||||
details.onloadend?.(data.data);
|
||||
break;
|
||||
case "onloadstart":
|
||||
details.onloadstart?.(data.data);
|
||||
break;
|
||||
case "onprogress":
|
||||
details.onprogress?.(data.data);
|
||||
break;
|
||||
case "onreadystatechange":
|
||||
details.onreadystatechange && details.onreadystatechange(data.data);
|
||||
break;
|
||||
case "ontimeout":
|
||||
details.ontimeout?.();
|
||||
break;
|
||||
case "onerror":
|
||||
details.onerror?.("");
|
||||
break;
|
||||
case "onabort":
|
||||
details.onabort?.();
|
||||
break;
|
||||
case "onstream":
|
||||
controller?.enqueue(new Uint8Array(data.data));
|
||||
break;
|
||||
default:
|
||||
LoggerCore.logger().warn("GM_xmlhttpRequest resp is error", {
|
||||
data,
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
// 由于需要同步返回一个abort,但是一些操作是异步的,所以需要在这里处理
|
||||
handler();
|
||||
return {
|
||||
abort: () => {
|
||||
if (connect) {
|
||||
connect.disconnect();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
GM_download(url: GMTypes.DownloadDetails | string, filename?: string): GMTypes.AbortHandle<void> {
|
||||
let details: GMTypes.DownloadDetails;
|
||||
if (typeof url === "string") {
|
||||
details = {
|
||||
name: filename || "",
|
||||
url,
|
||||
};
|
||||
} else {
|
||||
details = url;
|
||||
}
|
||||
let connect: MessageConnect;
|
||||
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,
|
||||
},
|
||||
]).then((con) => {
|
||||
connect = con;
|
||||
connect.onMessage((data: { action: string; data: any }) => {
|
||||
switch (data.action) {
|
||||
case "onload":
|
||||
details.onload && details.onload(data.data);
|
||||
break;
|
||||
case "onprogress":
|
||||
details.onprogress && details.onprogress(<GMTypes.XHRProgress>data.data);
|
||||
break;
|
||||
case "ontimeout":
|
||||
details.ontimeout && details.ontimeout();
|
||||
break;
|
||||
case "onerror":
|
||||
details.onerror &&
|
||||
details.onerror({
|
||||
error: "unknown",
|
||||
});
|
||||
break;
|
||||
default:
|
||||
LoggerCore.logger().warn("GM_download resp is error", {
|
||||
data,
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
abort: () => {
|
||||
connect?.disconnect();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@GMContext.API({
|
||||
depend: ["GM_closeNotification", "GM_updateNotification"],
|
||||
})
|
||||
public async GM_notification(
|
||||
detail: GMTypes.NotificationDetails | string,
|
||||
ondone?: GMTypes.NotificationOnDone | string,
|
||||
image?: string,
|
||||
onclick?: GMTypes.NotificationOnClick
|
||||
) {
|
||||
this.eventId += 1;
|
||||
let data: GMTypes.NotificationDetails;
|
||||
if (typeof detail === "string") {
|
||||
data = {};
|
||||
data.text = detail;
|
||||
switch (arguments.length) {
|
||||
case 4:
|
||||
data.onclick = onclick;
|
||||
case 3:
|
||||
data.image = image;
|
||||
case 2:
|
||||
data.title = <string>ondone;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
data = Object.assign({}, detail);
|
||||
data.ondone = data.ondone || <GMTypes.NotificationOnDone>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.sendMessage("GM_notification", [data]).then((id) => {
|
||||
if (create) {
|
||||
create.apply({ id }, [id]);
|
||||
}
|
||||
this.EE.addListener("GM_notification:" + id, (resp: any) => {
|
||||
switch (resp.event) {
|
||||
case "click":
|
||||
case "buttonClick": {
|
||||
click && click.apply({ id }, [id, resp.params.index]);
|
||||
break;
|
||||
}
|
||||
case "close": {
|
||||
done && done.apply({ id }, [resp.params.byUser]);
|
||||
this.EE.removeAllListeners("GM_notification:" + this.eventId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
LoggerCore.logger().warn("GM_notification resp is error", {
|
||||
resp,
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
public GM_closeNotification(id: string): void {
|
||||
this.sendMessage("GM_closeNotification", [id]);
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
public GM_updateNotification(id: string, details: GMTypes.NotificationDetails): void {
|
||||
this.sendMessage("GM_updateNotification", [id, details]);
|
||||
}
|
||||
|
||||
@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 = <GMTypes.OpenTabOptions>options;
|
||||
}
|
||||
if (option.active === undefined) {
|
||||
option.active = true;
|
||||
}
|
||||
let tabid: any;
|
||||
|
||||
const ret: GMTypes.Tab = {
|
||||
close: () => {
|
||||
tabid && this.GM_closeInTab(tabid);
|
||||
},
|
||||
};
|
||||
|
||||
this.sendMessage("GM_openInTab", [url, option]).then((id) => {
|
||||
if (id) {
|
||||
tabid = id;
|
||||
this.EE.addListener("GM_openInTab:" + id, (resp: any) => {
|
||||
switch (resp.event) {
|
||||
case "oncreate":
|
||||
tabid = resp.tabId;
|
||||
break;
|
||||
case "onclose":
|
||||
ret.onclose && ret.onclose();
|
||||
ret.closed = true;
|
||||
this.EE.removeAllListeners("GM_openInTab:" + id);
|
||||
break;
|
||||
default:
|
||||
LoggerCore.logger().warn("GM_openInTab resp is error", {
|
||||
resp,
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ret.onclose && ret.onclose();
|
||||
ret.closed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
public GM_closeInTab(tabid: string) {
|
||||
return this.sendMessage("GM_closeInTab", [tabid]);
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
GM_getTab(callback: (data: any) => void) {
|
||||
this.sendMessage("GM_getTab", []).then((data) => {
|
||||
callback(data);
|
||||
});
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
GM_saveTab(obj: object) {
|
||||
if (typeof obj === "object") {
|
||||
obj = JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
this.sendMessage("GM_saveTab", [obj]);
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
GM_getTabs(callback: (objs: { [key: string | number]: object }) => any) {
|
||||
this.sendMessage("GM_getTabs", []).then((resp) => {
|
||||
callback(resp);
|
||||
});
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
GM_setClipboard(data: string, info?: string | { type?: string; minetype?: string }) {
|
||||
this.sendMessage("GM_setClipboard", [data, info]);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
101
src/app/service/content/inject.ts
Normal file
101
src/app/service/content/inject.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { ScriptRunResouce } from "@App/app/repo/scripts";
|
||||
import { Message, Server } from "@Packages/message/server";
|
||||
import ExecScript, { ValueUpdateData } from "./exec_script";
|
||||
import { addStyle, ScriptFunc } from "./utils";
|
||||
import { getStorageName } from "@App/pkg/utils/utils";
|
||||
import { EmitEventRequest } from "../service_worker/runtime";
|
||||
import { ExternalWhitelist } from "@App/app/const";
|
||||
import { sendMessage } from "@Packages/message/client";
|
||||
|
||||
export class InjectRuntime {
|
||||
execList: ExecScript[] = [];
|
||||
|
||||
constructor(
|
||||
private server: Server,
|
||||
private msg: Message,
|
||||
private scripts: ScriptRunResouce[]
|
||||
) {}
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
this.server.on("runtime/emitEvent", (data: EmitEventRequest) => {
|
||||
// 转发给脚本
|
||||
const exec = this.execList.find((val) => val.scriptRes.uuid === data.uuid);
|
||||
if (exec) {
|
||||
exec.emitEvent(data.event, data.eventId, data.data);
|
||||
}
|
||||
});
|
||||
this.server.on("runtime/valueUpdate", (data: ValueUpdateData) => {
|
||||
this.execList
|
||||
.filter((val) => val.scriptRes.uuid === data.uuid || getStorageName(val.scriptRes) === data.storageName)
|
||||
.forEach((val) => {
|
||||
val.valueUpdate(data);
|
||||
});
|
||||
});
|
||||
// 注入允许外部调用
|
||||
this.externalMessage();
|
||||
}
|
||||
|
||||
externalMessage() {
|
||||
// 对外接口白名单
|
||||
let msg = this.msg;
|
||||
for (let i = 0; i < ExternalWhitelist.length; i += 1) {
|
||||
if (window.location.host.endsWith(ExternalWhitelist[i])) {
|
||||
// 注入
|
||||
(<{ external: any }>(<unknown>window)).external = window.external || {};
|
||||
(<
|
||||
{
|
||||
external: {
|
||||
Scriptcat: {
|
||||
isInstalled: (name: string, namespace: string, callback: any) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
>(<unknown>window)).external.Scriptcat = {
|
||||
async isInstalled(name: string, namespace: string, callback: any) {
|
||||
const resp = await sendMessage(msg, "content/script/isInstalled", {
|
||||
name,
|
||||
namespace,
|
||||
});
|
||||
callback(resp);
|
||||
},
|
||||
};
|
||||
(<{ external: { Tampermonkey: any } }>(<unknown>window)).external.Tampermonkey = (<
|
||||
{ external: { Scriptcat: any } }
|
||||
>(<unknown>window)).external.Scriptcat;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
execScript(script: ScriptRunResouce, scriptFunc: ScriptFunc) {
|
||||
// @ts-ignore
|
||||
delete window[script.flag];
|
||||
const exec = new ExecScript(script, "content", this.msg, 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
exec.exec();
|
||||
}
|
||||
}
|
@@ -1,3 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { init, proxyContext, writables } from "./utils";
|
||||
|
||||
describe("proxy context", () => {
|
@@ -3,9 +3,10 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import GMApi, { ApiValue, GMContext } from "./gm_api";
|
||||
import { has } from "@App/pkg/utils/lodash";
|
||||
import { Message } from "@Packages/message/server";
|
||||
import EventEmitter from "eventemitter3";
|
||||
|
||||
// 构建脚本运行代码
|
||||
export function compileScriptCode(scriptRes: ScriptRunResouce, code: string): string {
|
||||
export function compileScriptCode(scriptRes: ScriptRunResouce): string {
|
||||
let require = "";
|
||||
if (scriptRes.metadata.require) {
|
||||
scriptRes.metadata.require.forEach((val) => {
|
||||
@@ -15,7 +16,7 @@ export function compileScriptCode(scriptRes: ScriptRunResouce, code: string): st
|
||||
}
|
||||
});
|
||||
}
|
||||
code = require + code;
|
||||
const code = require + scriptRes.code;
|
||||
return `with (context) return (async ()=>{\n${code}\n//# sourceURL=${chrome.runtime.getURL(
|
||||
`/${encodeURI(scriptRes.name)}.user.js`
|
||||
)}\n})()`;
|
||||
@@ -53,16 +54,20 @@ function setDepend(context: { [key: string]: any }, apiVal: ApiValue) {
|
||||
}
|
||||
|
||||
// 构建沙盒上下文
|
||||
export function createContext(scriptRes: ScriptRunResouce, GMInfo: any, message: Message): GMApi {
|
||||
export function createContext(scriptRes: ScriptRunResouce, GMInfo: any, envPrefix: string, message: Message): GMApi {
|
||||
// 按照GMApi构建
|
||||
const context: { [key: string]: any } = {
|
||||
prefix: envPrefix,
|
||||
message: message,
|
||||
scriptRes,
|
||||
valueChangeListener: new Map<number, { name: string; listener: GMTypes.ValueChangeListener }>(),
|
||||
sendMessage: GMApi.prototype.sendMessage,
|
||||
connect: GMApi.prototype.connect,
|
||||
runFlag: uuidv4(),
|
||||
eventId: 10000,
|
||||
valueUpdate: GMApi.prototype.valueUpdate,
|
||||
emitEvent: GMApi.prototype.emitEvent,
|
||||
EE: new EventEmitter(),
|
||||
GM: { Info: GMInfo },
|
||||
GM_info: GMInfo,
|
||||
};
|
16
src/app/service/gm_api.test.ts
Normal file
16
src/app/service/gm_api.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { initTestEnv } from "@Tests/utils";
|
||||
|
||||
initTestEnv();
|
||||
// serviceWorker环境
|
||||
|
||||
beforeAll(() => {});
|
||||
|
||||
describe("GM xhr", () => {
|
||||
beforeEach(() => {
|
||||
// See https://webext-core.aklinker1.io/fake-browser/reseting-state
|
||||
});
|
||||
it("123123", async () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
});
|
@@ -1,19 +1,20 @@
|
||||
import { WindowMessage } from "@Packages/message/window_message";
|
||||
import { SCRIPT_RUN_STATUS } from "@App/app/repo/scripts";
|
||||
import { SCRIPT_RUN_STATUS, ScriptRunResouce } from "@App/app/repo/scripts";
|
||||
import { sendMessage } from "@Packages/message/client";
|
||||
import { MessageSend } from "@Packages/message/server";
|
||||
|
||||
export function preparationSandbox(msg: WindowMessage) {
|
||||
return sendMessage(msg, "preparationSandbox");
|
||||
return sendMessage(msg, "offscreen/preparationSandbox");
|
||||
}
|
||||
|
||||
// 代理发送消息到ServiceWorker
|
||||
export function sendMessageToServiceWorker(msg: WindowMessage, action: string, data?: any) {
|
||||
return sendMessage(msg, "sendMessageToServiceWorker", { action, data });
|
||||
return sendMessage(msg, "offscreen/sendMessageToServiceWorker", { action, data });
|
||||
}
|
||||
|
||||
// 代理连接ServiceWorker
|
||||
export function connectServiceWorker(msg: WindowMessage) {
|
||||
return sendMessage(msg, "connectServiceWorker");
|
||||
return sendMessage(msg, "offscreen/connectServiceWorker");
|
||||
}
|
||||
|
||||
export function proxyUpdateRunStatus(
|
||||
@@ -22,3 +23,15 @@ export function proxyUpdateRunStatus(
|
||||
) {
|
||||
return sendMessageToServiceWorker(msg, "script/updateRunStatus", data);
|
||||
}
|
||||
|
||||
export function runScript(msg: MessageSend, data: ScriptRunResouce) {
|
||||
return sendMessage(msg, "offscreen/script/runScript", data);
|
||||
}
|
||||
|
||||
export function stopScript(msg: MessageSend, uuid: string) {
|
||||
return sendMessage(msg, "offscreen/script/stopScript", uuid);
|
||||
}
|
||||
|
||||
export function createObjectURL(msg: MessageSend, data: Blob) {
|
||||
return sendMessage(msg, "offscreen/createObjectURL", data);
|
||||
}
|
||||
|
@@ -1,15 +1,209 @@
|
||||
import { Group } from "@Packages/message/server";
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import Logger from "@App/app/logger/logger";
|
||||
import { GetSender, Group, MessageConnect } from "@Packages/message/server";
|
||||
|
||||
export default class GMApi {
|
||||
logger: Logger = LoggerCore.logger().with({ service: "gmApi" });
|
||||
|
||||
export class GMApi {
|
||||
constructor(private group: Group) {}
|
||||
|
||||
xmlHttpRequest(){
|
||||
async dealXhrResponse(
|
||||
con: MessageConnect,
|
||||
details: GMSend.XHRDetails,
|
||||
event: string,
|
||||
xhr: XMLHttpRequest,
|
||||
data?: any
|
||||
) {
|
||||
const finalUrl = xhr.responseURL || details.url;
|
||||
let response: GMTypes.XHRResponse = {
|
||||
finalUrl,
|
||||
readyState: <any>xhr.readyState,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
// header由service_worker处理,但是存在特殊域名(例如:edge.microsoft.com)无法获取的情况,在这里增加一个默认值
|
||||
responseHeaders: xhr.getAllResponseHeaders(),
|
||||
responseType: details.responseType,
|
||||
};
|
||||
if (xhr.readyState === 4) {
|
||||
const responseType = details.responseType?.toLowerCase();
|
||||
if (responseType === "arraybuffer" || responseType === "blob") {
|
||||
let blob: Blob;
|
||||
if (xhr.response instanceof ArrayBuffer) {
|
||||
blob = new Blob([xhr.response]);
|
||||
response.response = URL.createObjectURL(blob);
|
||||
} else {
|
||||
blob = <Blob>xhr.response;
|
||||
response.response = URL.createObjectURL(blob);
|
||||
}
|
||||
try {
|
||||
if (xhr.getResponseHeader("Content-Type")?.indexOf("text") !== -1) {
|
||||
// 如果是文本类型,则尝试转换为文本
|
||||
response.responseText = await blob.text();
|
||||
}
|
||||
} catch (e) {
|
||||
LoggerCore.logger(Logger.E(e)).error("GM XHR getResponseHeader error");
|
||||
}
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(<string>response.response);
|
||||
}, 60 * 1000);
|
||||
} else if (response.responseType === "json") {
|
||||
try {
|
||||
response.response = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
LoggerCore.logger(Logger.E(e)).error("GM XHR JSON parse error");
|
||||
}
|
||||
try {
|
||||
response.responseText = xhr.responseText;
|
||||
} catch (e) {
|
||||
LoggerCore.logger(Logger.E(e)).error("GM XHR getResponseText error");
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
response.response = xhr.response;
|
||||
} catch (e) {
|
||||
LoggerCore.logger(Logger.E(e)).error("GM XHR response error");
|
||||
}
|
||||
try {
|
||||
response.responseText = xhr.responseText || undefined;
|
||||
} catch (e) {
|
||||
LoggerCore.logger(Logger.E(e)).error("GM XHR getResponseText error");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data) {
|
||||
response = Object.assign(response, data);
|
||||
}
|
||||
con.sendMessage({
|
||||
action: event,
|
||||
data: response,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
CAT_fetch(details: GMSend.XHRDetails, sender: GetSender) {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async xmlHttpRequest(details: GMSend.XHRDetails, sender: GetSender) {
|
||||
if (details.responseType === "stream") {
|
||||
// 只有fetch支持ReadableStream
|
||||
return this.CAT_fetch(details, sender);
|
||||
}
|
||||
const xhr = new XMLHttpRequest();
|
||||
const con = sender.getConnect();
|
||||
xhr.open(details.method || "GET", details.url, true, details.user || "", details.password || "");
|
||||
// 添加header
|
||||
if (details.headers) {
|
||||
for (const key in details.headers) {
|
||||
xhr.setRequestHeader(key, details.headers[key]);
|
||||
}
|
||||
}
|
||||
//超时时间
|
||||
if (details.timeout) {
|
||||
xhr.timeout = details.timeout;
|
||||
}
|
||||
if (details.overrideMimeType) {
|
||||
xhr.overrideMimeType(details.overrideMimeType);
|
||||
}
|
||||
//设置响应类型
|
||||
if (details.responseType !== "json") {
|
||||
xhr.responseType = details.responseType || "";
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
this.dealXhrResponse(con, details, "onload", xhr);
|
||||
};
|
||||
xhr.onloadstart = () => {
|
||||
this.dealXhrResponse(con!, details, "onloadstart", xhr);
|
||||
};
|
||||
xhr.onloadend = () => {
|
||||
this.dealXhrResponse(con!, details, "onloadend", xhr);
|
||||
};
|
||||
xhr.onabort = () => {
|
||||
this.dealXhrResponse(con!, details, "onabort", xhr);
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
this.dealXhrResponse(con!, details, "onerror", xhr);
|
||||
};
|
||||
xhr.onprogress = (event) => {
|
||||
const respond: GMTypes.XHRProgress = {
|
||||
done: xhr.DONE,
|
||||
lengthComputable: event.lengthComputable,
|
||||
loaded: event.loaded,
|
||||
total: event.total,
|
||||
totalSize: event.total,
|
||||
};
|
||||
this.dealXhrResponse(con!, details, "onprogress", xhr, respond);
|
||||
};
|
||||
xhr.onreadystatechange = () => {
|
||||
this.dealXhrResponse(con!, details, "onreadystatechange", xhr);
|
||||
};
|
||||
xhr.ontimeout = () => {
|
||||
con?.sendMessage({ action: "ontimeout", data: {} });
|
||||
};
|
||||
//处理数据
|
||||
if (details.dataType === "FormData") {
|
||||
const data = new FormData();
|
||||
if (details.data && details.data instanceof Array) {
|
||||
await Promise.all(
|
||||
details.data.map(async (val: GMSend.XHRFormData) => {
|
||||
if (val.type === "file") {
|
||||
const file = new File([await (await fetch(val.val)).blob()], val.filename!);
|
||||
data.append(val.key, file, val.filename);
|
||||
} else {
|
||||
data.append(val.key, val.val);
|
||||
}
|
||||
})
|
||||
);
|
||||
xhr.send(data);
|
||||
}
|
||||
} else if (details.dataType === "Blob") {
|
||||
if (!details.data) {
|
||||
throw new Error("Blob data is empty");
|
||||
}
|
||||
const resp = await (await fetch(<string>details.data)).blob();
|
||||
xhr.send(resp);
|
||||
} else {
|
||||
xhr.send(<string>details.data);
|
||||
}
|
||||
|
||||
con?.onDisconnect(() => {
|
||||
xhr.abort();
|
||||
});
|
||||
}
|
||||
|
||||
openInTab({ url }: { url: string }) {
|
||||
return Promise.resolve(window.open(url) !== undefined);
|
||||
}
|
||||
|
||||
textarea: HTMLTextAreaElement = document.createElement("textarea");
|
||||
|
||||
clipboardData: { type?: string; data: string } | undefined;
|
||||
|
||||
async setClipboard({ data, type }: { data: string; type: string }) {
|
||||
this.clipboardData = {
|
||||
type,
|
||||
data,
|
||||
};
|
||||
this.textarea.focus();
|
||||
document.execCommand("copy", false, <any>null);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.group.on("xmlHttpRequest", async (data) => {
|
||||
console.log(data);
|
||||
this.textarea.style.display = "none";
|
||||
document.documentElement.appendChild(this.textarea);
|
||||
document.addEventListener("copy", (e: ClipboardEvent) => {
|
||||
if (!this.clipboardData || !e.clipboardData) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const { type, data } = this.clipboardData;
|
||||
e.clipboardData.setData(type || "text/plain", data);
|
||||
this.clipboardData = undefined;
|
||||
});
|
||||
|
||||
this.group.on("xmlHttpRequest", this.xmlHttpRequest.bind(this));
|
||||
this.group.on("openInTab", this.openInTab.bind(this));
|
||||
this.group.on("setClipboard", this.setClipboard.bind(this));
|
||||
}
|
||||
}
|
||||
|
@@ -1,12 +1,12 @@
|
||||
import { forwardMessage, MessageSend, Server } from "@Packages/message/server";
|
||||
import { ScriptService } from "./script";
|
||||
import { Broker } from "@Packages/message/message_queue";
|
||||
import { Logger, LoggerDAO } from "@App/app/repo/logger";
|
||||
import { WindowMessage } from "@Packages/message/window_message";
|
||||
import { ExtensionMessageSend } from "@Packages/message/extension_message";
|
||||
import { ServiceWorkerClient } from "../service_worker/client";
|
||||
import { sendMessage } from "@Packages/message/client";
|
||||
import { GMApi } from "./gm_api";
|
||||
import GMApi from "./gm_api";
|
||||
import { MessageQueue } from "@Packages/message/message_queue";
|
||||
|
||||
// offscreen环境的管理器
|
||||
export class OffscreenManager {
|
||||
@@ -14,12 +14,16 @@ export class OffscreenManager {
|
||||
|
||||
private windowMessage = new WindowMessage(window, sandbox, true);
|
||||
|
||||
private windowApi: Server = new Server(this.windowMessage);
|
||||
private windowServer: Server = new Server("offscreen", this.windowMessage);
|
||||
|
||||
private broker: Broker = new Broker(this.extensionMessage);
|
||||
private messageQueue: MessageQueue = new MessageQueue();
|
||||
|
||||
private serviceWorker = new ServiceWorkerClient(this.extensionMessage);
|
||||
|
||||
constructor(private extensionMessage:MessageSend) {
|
||||
|
||||
}
|
||||
|
||||
logger(data: Logger) {
|
||||
const dao = new LoggerDAO();
|
||||
dao.save(data);
|
||||
@@ -31,34 +35,37 @@ export class OffscreenManager {
|
||||
}
|
||||
|
||||
sendMessageToServiceWorker(data: { action: string; data: any }) {
|
||||
return sendMessage(this.extensionMessage, data.action, data.data);
|
||||
return sendMessage(this.extensionMessage, "serviceWorker/" + data.action, data.data);
|
||||
}
|
||||
|
||||
async initManager() {
|
||||
// 监听消息
|
||||
this.windowApi.on("logger", this.logger.bind(this));
|
||||
this.windowApi.on("preparationSandbox", this.preparationSandbox.bind(this));
|
||||
this.windowApi.on("sendMessageToServiceWorker", this.sendMessageToServiceWorker.bind(this));
|
||||
const script = new ScriptService(this.extensionMessage, this.windowMessage, this.broker);
|
||||
this.windowServer.on("logger", this.logger.bind(this));
|
||||
this.windowServer.on("preparationSandbox", this.preparationSandbox.bind(this));
|
||||
this.windowServer.on("sendMessageToServiceWorker", this.sendMessageToServiceWorker.bind(this));
|
||||
const script = new ScriptService(
|
||||
this.windowServer.group("script"),
|
||||
this.extensionMessage,
|
||||
this.windowMessage,
|
||||
this.messageQueue
|
||||
);
|
||||
script.init();
|
||||
// 转发gm api请求
|
||||
forwardMessage("runtime/gmApi", this.windowApi, this.extensionMessage);
|
||||
const gmApi = new GMApi(this.windowApi.group("gmApi"));
|
||||
// 转发从sandbox来的gm api请求
|
||||
forwardMessage("serviceWorker", "runtime/gmApi", this.windowServer, this.extensionMessage);
|
||||
// 转发valueUpdate与emitEvent
|
||||
forwardMessage("sandbox", "runtime/valueUpdate", this.windowServer, this.windowMessage);
|
||||
forwardMessage("sandbox", "runtime/emitEvent", this.windowServer, this.windowMessage);
|
||||
|
||||
const gmApi = new GMApi(this.windowServer.group("gmApi"));
|
||||
gmApi.init();
|
||||
|
||||
// // 处理gm xhr请求
|
||||
// this.api.on("gmXhr", (data) => {
|
||||
// console.log("123");
|
||||
// });
|
||||
// // 测试xhr
|
||||
// const ret = await sendMessage(this.extensionMessage, "serviceWorker/testGmApi");
|
||||
// console.log("test xhr", ret);
|
||||
// const xhr = new XMLHttpRequest();
|
||||
// xhr.onload = () => {
|
||||
// console.log(xhr);
|
||||
// };
|
||||
// xhr.open("GET", "https://scriptcat.org/zh-CN");
|
||||
|
||||
// xhr.send();
|
||||
this.windowServer.on("createObjectURL", (data: Blob) => {
|
||||
console.log("createObjectURL", data);
|
||||
const url = URL.createObjectURL(data);
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 1000 * 60);
|
||||
return Promise.resolve(url);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,17 +1,12 @@
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import Logger from "@App/app/logger/logger";
|
||||
import { Broker } from "@Packages/message/message_queue";
|
||||
import { MessageQueue } from "@Packages/message/message_queue";
|
||||
import { WindowMessage } from "@Packages/message/window_message";
|
||||
import {
|
||||
ResourceClient,
|
||||
ScriptClient,
|
||||
subscribeScriptEnable,
|
||||
subscribeScriptInstall,
|
||||
ValueClient,
|
||||
} from "../service_worker/client";
|
||||
import { SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts";
|
||||
import { disableScript, enableScript } from "../sandbox/client";
|
||||
import { MessageSend } from "@Packages/message/server";
|
||||
import { ResourceClient, ScriptClient, ValueClient } from "../service_worker/client";
|
||||
import { SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL, ScriptRunResouce } from "@App/app/repo/scripts";
|
||||
import { disableScript, enableScript, runScript, stopScript } from "../sandbox/client";
|
||||
import { Group, MessageSend } from "@Packages/message/server";
|
||||
import { subscribeScriptDelete, subscribeScriptEnable, subscribeScriptInstall } from "../queue";
|
||||
|
||||
export class ScriptService {
|
||||
logger: Logger;
|
||||
@@ -21,15 +16,24 @@ export class ScriptService {
|
||||
valueClient: ValueClient = new ValueClient(this.extensionMessage);
|
||||
|
||||
constructor(
|
||||
private group: Group,
|
||||
private extensionMessage: MessageSend,
|
||||
private windowMessage: WindowMessage,
|
||||
private broker: Broker
|
||||
private messageQueue: MessageQueue
|
||||
) {
|
||||
this.logger = LoggerCore.logger().with({ service: "script" });
|
||||
}
|
||||
|
||||
runScript(script: ScriptRunResouce) {
|
||||
runScript(this.windowMessage, script);
|
||||
}
|
||||
|
||||
stopScript(uuid: string) {
|
||||
stopScript(this.windowMessage, uuid);
|
||||
}
|
||||
|
||||
async init() {
|
||||
subscribeScriptEnable(this.broker, async (data) => {
|
||||
subscribeScriptEnable(this.messageQueue, async (data) => {
|
||||
const script = await this.scriptClient.info(data.uuid);
|
||||
if (script.type === SCRIPT_TYPE_NORMAL) {
|
||||
return;
|
||||
@@ -42,7 +46,7 @@ export class ScriptService {
|
||||
disableScript(this.windowMessage, script.uuid);
|
||||
}
|
||||
});
|
||||
subscribeScriptInstall(this.broker, async (data) => {
|
||||
subscribeScriptInstall(this.messageQueue, async (data) => {
|
||||
// 判断是开启还是关闭
|
||||
if (data.script.status === SCRIPT_STATUS_ENABLE) {
|
||||
// 构造脚本运行资源,发送给沙盒运行
|
||||
@@ -52,5 +56,11 @@ export class ScriptService {
|
||||
disableScript(this.windowMessage, data.script.uuid);
|
||||
}
|
||||
});
|
||||
subscribeScriptDelete(this.messageQueue, async (data) => {
|
||||
disableScript(this.windowMessage, data.uuid);
|
||||
});
|
||||
|
||||
this.group.on("runScript", this.runScript.bind(this));
|
||||
this.group.on("stopScript", this.stopScript.bind(this));
|
||||
}
|
||||
}
|
||||
|
59
src/app/service/queue.ts
Normal file
59
src/app/service/queue.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { MessageQueue } from "@Packages/message/message_queue";
|
||||
import { Script, SCRIPT_RUN_STATUS } from "../repo/scripts";
|
||||
import { InstallSource } from "./service_worker";
|
||||
import { Subscribe } from "../repo/subscribe";
|
||||
|
||||
export function subscribeScriptInstall(
|
||||
messageQueue: MessageQueue,
|
||||
callback: (message: { script: Script; update: boolean; upsertBy: InstallSource }) => void
|
||||
) {
|
||||
return messageQueue.subscribe("installScript", callback);
|
||||
}
|
||||
|
||||
export function subscribeScriptDelete(messageQueue: MessageQueue, callback: (message: { uuid: string }) => void) {
|
||||
return messageQueue.subscribe("deleteScript", callback);
|
||||
}
|
||||
|
||||
export function subscribeSubscribeInstall(
|
||||
messageQueue: MessageQueue,
|
||||
callback: (message: { subscribe: Subscribe; update: boolean }) => void
|
||||
) {
|
||||
return messageQueue.subscribe("installSubscribe", callback);
|
||||
}
|
||||
|
||||
export function publishSubscribeInstall(messageQueue: MessageQueue, message: { subscribe: Subscribe }) {
|
||||
return messageQueue.publish("installSubscribe", message);
|
||||
}
|
||||
|
||||
export type ScriptEnableCallbackValue = { uuid: string; enable: boolean };
|
||||
|
||||
export function subscribeScriptEnable(
|
||||
messageQueue: MessageQueue,
|
||||
callback: (message: ScriptEnableCallbackValue) => void
|
||||
) {
|
||||
return messageQueue.subscribe("enableScript", callback);
|
||||
}
|
||||
|
||||
export function subscribeScriptRunStatus(
|
||||
messageQueue: MessageQueue,
|
||||
callback: (message: { uuid: string; runStatus: SCRIPT_RUN_STATUS }) => void
|
||||
) {
|
||||
return messageQueue.subscribe("scriptRunStatus", callback);
|
||||
}
|
||||
|
||||
export type ScriptMenuRegisterCallbackValue = {
|
||||
uuid: string;
|
||||
id: number;
|
||||
name: string;
|
||||
accessKey: string;
|
||||
tabId: number;
|
||||
frameId: number;
|
||||
documentId: string;
|
||||
};
|
||||
|
||||
export function subscribeScriptMenuRegister(
|
||||
messageQueue: MessageQueue,
|
||||
callback: (message: ScriptMenuRegisterCallbackValue) => void
|
||||
) {
|
||||
return messageQueue.subscribe("registerMenuCommand", callback);
|
||||
}
|
@@ -3,9 +3,17 @@ import { sendMessage } from "@Packages/message/client";
|
||||
import { WindowMessage } from "@Packages/message/window_message";
|
||||
|
||||
export function enableScript(msg: WindowMessage, data: ScriptRunResouce) {
|
||||
return sendMessage(msg, "enableScript", data);
|
||||
return sendMessage(msg, "sandbox/enableScript", data);
|
||||
}
|
||||
|
||||
export function disableScript(msg: WindowMessage, uuid: string) {
|
||||
return sendMessage(msg, "disableScript", uuid);
|
||||
return sendMessage(msg, "sandbox/disableScript", uuid);
|
||||
}
|
||||
|
||||
export function runScript(msg: WindowMessage, data: ScriptRunResouce) {
|
||||
return sendMessage(msg, "sandbox/runScript", data);
|
||||
}
|
||||
|
||||
export function stopScript(msg: WindowMessage, uuid: string) {
|
||||
return sendMessage(msg, "sandbox/stopScript", uuid);
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ import { Runtime } from "./runtime";
|
||||
|
||||
// sandbox环境的管理器
|
||||
export class SandboxManager {
|
||||
api: Server = new Server(this.windowMessage);
|
||||
api: Server = new Server("sandbox", this.windowMessage);
|
||||
|
||||
constructor(private windowMessage: WindowMessage) {}
|
||||
|
||||
|
@@ -7,12 +7,14 @@ import {
|
||||
SCRIPT_TYPE_BACKGROUND,
|
||||
ScriptRunResouce,
|
||||
} from "@App/app/repo/scripts";
|
||||
import ExecScript from "@App/runtime/content/exec_script";
|
||||
import { BgExecScriptWarp, CATRetryError } from "@App/runtime/content/exec_warp";
|
||||
import { Server } from "@Packages/message/server";
|
||||
import { WindowMessage } from "@Packages/message/window_message";
|
||||
import { CronJob } from "cron";
|
||||
import { proxyUpdateRunStatus } from "../offscreen/client";
|
||||
import { BgExecScriptWarp } from "../content/exec_warp";
|
||||
import ExecScript, { ValueUpdateData } from "../content/exec_script";
|
||||
import { getStorageName } from "@App/pkg/utils/utils";
|
||||
import { EmitEventRequest } from "../service_worker/runtime";
|
||||
|
||||
export class Runtime {
|
||||
cronJob: Map<string, Array<CronJob>> = new Map();
|
||||
@@ -83,6 +85,7 @@ export class Runtime {
|
||||
return this.execScript(script);
|
||||
} else {
|
||||
// 定时脚本加入定时任务
|
||||
await this.stopCronJob(script.uuid);
|
||||
return this.crontabScript(script);
|
||||
}
|
||||
}
|
||||
@@ -158,7 +161,7 @@ export class Runtime {
|
||||
crontabScript(script: ScriptRunResouce) {
|
||||
// 执行定时脚本 运行表达式
|
||||
if (!script.metadata.crontab) {
|
||||
throw new Error("错误的crontab表达式");
|
||||
throw new Error(script.name + " - 错误的crontab表达式");
|
||||
}
|
||||
// 如果有nextruntime,则加入重试队列
|
||||
this.joinRetryList(script);
|
||||
@@ -203,7 +206,7 @@ export class Runtime {
|
||||
} else {
|
||||
this.cronJob.set(script.uuid, cronJobList);
|
||||
}
|
||||
return Promise.resolve(!flag);
|
||||
return !flag;
|
||||
}
|
||||
|
||||
crontabExec(script: ScriptRunResouce, oncePos: number) {
|
||||
@@ -268,18 +271,51 @@ export class Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
stopScript(uuid: string) {
|
||||
async stopScript(uuid: string) {
|
||||
const exec = this.execScripts.get(uuid);
|
||||
if (!exec) {
|
||||
proxyUpdateRunStatus(this.windowMessage, { uuid: uuid, runStatus: SCRIPT_RUN_STATUS_COMPLETE });
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
exec.stop();
|
||||
this.execScripts.delete(uuid);
|
||||
proxyUpdateRunStatus(this.windowMessage, { uuid: uuid, runStatus: SCRIPT_RUN_STATUS_COMPLETE });
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async runScript(script: ScriptRunResouce) {
|
||||
const exec = this.execScripts.get(script.uuid);
|
||||
// 如果正在运行,先释放
|
||||
if (exec) {
|
||||
await this.stopScript(script.uuid);
|
||||
}
|
||||
return this.execScript(script, true);
|
||||
}
|
||||
|
||||
valueUpdate(data: ValueUpdateData) {
|
||||
// 转发给脚本
|
||||
this.execScripts.forEach((val) => {
|
||||
if (val.scriptRes.uuid === data.uuid || getStorageName(val.scriptRes) === data.storageName) {
|
||||
val.valueUpdate(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
emitEvent(data: EmitEventRequest) {
|
||||
// 转发给脚本
|
||||
const exec = this.execScripts.get(data.uuid);
|
||||
if (exec) {
|
||||
exec.emitEvent(data.event, data.eventId, data.data);
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.api.on("enableScript", this.enableScript.bind(this));
|
||||
this.api.on("disableScript", this.disableScript.bind(this));
|
||||
this.api.on("runScript", this.runScript.bind(this));
|
||||
this.api.on("stopScript", this.stopScript.bind(this));
|
||||
|
||||
this.api.on("runtime/valueUpdate", this.valueUpdate.bind(this));
|
||||
this.api.on("runtime/emitEvent", this.emitEvent.bind(this));
|
||||
}
|
||||
}
|
||||
|
@@ -1,13 +1,20 @@
|
||||
import { Script, ScriptCode, ScriptRunResouce } from "@App/app/repo/scripts";
|
||||
import { Client } from "@Packages/message/client";
|
||||
import { InstallSource } from ".";
|
||||
import { Broker } from "@Packages/message/message_queue";
|
||||
import { Resource } from "@App/app/repo/resource";
|
||||
import { MessageSend } from "@Packages/message/server";
|
||||
import { ScriptMenu, ScriptMenuItem } from "./popup";
|
||||
import PermissionVerify, { ConfirmParam, UserConfirm } from "./permission_verify";
|
||||
import { FileSystemType } from "@Packages/filesystem/factory";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import Cache from "@App/app/cache";
|
||||
import CacheKey from "@App/app/cache_key";
|
||||
import { Subscribe } from "@App/app/repo/subscribe";
|
||||
import { Permission } from "@App/app/repo/permission";
|
||||
|
||||
export class ServiceWorkerClient extends Client {
|
||||
constructor(msg: MessageSend) {
|
||||
super(msg);
|
||||
super(msg, "serviceWorker");
|
||||
}
|
||||
|
||||
preparationOffscreen() {
|
||||
@@ -17,7 +24,7 @@ export class ServiceWorkerClient extends Client {
|
||||
|
||||
export class ScriptClient extends Client {
|
||||
constructor(msg: MessageSend) {
|
||||
super(msg, "script");
|
||||
super(msg, "serviceWorker/script");
|
||||
}
|
||||
|
||||
// 获取安装信息
|
||||
@@ -25,7 +32,7 @@ export class ScriptClient extends Client {
|
||||
return this.do("getInstallInfo", uuid);
|
||||
}
|
||||
|
||||
install(script: Script, code: string, upsertBy: InstallSource = "user") {
|
||||
install(script: Script, code: string, upsertBy: InstallSource = "user"): Promise<{ update: boolean }> {
|
||||
return this.do("install", { script, code, upsertBy });
|
||||
}
|
||||
|
||||
@@ -48,41 +55,187 @@ export class ScriptClient extends Client {
|
||||
getScriptRunResource(script: Script): Promise<ScriptRunResouce> {
|
||||
return this.do("getScriptRunResource", script);
|
||||
}
|
||||
|
||||
excludeUrl(uuid: string, url: string, remove: boolean) {
|
||||
return this.do("excludeUrl", { uuid, url, remove });
|
||||
}
|
||||
|
||||
// 重置匹配项
|
||||
resetMatch(uuid: string, match: string[] | undefined) {
|
||||
return this.do("resetMatch", { uuid, match });
|
||||
}
|
||||
|
||||
// 重置排除项
|
||||
resetExclude(uuid: string, exclude: string[] | undefined) {
|
||||
return this.do("resetExclude", { uuid, exclude });
|
||||
}
|
||||
|
||||
requestCheckUpdate(uuid: string) {
|
||||
return this.do("requestCheckUpdate", uuid);
|
||||
}
|
||||
}
|
||||
|
||||
export class ResourceClient extends Client {
|
||||
constructor(msg: MessageSend) {
|
||||
super(msg, "resource");
|
||||
super(msg, "serviceWorker/resource");
|
||||
}
|
||||
|
||||
getScriptResources(script: Script): Promise<{ [key: string]: Resource }> {
|
||||
return this.do("getScriptResources", script);
|
||||
}
|
||||
|
||||
deleteResource(url: string) {
|
||||
return this.do("deleteResource", url);
|
||||
}
|
||||
}
|
||||
|
||||
export class ValueClient extends Client {
|
||||
constructor(msg: MessageSend) {
|
||||
super(msg, "value");
|
||||
super(msg, "serviceWorker/value");
|
||||
}
|
||||
|
||||
getScriptValue(script: Script) {
|
||||
getScriptValue(script: Script): Promise<{ [key: string]: any }> {
|
||||
return this.do("getScriptValue", script);
|
||||
}
|
||||
|
||||
setScriptValue(uuid: string, key: string, value: any) {
|
||||
return this.do("setScriptValue", { uuid, key, value });
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeScriptInstall(
|
||||
border: Broker,
|
||||
callback: (message: { script: Script; update: boolean }) => void
|
||||
) {
|
||||
return border.subscribe("installScript", callback);
|
||||
export class RuntimeClient extends Client {
|
||||
constructor(msg: MessageSend) {
|
||||
super(msg, "serviceWorker/runtime");
|
||||
}
|
||||
|
||||
runScript(uuid: string) {
|
||||
return this.do("runScript", uuid);
|
||||
}
|
||||
|
||||
stopScript(uuid: string) {
|
||||
return this.do("stopScript", uuid);
|
||||
}
|
||||
|
||||
pageLoad(): Promise<{ flag: string; scripts: ScriptRunResouce[] }> {
|
||||
return this.do("pageLoad");
|
||||
}
|
||||
|
||||
scriptLoad(flag: string, uuid: string) {
|
||||
return this.do("scriptLoad", { flag, uuid });
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeScriptDelete(border: Broker, callback: (message: { uuid: string }) => void) {
|
||||
return border.subscribe("deleteScript", callback);
|
||||
export type GetPopupDataReq = {
|
||||
tabId: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type GetPopupDataRes = {
|
||||
scriptList: ScriptMenu[];
|
||||
backScriptList: ScriptMenu[];
|
||||
};
|
||||
|
||||
export class PopupClient extends Client {
|
||||
constructor(msg: MessageSend) {
|
||||
super(msg, "serviceWorker/popup");
|
||||
}
|
||||
|
||||
getPopupData(data: GetPopupDataReq): Promise<GetPopupDataRes> {
|
||||
return this.do("getPopupData", data);
|
||||
}
|
||||
|
||||
menuClick(uuid: string, data: ScriptMenuItem) {
|
||||
return this.do("menuClick", {
|
||||
uuid,
|
||||
id: data.id,
|
||||
sender: {
|
||||
tabId: data.tabId,
|
||||
frameId: data.frameId,
|
||||
documentId: data.documentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type ScriptEnableCallbackValue = { uuid: string; enable: boolean };
|
||||
export class PermissionClient extends Client {
|
||||
constructor(msg: MessageSend) {
|
||||
super(msg, "serviceWorker/runtime/permission");
|
||||
}
|
||||
|
||||
export function subscribeScriptEnable(border: Broker, callback: (message: ScriptEnableCallbackValue) => void) {
|
||||
return border.subscribe("enableScript", callback);
|
||||
confirm(uuid: string, userConfirm: UserConfirm): Promise<void> {
|
||||
return this.do("confirm", { uuid, userConfirm });
|
||||
}
|
||||
|
||||
getPermissionInfo(uuid: string): ReturnType<PermissionVerify["getInfo"]> {
|
||||
return this.do("getInfo", uuid);
|
||||
}
|
||||
|
||||
deletePermission(uuid: string, permission: string, permissionValue: string) {
|
||||
return this.do("deletePermission", { uuid, permission, permissionValue });
|
||||
}
|
||||
|
||||
getScriptPermissions(uuid: string): ReturnType<PermissionVerify["getScriptPermissions"]> {
|
||||
return this.do("getScriptPermissions", uuid);
|
||||
}
|
||||
|
||||
addPermission(permission: Permission) {
|
||||
return this.do("addPermission", permission);
|
||||
}
|
||||
|
||||
resetPermission(uuid: string) {
|
||||
return this.do("resetPermission", uuid);
|
||||
}
|
||||
}
|
||||
|
||||
export class SynchronizeClient extends Client {
|
||||
constructor(msg: MessageSend) {
|
||||
super(msg, "serviceWorker/synchronize");
|
||||
}
|
||||
|
||||
export(uuids?: string[]) {
|
||||
return this.do("export", uuids);
|
||||
}
|
||||
|
||||
backupToCloud(type: FileSystemType, params: any) {
|
||||
return this.do("backupToCloud", { type, params });
|
||||
}
|
||||
|
||||
async openImportWindow(filename: string, file: File | Blob) {
|
||||
// 打开导入窗口,用cache实现数据交互
|
||||
const url = URL.createObjectURL(file);
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 60 * 1000);
|
||||
const uuid = uuidv4();
|
||||
await Cache.getInstance().set(CacheKey.importFile(uuid), {
|
||||
filename: filename,
|
||||
url: url,
|
||||
});
|
||||
// 打开导入窗口,用cache实现数据交互
|
||||
chrome.tabs.create({
|
||||
url: `/src/import.html?uuid=${uuid}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SubscribeClient extends Client {
|
||||
constructor(msg: MessageSend) {
|
||||
super(msg, "serviceWorker/subscribe");
|
||||
}
|
||||
|
||||
install(subscribe: Subscribe) {
|
||||
return this.do("install", { subscribe });
|
||||
}
|
||||
|
||||
delete(url: string) {
|
||||
return this.do("delete", { url });
|
||||
}
|
||||
|
||||
checkUpdate(url: string) {
|
||||
return this.do("checkUpdate", { url });
|
||||
}
|
||||
|
||||
enable(url: string, enable: boolean) {
|
||||
return this.do("enable", { url, enable });
|
||||
}
|
||||
}
|
||||
|
@@ -1,13 +1,22 @@
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import Logger from "@App/app/logger/logger";
|
||||
import { Script, ScriptDAO } from "@App/app/repo/scripts";
|
||||
import { Group, MessageConnect, MessageSender } from "@Packages/message/server";
|
||||
import { ExtMessageSender, GetSender, Group, MessageSend } from "@Packages/message/server";
|
||||
import { ValueService } from "@App/app/service/service_worker/value";
|
||||
import PermissionVerify from "./permission_verify";
|
||||
import { ServiceWorkerMessageSend } from "@Packages/message/window_message";
|
||||
import PermissionVerify, { ConfirmParam } from "./permission_verify";
|
||||
import { connect, sendMessage } from "@Packages/message/client";
|
||||
import Cache, { incr } from "@App/app/cache";
|
||||
import { unsafeHeaders } from "@App/runtime/utils";
|
||||
import EventEmitter from "eventemitter3";
|
||||
import { MessageQueue } from "@Packages/message/message_queue";
|
||||
import { RuntimeService } from "./runtime";
|
||||
import { getIcon, isFirefox } from "@App/pkg/utils/utils";
|
||||
import { MockMessageConnect } from "@Packages/message/mock_message";
|
||||
import i18next, { i18nName } from "@App/locales/locales";
|
||||
import { SystemConfig } from "@App/pkg/config/config";
|
||||
import FileSystemFactory from "@Packages/filesystem/factory";
|
||||
import FileSystem from "@Packages/filesystem/filesystem";
|
||||
import { isWarpTokenError } from "@Packages/filesystem/error";
|
||||
import { joinPath } from "@Packages/filesystem/utils";
|
||||
|
||||
// GMApi,处理脚本的GM API调用请求
|
||||
|
||||
@@ -20,87 +29,397 @@ export type MessageRequest = {
|
||||
|
||||
export type Request = MessageRequest & {
|
||||
script: Script;
|
||||
sender: MessageSender;
|
||||
};
|
||||
|
||||
export type Api = (request: Request, con: MessageConnect | null) => Promise<any>;
|
||||
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,
|
||||
};
|
||||
|
||||
type NotificationData = {
|
||||
uuid: string;
|
||||
details: GMTypes.NotificationDetails;
|
||||
sender: ExtMessageSender;
|
||||
};
|
||||
|
||||
export type Api = (request: Request, con: GetSender) => Promise<any>;
|
||||
|
||||
export default class GMApi {
|
||||
logger: Logger;
|
||||
|
||||
scriptDAO: ScriptDAO = new ScriptDAO();
|
||||
|
||||
permissionVerify: PermissionVerify = new PermissionVerify();
|
||||
|
||||
constructor(
|
||||
private systemConfig: SystemConfig,
|
||||
private permissionVerify: PermissionVerify,
|
||||
private group: Group,
|
||||
private sender: ServiceWorkerMessageSend,
|
||||
private value: ValueService
|
||||
private send: MessageSend,
|
||||
private mq: MessageQueue,
|
||||
private value: ValueService,
|
||||
private runtime: RuntimeService
|
||||
) {
|
||||
this.logger = LoggerCore.logger().with({ service: "runtime/gm_api" });
|
||||
}
|
||||
|
||||
async handlerRequest(data: MessageRequest, con: MessageConnect | null) {
|
||||
async handlerRequest(data: MessageRequest, sender: GetSender) {
|
||||
this.logger.trace("GM API request", { api: data.api, uuid: data.uuid, param: data.params });
|
||||
const api = PermissionVerify.apis.get(data.api);
|
||||
if (!api) {
|
||||
return Promise.reject(new Error("api is not found"));
|
||||
throw new Error("gm api is not found");
|
||||
}
|
||||
const req = await this.parseRequest(data, { tabId: 0 });
|
||||
const req = await this.parseRequest(data);
|
||||
try {
|
||||
await this.permissionVerify.verify(req, api);
|
||||
} catch (e) {
|
||||
this.logger.error("verify error", { api: data.api }, Logger.E(e));
|
||||
return Promise.reject(e);
|
||||
throw e;
|
||||
}
|
||||
return api.api.call(this, req, con);
|
||||
return api.api.call(this, req, sender);
|
||||
}
|
||||
|
||||
// 解析请求
|
||||
async parseRequest(data: MessageRequest, sender: MessageSender): Promise<Request> {
|
||||
async parseRequest(data: MessageRequest): Promise<Request> {
|
||||
const script = await this.scriptDAO.get(data.uuid);
|
||||
if (!script) {
|
||||
return Promise.reject(new Error("script is not found"));
|
||||
throw new Error("script is not found");
|
||||
}
|
||||
const req: Request = <Request>data;
|
||||
req.script = script;
|
||||
req.sender = sender;
|
||||
return Promise.resolve(req);
|
||||
return req;
|
||||
}
|
||||
|
||||
@PermissionVerify.API({
|
||||
confirm(request: Request) {
|
||||
if (request.params[0] === "store") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const detail = <GMTypes.CookieDetails>request.params[1];
|
||||
if (!detail.url && !detail.domain) {
|
||||
return Promise.reject(new Error("there must be one of url or domain"));
|
||||
}
|
||||
let url: 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: "",
|
||||
});
|
||||
},
|
||||
})
|
||||
async GM_cookie(request: Request, sender: GetSender) {
|
||||
const param = request.params;
|
||||
if (param.length !== 2) {
|
||||
throw new Error("there must be two parameters");
|
||||
}
|
||||
const detail = <GMTypes.CookieDetails>request.params[1];
|
||||
// url或者域名不能为空
|
||||
if (detail.url) {
|
||||
detail.url = detail.url.trim();
|
||||
}
|
||||
if (detail.domain) {
|
||||
detail.domain = detail.domain.trim();
|
||||
}
|
||||
if (!detail.url && !detail.domain) {
|
||||
throw new Error("there must be one of url or domain");
|
||||
}
|
||||
// 处理tab的storeid
|
||||
let tabId = sender.getExtMessageSender().tabId;
|
||||
let storeId: string | undefined;
|
||||
if (tabId !== -1) {
|
||||
const stores = await chrome.cookies.getAllCookieStores();
|
||||
const store = stores.find((val) => val.tabIds.includes(tabId));
|
||||
if (store) {
|
||||
storeId = store.id;
|
||||
}
|
||||
}
|
||||
switch (param[0]) {
|
||||
case "list": {
|
||||
return chrome.cookies.getAll({
|
||||
domain: detail.domain,
|
||||
name: detail.name,
|
||||
path: detail.path,
|
||||
secure: detail.secure,
|
||||
session: detail.session,
|
||||
url: detail.url,
|
||||
storeId: storeId,
|
||||
});
|
||||
}
|
||||
case "delete": {
|
||||
if (!detail.url || !detail.name) {
|
||||
throw new Error("delete operation must have url and name");
|
||||
}
|
||||
await chrome.cookies.remove({
|
||||
name: detail.name,
|
||||
url: detail.url,
|
||||
storeId: storeId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "set": {
|
||||
if (!detail.url || !detail.name) {
|
||||
throw new Error("set operation must have name and value");
|
||||
}
|
||||
await 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: storeId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error("action can only be: get, set, delete, store");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PermissionVerify.API()
|
||||
GM_setValue(request: Request): Promise<any> {
|
||||
if (!request.params || request.params.length !== 2) {
|
||||
return Promise.reject(new Error("param is failed"));
|
||||
GM_log(request: Request): Promise<boolean> {
|
||||
const message = request.params[0];
|
||||
const level = request.params[1] || "info";
|
||||
const labels = request.params[2] || {};
|
||||
LoggerCore.logger(labels).log(level, message, {
|
||||
uuid: request.uuid,
|
||||
name: request.script.name,
|
||||
component: "GM_log",
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@PermissionVerify.API()
|
||||
async GM_setValue(request: Request, sender: GetSender) {
|
||||
if (!request.params || request.params.length < 1) {
|
||||
throw new Error("param is failed");
|
||||
}
|
||||
const [key, value] = request.params;
|
||||
const sender = <MessageSender & { runFlag: string }>request.sender;
|
||||
sender.runFlag = request.runFlag;
|
||||
return this.value.setValue(request.script.uuid, key, value);
|
||||
await this.value.setValue(request.script.uuid, key, value, {
|
||||
runFlag: request.runFlag,
|
||||
tabId: sender.getSender().tab?.id,
|
||||
});
|
||||
}
|
||||
|
||||
@PermissionVerify.API()
|
||||
CAT_userConfig(request: Request) {
|
||||
chrome.tabs.create({
|
||||
url: `/src/options.html#/?userConfig=${request.uuid}`,
|
||||
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);
|
||||
},
|
||||
})
|
||||
async CAT_fileStorage(request: Request, sender: GetSender): Promise<{ action: string; data: any } | boolean> {
|
||||
const [action, details] = request.params;
|
||||
if (action === "config") {
|
||||
chrome.tabs.create({
|
||||
url: `/src/options.html#/setting`,
|
||||
active: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const fsConfig = await this.systemConfig.getCatFileStorage();
|
||||
if (fsConfig.status === "unset") {
|
||||
return { action: "error", data: { code: 1, error: "file storage is unset" } };
|
||||
}
|
||||
if (fsConfig.status === "error") {
|
||||
return { action: "error", data: { code: 2, error: "file storage 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.setCatFileStorage(fsConfig);
|
||||
return { action: "error", data: { code: 2, error: e.error.message } };
|
||||
}
|
||||
return { action: "error", data: { code: 8, error: e.message } };
|
||||
}
|
||||
switch (action) {
|
||||
case "list":
|
||||
try {
|
||||
const list = await fs.list();
|
||||
list.forEach((file) => {
|
||||
(<any>file).absPath = file.path;
|
||||
file.path = joinPath(file.path.substring(file.path.indexOf(baseDir) + baseDir.length));
|
||||
});
|
||||
return { action: "onload", data: list };
|
||||
} catch (e: any) {
|
||||
return { action: "error", data: { code: 3, error: e.message } };
|
||||
}
|
||||
case "upload":
|
||||
try {
|
||||
const w = await fs.create(details.path);
|
||||
await w.write(await (await fetch(<string>details.data)).blob());
|
||||
return { action: "onload", data: true };
|
||||
} catch (e: any) {
|
||||
return { action: "error", data: { code: 4, error: e.message } };
|
||||
}
|
||||
case "download":
|
||||
try {
|
||||
const info = <CATType.FileStorageFileInfo>details.file;
|
||||
fs = await fs.openDir(`${info.path}`);
|
||||
const r = await fs.open({
|
||||
fsid: (<any>info).fsid,
|
||||
name: info.name,
|
||||
path: info.absPath,
|
||||
size: info.size,
|
||||
digest: info.digest,
|
||||
createtime: info.createtime,
|
||||
updatetime: info.updatetime,
|
||||
});
|
||||
const blob = await r.read("blob");
|
||||
const url = URL.createObjectURL(blob);
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 30 * 1000);
|
||||
return { action: "onload", data: url };
|
||||
} catch (e: any) {
|
||||
return { action: "error", data: { code: 5, error: e.message } };
|
||||
}
|
||||
break;
|
||||
case "delete":
|
||||
try {
|
||||
await fs.delete(`${details.path}`);
|
||||
return { action: "onload", data: true };
|
||||
} catch (e: any) {
|
||||
return { action: "error", data: { code: 6, error: e.message } };
|
||||
}
|
||||
default:
|
||||
throw new Error("action is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
// 根据header生成dnr规则
|
||||
async buildDNRRule(params: GMSend.XHRDetails) {
|
||||
async buildDNRRule(reqeustId: number, params: GMSend.XHRDetails): Promise<{ [key: string]: string }> {
|
||||
// 检查是否有unsafe header,有则生成dnr规则
|
||||
const headers = params.headers;
|
||||
if (!headers) {
|
||||
return;
|
||||
return {};
|
||||
}
|
||||
const requestHeaders = [] as chrome.declarativeNetRequest.ModifyHeaderInfo[];
|
||||
const requestHeaders = [
|
||||
{
|
||||
header: "X-Scriptcat-GM-XHR-Request-Id",
|
||||
operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE,
|
||||
},
|
||||
] as chrome.declarativeNetRequest.ModifyHeaderInfo[];
|
||||
Object.keys(headers).forEach((key) => {
|
||||
const lowKey = key.toLowerCase();
|
||||
if (unsafeHeaders[lowKey] || lowKey.startsWith("sec-") || lowKey.startsWith("proxy-")) {
|
||||
if (headers[key]) {
|
||||
if (unsafeHeaders[lowKey] || lowKey.startsWith("sec-") || lowKey.startsWith("proxy-")) {
|
||||
if (headers[key]) {
|
||||
requestHeaders.push({
|
||||
header: key,
|
||||
operation: chrome.declarativeNetRequest.HeaderOperation.SET,
|
||||
value: headers[key],
|
||||
});
|
||||
}
|
||||
delete headers[key];
|
||||
}
|
||||
} else {
|
||||
requestHeaders.push({
|
||||
header: key,
|
||||
operation: chrome.declarativeNetRequest.HeaderOperation.SET,
|
||||
value: headers[key],
|
||||
operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE,
|
||||
});
|
||||
delete headers[key];
|
||||
}
|
||||
});
|
||||
if (requestHeaders.length === 0) {
|
||||
return;
|
||||
// 判断是否是anonymous
|
||||
if (params.anonymous) {
|
||||
// 如果是anonymous,并且有cookie,则设置为自定义的cookie
|
||||
if (params.cookie) {
|
||||
requestHeaders.push({
|
||||
header: "cookie",
|
||||
operation: chrome.declarativeNetRequest.HeaderOperation.SET,
|
||||
value: params.cookie,
|
||||
});
|
||||
} else {
|
||||
// 否则删除cookie
|
||||
requestHeaders.push({
|
||||
header: "cookie",
|
||||
operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE,
|
||||
});
|
||||
}
|
||||
} else if (params.cookie) {
|
||||
// 否则正常携带cookie header
|
||||
headers["cookie"] = params.cookie;
|
||||
}
|
||||
const ruleId = 1000 + (await incr(Cache.getInstance(), "dnrRuleId", 1));
|
||||
const ruleId = reqeustId;
|
||||
const rule = {} as chrome.declarativeNetRequest.Rule;
|
||||
rule.id = ruleId;
|
||||
rule.action = {
|
||||
@@ -117,31 +436,482 @@ export default class GMApi {
|
||||
});
|
||||
rule.condition = {
|
||||
resourceTypes: [chrome.declarativeNetRequest.ResourceType.XMLHTTPREQUEST],
|
||||
urlFilter: "^" + params.url + "$",
|
||||
urlFilter: params.url,
|
||||
requestMethods: [(params.method || "GET").toLowerCase() as chrome.declarativeNetRequest.RequestMethod],
|
||||
excludedTabIds: excludedTabIds,
|
||||
};
|
||||
return chrome.declarativeNetRequest.updateSessionRules({
|
||||
await chrome.declarativeNetRequest.updateSessionRules({
|
||||
removeRuleIds: [ruleId],
|
||||
addRules: [rule],
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
gmXhrHeadersReceived: EventEmitter = new EventEmitter();
|
||||
|
||||
dealFetch(config: GMSend.XHRDetails, response: Response, readyState: 0 | 1 | 2 | 3 | 4) {
|
||||
let respHeader = "";
|
||||
response.headers.forEach((value, key) => {
|
||||
respHeader += `${key}: ${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;
|
||||
}
|
||||
|
||||
CAT_fetch(config: GMSend.XHRDetails, con: GetSender, resultParam: { requestId: number; responseHeader: string }) {
|
||||
const { url } = config;
|
||||
let connect = con.getConnect();
|
||||
return fetch(url, {
|
||||
method: config.method || "GET",
|
||||
body: <any>config.data,
|
||||
headers: config.headers,
|
||||
}).then((resp) => {
|
||||
const send = this.dealFetch(config, resp, 1);
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("read is not found");
|
||||
}
|
||||
const _this = this;
|
||||
reader.read().then(function read({ done, value }) {
|
||||
if (done) {
|
||||
const data = _this.dealFetch(config, resp, 4);
|
||||
data.responseHeaders = resultParam.responseHeader || data.responseHeaders;
|
||||
connect.sendMessage({
|
||||
action: "onreadystatechange",
|
||||
data: data,
|
||||
});
|
||||
connect.sendMessage({
|
||||
action: "onload",
|
||||
data: data,
|
||||
});
|
||||
connect.sendMessage({
|
||||
action: "onloadend",
|
||||
data: data,
|
||||
});
|
||||
} else {
|
||||
connect.sendMessage({
|
||||
action: "onstream",
|
||||
data: Array.from(value),
|
||||
});
|
||||
reader.read().then(read);
|
||||
}
|
||||
});
|
||||
send.responseHeaders = resultParam.responseHeader || send.responseHeaders;
|
||||
connect.sendMessage({
|
||||
action: "onloadstart",
|
||||
data: send,
|
||||
});
|
||||
send.readyState = 2;
|
||||
connect.sendMessage({
|
||||
action: "onreadystatechange",
|
||||
data: send,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@PermissionVerify.API({
|
||||
confirm: async (request: Request) => {
|
||||
const config = <GMSend.XHRDetails>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);
|
||||
},
|
||||
})
|
||||
async GM_xmlhttpRequest(request: Request, sender: GetSender) {
|
||||
if (request.params.length === 0) {
|
||||
throw new Error("param is failed");
|
||||
}
|
||||
const params = request.params[0] as GMSend.XHRDetails;
|
||||
// 先处理unsafe hearder
|
||||
// 关联自己生成的请求id与chrome.webRequest的请求id
|
||||
const requestId = 10000 + (await incr(Cache.getInstance(), "gmXhrRequestId", 1));
|
||||
// 添加请求header
|
||||
if (!params.headers) {
|
||||
params.headers = {};
|
||||
}
|
||||
params.headers["X-Scriptcat-GM-XHR-Request-Id"] = requestId.toString();
|
||||
params.headers = await this.buildDNRRule(requestId, request.params[0]);
|
||||
let resultParam = {
|
||||
requestId,
|
||||
responseHeader: "",
|
||||
};
|
||||
// 等待response
|
||||
this.gmXhrHeadersReceived.addListener(
|
||||
"headersReceived:" + requestId,
|
||||
(details: chrome.webRequest.WebResponseHeadersDetails) => {
|
||||
details.responseHeaders?.forEach((header) => {
|
||||
resultParam.responseHeader += header.name + ": " + header.value + "\n";
|
||||
});
|
||||
this.gmXhrHeadersReceived.removeAllListeners("headersReceived:" + requestId);
|
||||
}
|
||||
);
|
||||
if (params.responseType === "stream" || params.fetch || params.redirect) {
|
||||
// 只有fetch支持ReadableStream、redirect这些,直接使用fetch
|
||||
return this.CAT_fetch(params, sender, resultParam);
|
||||
}
|
||||
// 再发送到offscreen, 处理请求
|
||||
const offscreenCon = await connect(this.send, "offscreen/gmApi/xmlHttpRequest", request.params[0]);
|
||||
offscreenCon.onMessage((msg: { action: string; data: any }) => {
|
||||
// 发送到content
|
||||
// 替换msg.data.responseHeaders
|
||||
msg.data.responseHeaders = resultParam.responseHeader || msg.data.responseHeaders;
|
||||
sender.getConnect().sendMessage(msg);
|
||||
});
|
||||
sender.getConnect().onDisconnect(() => {
|
||||
// 关闭连接
|
||||
offscreenCon.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
@PermissionVerify.API()
|
||||
async GM_xmlhttpRequest(request: Request, con: MessageConnect) {
|
||||
console.log("xml request", request, con);
|
||||
// 先处理unsafe hearder
|
||||
await this.buildDNRRule(request.params[0]);
|
||||
// 再发送到offscreen, 处理请求
|
||||
connect(this.sender, "gmApi/xmlHttpRequest", request.params);
|
||||
GM_registerMenuCommand(request: Request, sender: GetSender) {
|
||||
const [id, name, accessKey] = request.params;
|
||||
// 触发菜单注册, 在popup中处理
|
||||
this.mq.emit("registerMenuCommand", {
|
||||
uuid: request.script.uuid,
|
||||
id: id,
|
||||
name: name,
|
||||
accessKey: accessKey,
|
||||
tabId: sender.getSender().tab?.id || -1,
|
||||
frameId: sender.getSender().frameId,
|
||||
documentId: sender.getSender().documentId,
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
this.group.on("gmApi", this.handlerRequest.bind(this));
|
||||
@PermissionVerify.API()
|
||||
GM_unregisterMenuCommand(request: Request, sender: GetSender) {
|
||||
const [id] = request.params;
|
||||
// 触发菜单取消注册, 在popup中处理
|
||||
this.mq.emit("unregisterMenuCommand", {
|
||||
uuid: request.script.uuid,
|
||||
id: id,
|
||||
tabId: sender.getSender().tab?.id || -1,
|
||||
frameId: sender.getSender().frameId,
|
||||
});
|
||||
}
|
||||
|
||||
// 处理收到的header
|
||||
@PermissionVerify.API({})
|
||||
async GM_openInTab(request: Request, sender: GetSender) {
|
||||
const url = request.params[0];
|
||||
const options = request.params[1] || {};
|
||||
if (options.useOpen === true) {
|
||||
// 发送给offscreen页面处理
|
||||
const ok = await sendMessage(this.send, "offscreen/gmApi/openInTab", { url });
|
||||
if (ok) {
|
||||
// 由于window.open强制在前台打开标签,因此获取状态为{ active:true }的标签即为新标签
|
||||
const [tab] = await chrome.tabs.query({ active: true });
|
||||
await Cache.getInstance().set(`GM_openInTab:${tab.id}`, {
|
||||
uuid: request.uuid,
|
||||
sender: sender.getExtMessageSender(),
|
||||
});
|
||||
return tab.id;
|
||||
} else {
|
||||
// 当新tab被浏览器阻止时window.open()会返回null 视为已经关闭
|
||||
// 似乎在Firefox中禁止在background页面使用window.open(),强制返回null
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const tab = await chrome.tabs.create({ url, active: options.active });
|
||||
await Cache.getInstance().set(`GM_openInTab:${tab.id}`, {
|
||||
uuid: request.uuid,
|
||||
sender: sender.getExtMessageSender(),
|
||||
});
|
||||
return tab.id;
|
||||
}
|
||||
}
|
||||
|
||||
@PermissionVerify.API({
|
||||
link: "GM_openInTab",
|
||||
})
|
||||
async GM_closeInTab(request: Request): Promise<boolean> {
|
||||
try {
|
||||
await chrome.tabs.remove(<number>request.params[0]);
|
||||
} catch (e) {
|
||||
this.logger.error("GM_closeInTab", Logger.E(e));
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@PermissionVerify.API({})
|
||||
GM_getTab(request: Request, sender: GetSender) {
|
||||
return Cache.getInstance()
|
||||
.tx(`GM_getTab:${request.uuid}`, async (tabData: { [key: number]: any }) => {
|
||||
return tabData || {};
|
||||
})
|
||||
.then((data) => {
|
||||
return data[sender.getExtMessageSender().tabId];
|
||||
});
|
||||
}
|
||||
|
||||
@PermissionVerify.API()
|
||||
GM_saveTab(request: Request, sender: GetSender) {
|
||||
const data = request.params[0];
|
||||
const tabId = sender.getExtMessageSender().tabId;
|
||||
return Cache.getInstance()
|
||||
.tx(`GM_getTab:${request.uuid}`, (tabData: { [key: number]: any }) => {
|
||||
tabData = tabData || {};
|
||||
tabData[tabId] = data;
|
||||
return Promise.resolve(tabData);
|
||||
})
|
||||
.then(() => true);
|
||||
}
|
||||
|
||||
@PermissionVerify.API()
|
||||
GM_getTabs(request: Request) {
|
||||
return Cache.getInstance().tx(`GM_getTab:${request.uuid}`, async (tabData: { [key: number]: any }) => {
|
||||
return tabData || {};
|
||||
});
|
||||
}
|
||||
|
||||
@PermissionVerify.API({})
|
||||
GM_notification(request: Request, sender: GetSender) {
|
||||
if (request.params.length === 0) {
|
||||
throw new Error("param is failed");
|
||||
}
|
||||
const details: GMTypes.NotificationDetails = request.params[0];
|
||||
const options: chrome.notifications.NotificationOptions<true> = {
|
||||
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;
|
||||
}
|
||||
options.progress = options.progress && parseInt(details.progress as any, 10);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
chrome.notifications.create(options, (notificationId) => {
|
||||
Cache.getInstance().set(`GM_notification:${notificationId}`, {
|
||||
uuid: request.script.uuid,
|
||||
details: details,
|
||||
sender: sender.getExtMessageSender(),
|
||||
});
|
||||
if (details.timeout) {
|
||||
setTimeout(() => {
|
||||
chrome.notifications.clear(notificationId);
|
||||
Cache.getInstance().del(`GM_notification:${notificationId}`);
|
||||
}, details.timeout);
|
||||
}
|
||||
resolve(notificationId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@PermissionVerify.API({
|
||||
link: "GM_notification",
|
||||
})
|
||||
GM_closeNotification(request: Request) {
|
||||
if (request.params.length === 0) {
|
||||
throw new Error("param is failed");
|
||||
}
|
||||
const [notificationId] = request.params;
|
||||
Cache.getInstance().del(`GM_notification:${notificationId}`);
|
||||
chrome.notifications.clear(notificationId);
|
||||
}
|
||||
|
||||
@PermissionVerify.API({
|
||||
link: "GM_notification",
|
||||
})
|
||||
GM_updateNotification(request: Request) {
|
||||
if (isFirefox()) {
|
||||
throw 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 && parseInt(details.progress as any, 10),
|
||||
};
|
||||
chrome.notifications.update(<string>id, options);
|
||||
}
|
||||
|
||||
@PermissionVerify.API()
|
||||
async GM_download(request: Request, sender: GetSender) {
|
||||
const params = <GMTypes.DownloadDetails>request.params[0];
|
||||
// blob本地文件直接下载
|
||||
if (params.url.startsWith("blob:")) {
|
||||
chrome.downloads.download(
|
||||
{
|
||||
url: params.url,
|
||||
saveAs: params.saveAs,
|
||||
filename: params.name,
|
||||
},
|
||||
() => {
|
||||
sender.getConnect().sendMessage({ event: "onload" });
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 使用xhr下载blob,再使用download api创建下载
|
||||
const EE = new EventEmitter();
|
||||
const mockConnect = new MockMessageConnect(EE);
|
||||
EE.addListener("message", (data: any) => {
|
||||
const xhr = data.data;
|
||||
const respond: any = {
|
||||
finalUrl: xhr.url,
|
||||
readyState: xhr.readyState,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
responseHeaders: xhr.responseHeaders,
|
||||
};
|
||||
switch (data.action) {
|
||||
case "onload":
|
||||
sender.getConnect().sendMessage({
|
||||
action: "onload",
|
||||
data: respond,
|
||||
});
|
||||
chrome.downloads.download({
|
||||
url: xhr.response,
|
||||
saveAs: params.saveAs,
|
||||
filename: params.name,
|
||||
});
|
||||
break;
|
||||
case "onerror":
|
||||
sender.getConnect().sendMessage({
|
||||
action: "onerror",
|
||||
data: respond,
|
||||
});
|
||||
break;
|
||||
case "onprogress":
|
||||
respond.done = xhr.DONE;
|
||||
respond.lengthComputable = xhr.lengthComputable;
|
||||
respond.loaded = xhr.loaded;
|
||||
respond.total = xhr.total;
|
||||
respond.totalSize = xhr.total;
|
||||
sender.getConnect().sendMessage({
|
||||
action: "onprogress",
|
||||
data: respond,
|
||||
});
|
||||
break;
|
||||
case "ontimeout":
|
||||
sender.getConnect().sendMessage({
|
||||
action: "ontimeout",
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
// 处理参数问题
|
||||
request.params[0] = {
|
||||
method: params.method || "GET",
|
||||
url: params.url,
|
||||
headers: params.headers,
|
||||
timeout: params.timeout,
|
||||
cookie: params.cookie,
|
||||
anonymous: params.anonymous,
|
||||
responseType: "blob",
|
||||
} as GMSend.XHRDetails;
|
||||
return this.GM_xmlhttpRequest(request, new GetSender(mockConnect));
|
||||
}
|
||||
|
||||
@PermissionVerify.API()
|
||||
async GM_setClipboard(request: Request) {
|
||||
let [data, type] = request.params;
|
||||
type = type || "text/plain";
|
||||
await sendMessage(this.send, "offscreen/gmApi/setClipboard", { data, type });
|
||||
}
|
||||
|
||||
handlerNotification() {
|
||||
const send = async (event: string, notificationId: string, params?: any) => {
|
||||
const ret = (await Cache.getInstance().get(`GM_notification:${notificationId}`)) as NotificationData;
|
||||
if (ret) {
|
||||
this.runtime.emitEventToTab(ret.sender, {
|
||||
event: "GM_notification",
|
||||
eventId: notificationId,
|
||||
uuid: ret.uuid,
|
||||
data: {
|
||||
event,
|
||||
params,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
chrome.notifications.onClosed.addListener((notificationId, byUser) => {
|
||||
send("close", notificationId, {
|
||||
byUser,
|
||||
});
|
||||
Cache.getInstance().del(`GM_notification:${notificationId}`);
|
||||
});
|
||||
chrome.notifications.onClicked.addListener((notificationId) => {
|
||||
send("click", notificationId);
|
||||
});
|
||||
chrome.notifications.onButtonClicked.addListener((notificationId, index) => {
|
||||
send("buttonClick", notificationId, {
|
||||
index: index,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 处理GM_xmlhttpRequest请求
|
||||
handlerGmXhr() {
|
||||
chrome.webRequest.onBeforeSendHeaders.addListener(
|
||||
(details) => {
|
||||
if (details.tabId === -1) {
|
||||
// 判断是否存在X-Scriptcat-GM-XHR-Request-Id
|
||||
// 讲请求id与chrome.webRequest的请求id关联
|
||||
if (details.requestHeaders) {
|
||||
const requestId = details.requestHeaders.find((header) => header.name === "X-Scriptcat-GM-XHR-Request-Id");
|
||||
if (requestId) {
|
||||
Cache.getInstance().set("gmXhrRequest:" + details.requestId, requestId.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
urls: ["<all_urls>"],
|
||||
types: ["xmlhttprequest"],
|
||||
},
|
||||
["requestHeaders", "extraHeaders"]
|
||||
);
|
||||
chrome.webRequest.onHeadersReceived.addListener(
|
||||
(details) => {
|
||||
console.log(details);
|
||||
if (details.tabId === -1) {
|
||||
// 判断请求是否与gmXhrRequest关联
|
||||
Cache.getInstance()
|
||||
.get("gmXhrRequest:" + details.requestId)
|
||||
.then((requestId) => {
|
||||
if (requestId) {
|
||||
this.gmXhrHeadersReceived.emit("headersReceived:" + requestId, details);
|
||||
// 删除关联与DNR
|
||||
Cache.getInstance().del("gmXhrRequest:" + details.requestId);
|
||||
chrome.declarativeNetRequest.updateSessionRules({
|
||||
removeRuleIds: [parseInt(requestId)],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
urls: ["<all_urls>"],
|
||||
@@ -150,4 +920,30 @@ export default class GMApi {
|
||||
["responseHeaders", "extraHeaders"]
|
||||
);
|
||||
}
|
||||
|
||||
start() {
|
||||
this.group.on("gmApi", this.handlerRequest.bind(this));
|
||||
this.handlerGmXhr();
|
||||
this.handlerNotification();
|
||||
|
||||
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
// 处理GM_openInTab关闭事件
|
||||
const sender = (await Cache.getInstance().get(`GM_openInTab:${tabId}`)) as {
|
||||
uuid: string;
|
||||
sender: ExtMessageSender;
|
||||
};
|
||||
if (sender) {
|
||||
this.runtime.emitEventToTab(sender.sender, {
|
||||
event: "GM_openInTab",
|
||||
eventId: tabId.toString(),
|
||||
uuid: sender.uuid,
|
||||
data: {
|
||||
event: "onclose",
|
||||
tabId: tabId,
|
||||
},
|
||||
});
|
||||
Cache.getInstance().del(`GM_openInTab:${tabId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,21 +1,26 @@
|
||||
import { Server } from "@Packages/message/server";
|
||||
import { MessageQueue } from "@Packages/message/message_queue";
|
||||
import { ScriptService } from "./script";
|
||||
import { ExtensionMessage } from "@Packages/message/extension_message";
|
||||
import { ResourceService } from "./resource";
|
||||
import { ValueService } from "./value";
|
||||
import { RuntimeService } from "./runtime";
|
||||
import { ServiceWorkerMessageSend } from "@Packages/message/window_message";
|
||||
import { PopupService } from "./popup";
|
||||
import { SystemConfig } from "@App/pkg/config/config";
|
||||
import { SynchronizeService } from "./synchronize";
|
||||
import { SubscribeService } from "./subscribe";
|
||||
import { ExtServer, ExtVersion } from "@App/app/const";
|
||||
import { systemConfig } from "@App/pages/store/global";
|
||||
|
||||
export type InstallSource = "user" | "system" | "sync" | "subscribe" | "vscode";
|
||||
|
||||
// service worker的管理器
|
||||
export default class ServiceWorkerManager {
|
||||
private api: Server = new Server(new ExtensionMessage());
|
||||
|
||||
private mq: MessageQueue = new MessageQueue(this.api);
|
||||
|
||||
private sender: ServiceWorkerMessageSend = new ServiceWorkerMessageSend();
|
||||
constructor(
|
||||
private api: Server,
|
||||
private mq: MessageQueue,
|
||||
private sender: ServiceWorkerMessageSend
|
||||
) {}
|
||||
|
||||
async initManager() {
|
||||
this.api.on("preparationOffscreen", async () => {
|
||||
@@ -23,88 +28,109 @@ export default class ServiceWorkerManager {
|
||||
await this.sender.init();
|
||||
this.mq.emit("preparationOffscreen", {});
|
||||
});
|
||||
this.sender.init();
|
||||
|
||||
const systemConfig = new SystemConfig(this.mq);
|
||||
|
||||
const resource = new ResourceService(this.api.group("resource"), this.mq);
|
||||
resource.init();
|
||||
const value = new ValueService(this.api.group("value"), this.mq);
|
||||
value.init();
|
||||
const script = new ScriptService(this.api.group("script"), this.mq, value, resource);
|
||||
const value = new ValueService(this.api.group("value"), this.sender);
|
||||
const script = new ScriptService(systemConfig, this.api.group("script"), this.mq, value, resource);
|
||||
script.init();
|
||||
const runtime = new RuntimeService(this.api.group("runtime"), this.sender, this.mq, value);
|
||||
const runtime = new RuntimeService(
|
||||
systemConfig,
|
||||
this.api.group("runtime"),
|
||||
this.sender,
|
||||
this.mq,
|
||||
value,
|
||||
script,
|
||||
resource
|
||||
);
|
||||
runtime.init();
|
||||
const popup = new PopupService(this.api.group("popup"), this.mq, runtime);
|
||||
popup.init();
|
||||
value.init(runtime, popup);
|
||||
const synchronize = new SynchronizeService(
|
||||
this.sender,
|
||||
this.api.group("synchronize"),
|
||||
script,
|
||||
value,
|
||||
resource,
|
||||
this.mq,
|
||||
systemConfig
|
||||
);
|
||||
synchronize.init();
|
||||
const subscribe = new SubscribeService(systemConfig, this.api.group("subscribe"), this.mq, script);
|
||||
subscribe.init();
|
||||
|
||||
// 测试xhr
|
||||
// setTimeout(() => {
|
||||
// chrome.tabs.query(
|
||||
// {
|
||||
// url: chrome.runtime.getURL("src/offscreen.html"),
|
||||
// },
|
||||
// (result) => {
|
||||
// console.log(result);
|
||||
// }
|
||||
// );
|
||||
// }, 2000);
|
||||
// group.on("testGmApi", () => {
|
||||
// console.log(chrome.runtime.getURL("src/offscreen.html"));
|
||||
// return new Promise((resolve) => {
|
||||
// chrome.tabs.query({}, (tabs) => {
|
||||
// const excludedTabIds: number[] = [];
|
||||
// tabs.forEach((tab) => {
|
||||
// if (tab.id) {
|
||||
// excludedTabIds.push(tab.id);
|
||||
// }
|
||||
// });
|
||||
// chrome.declarativeNetRequest.updateSessionRules(
|
||||
// {
|
||||
// removeRuleIds: [100],
|
||||
// addRules: [
|
||||
// {
|
||||
// id: 100,
|
||||
// priority: 1,
|
||||
// action: {
|
||||
// type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS,
|
||||
// requestHeaders: [
|
||||
// {
|
||||
// header: "cookie",
|
||||
// operation: chrome.declarativeNetRequest.HeaderOperation.SET,
|
||||
// value: "test=1234314",
|
||||
// },
|
||||
// {
|
||||
// header: "origin",
|
||||
// operation: chrome.declarativeNetRequest.HeaderOperation.SET,
|
||||
// value: "https://learn.scriptcat.org",
|
||||
// },
|
||||
// {
|
||||
// header: "user-agent",
|
||||
// operation: chrome.declarativeNetRequest.HeaderOperation.SET,
|
||||
// value: "test",
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// condition: {
|
||||
// resourceTypes: [chrome.declarativeNetRequest.ResourceType.XMLHTTPREQUEST],
|
||||
// urlFilter: "^https://scriptcat.org/zh-CN$",
|
||||
// excludedTabIds: excludedTabIds,
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// () => {
|
||||
// resolve(1);
|
||||
// }
|
||||
// );
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
// chrome.webRequest.onHeadersReceived.addListener(
|
||||
// (details) => {
|
||||
// console.log(details);
|
||||
// },
|
||||
// {
|
||||
// urls: ["<all_urls>"],
|
||||
// types: ["xmlhttprequest"],
|
||||
// },
|
||||
// ["responseHeaders", "extraHeaders"]
|
||||
// );
|
||||
// 定时器处理
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
switch (alarm.name) {
|
||||
case "checkScriptUpdate":
|
||||
script.checkScriptUpdate();
|
||||
break;
|
||||
case "cloudSync":
|
||||
// 进行一次云同步
|
||||
systemConfig.getCloudSync().then((config) => {
|
||||
synchronize.buildFileSystem(config).then((fs) => {
|
||||
synchronize.syncOnce(fs);
|
||||
});
|
||||
});
|
||||
break;
|
||||
case "checkSubscribeUpdate":
|
||||
subscribe.checkSubscribeUpdate();
|
||||
break;
|
||||
case "checkUpdate":
|
||||
// 检查扩展更新
|
||||
this.checkUpdate();
|
||||
break;
|
||||
}
|
||||
});
|
||||
// 8小时检查一次扩展更新
|
||||
chrome.alarms.create("checkUpdate", {
|
||||
delayInMinutes: 0,
|
||||
periodInMinutes: 8 * 60,
|
||||
});
|
||||
|
||||
// 监听配置变化
|
||||
this.mq.subscribe("systemConfigChange", (msg) => {
|
||||
console.log("systemConfigChange", msg);
|
||||
switch (msg.key) {
|
||||
case "cloud_sync": {
|
||||
synchronize.cloudSyncConfigChange(msg.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
// 启动一次云同步
|
||||
systemConfig.getCloudSync().then((config) => {
|
||||
synchronize.cloudSyncConfigChange(config);
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
if (details.reason === "install") {
|
||||
chrome.tabs.create({ url: "https://docs.scriptcat.org/" });
|
||||
} else if (details.reason === "update") {
|
||||
chrome.tabs.create({
|
||||
url: `https://docs.scriptcat.org/docs/change/#${ExtVersion}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
checkUpdate() {
|
||||
fetch(`${ExtServer}api/v1/system/version?version=${ExtVersion}`)
|
||||
.then((resp) => resp.json())
|
||||
.then((resp: { data: { notice: string; version: string } }) => {
|
||||
systemConfig.getCheckUpdate().then((items) => {
|
||||
if (items.notice !== resp.data.notice) {
|
||||
systemConfig.setCheckUpdate(Object.assign(resp.data, { isRead: false }));
|
||||
} else {
|
||||
systemConfig.setCheckUpdate(Object.assign(resp.data, { isRead: items.isRead }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -6,6 +6,7 @@ import { Api, Request } from "./gm_api";
|
||||
import Queue from "@App/pkg/utils/queue";
|
||||
import CacheKey from "@App/app/cache_key";
|
||||
import { Permission, PermissionDAO } from "@App/app/repo/permission";
|
||||
import { Group } from "@Packages/message/server";
|
||||
|
||||
export interface ConfirmParam {
|
||||
// 权限名
|
||||
@@ -36,8 +37,6 @@ export interface ApiParam {
|
||||
background?: boolean;
|
||||
// 是否需要弹出页面让用户进行确认
|
||||
confirm?: (request: Request) => Promise<boolean | ConfirmParam>;
|
||||
// 监听方法
|
||||
listener?: () => void;
|
||||
// 别名
|
||||
alias?: string[];
|
||||
// 关联
|
||||
@@ -59,9 +58,6 @@ export default class PermissionVerify {
|
||||
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,
|
||||
@@ -100,21 +96,9 @@ export default class PermissionVerify {
|
||||
reject: (reason: any) => void;
|
||||
}> = new Queue();
|
||||
|
||||
async removePermissionCache(scriptId: number) {
|
||||
// 先删除缓存
|
||||
(await Cache.getInstance().list()).forEach((key) => {
|
||||
if (key.startsWith(`permission:${scriptId.toString()}:`)) {
|
||||
Cache.getInstance().del(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
private permissionDAO: PermissionDAO = new PermissionDAO();
|
||||
|
||||
private permissionDAO: PermissionDAO;
|
||||
|
||||
constructor() {
|
||||
this.permissionDAO = new PermissionDAO();
|
||||
this.dealConfirmQueue();
|
||||
}
|
||||
constructor(private group: Group) {}
|
||||
|
||||
// 验证是否有权限
|
||||
verify(request: Request, api: ApiValue): Promise<boolean> {
|
||||
@@ -254,9 +238,108 @@ export default class PermissionVerify {
|
||||
|
||||
// 弹出窗口让用户进行确认
|
||||
async confirmWindow(script: Script, confirm: ConfirmParam): Promise<UserConfirm> {
|
||||
return Promise.resolve({
|
||||
allow: true,
|
||||
type: 1,
|
||||
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}`),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 处理确认
|
||||
private userConfirm(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);
|
||||
}
|
||||
|
||||
// 获取信息
|
||||
private getInfo(uuid: string) {
|
||||
const data = this.confirmMap.get(uuid);
|
||||
if (!data) {
|
||||
return Promise.reject(new Error("permission confirm not found"));
|
||||
}
|
||||
const { script, confirm } = data;
|
||||
// 查询允许统配的有多少个相同等待确认权限
|
||||
let likeNum = 0;
|
||||
if (data.confirm.wildcard) {
|
||||
this.confirmQueue.list.forEach((value) => {
|
||||
const confirm = value.confirm as ConfirmParam;
|
||||
if (
|
||||
confirm.wildcard &&
|
||||
value.request.uuid === data.script.uuid &&
|
||||
confirm.permission === data.confirm.permission
|
||||
) {
|
||||
likeNum += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ script, confirm, likeNum });
|
||||
}
|
||||
|
||||
async deletePermission(data: { uuid: string; permission: string; permissionValue: string }) {
|
||||
const oldConfirm = await this.permissionDAO.findByKey(data.uuid, data.permission, data.permissionValue);
|
||||
if (!oldConfirm) {
|
||||
throw new Error("permission not found");
|
||||
} else {
|
||||
await this.permissionDAO.delete(this.permissionDAO.key(oldConfirm));
|
||||
// 删除缓存
|
||||
Cache.getInstance().del(CacheKey.permissionConfirm(data.uuid, oldConfirm));
|
||||
}
|
||||
}
|
||||
|
||||
getScriptPermissions(uuid: string) {
|
||||
// 获取脚本的所有权限
|
||||
return this.permissionDAO.find((key, item) => item.uuid === uuid);
|
||||
}
|
||||
|
||||
// 添加权限
|
||||
async addPermission(permission: Permission) {
|
||||
await this.permissionDAO.save(permission);
|
||||
Cache.getInstance().del(CacheKey.permissionConfirm(permission.uuid, permission));
|
||||
}
|
||||
|
||||
// 重置权限
|
||||
async resetPermission(uuid: string) {
|
||||
// 删除所有权限
|
||||
const permissions = await this.permissionDAO.find((key, item) => item.uuid === uuid);
|
||||
permissions.forEach((item) => {
|
||||
this.permissionDAO.delete(this.permissionDAO.key(item));
|
||||
Cache.getInstance().del(CacheKey.permissionConfirm(uuid, item));
|
||||
});
|
||||
}
|
||||
|
||||
init() {
|
||||
this.dealConfirmQueue();
|
||||
this.group.on("confirm", this.userConfirm.bind(this));
|
||||
this.group.on("getInfo", this.getInfo.bind(this));
|
||||
this.group.on("deletePermission", this.deletePermission.bind(this));
|
||||
this.group.on("getScriptPermissions", this.getScriptPermissions.bind(this));
|
||||
this.group.on("addPermission", this.getInfo.bind(this));
|
||||
this.group.on("resetPermission", this.resetPermission.bind(this));
|
||||
}
|
||||
}
|
||||
|
427
src/app/service/service_worker/popup.ts
Normal file
427
src/app/service/service_worker/popup.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { MessageQueue } from "@Packages/message/message_queue";
|
||||
import { ExtMessageSender, Group } from "@Packages/message/server";
|
||||
import { RuntimeService, ScriptMatchInfo } from "./runtime";
|
||||
import Cache from "@App/app/cache";
|
||||
import { GetPopupDataReq, GetPopupDataRes } from "./client";
|
||||
import {
|
||||
SCRIPT_RUN_STATUS,
|
||||
Metadata,
|
||||
SCRIPT_STATUS_ENABLE,
|
||||
Script,
|
||||
ScriptDAO,
|
||||
SCRIPT_TYPE_NORMAL,
|
||||
SCRIPT_RUN_STATUS_RUNNING,
|
||||
} from "@App/app/repo/scripts";
|
||||
import {
|
||||
ScriptMenuRegisterCallbackValue,
|
||||
subscribeScriptDelete,
|
||||
subscribeScriptEnable,
|
||||
subscribeScriptInstall,
|
||||
subscribeScriptMenuRegister,
|
||||
subscribeScriptRunStatus,
|
||||
} from "../queue";
|
||||
import { getStorageName } from "@App/pkg/utils/utils";
|
||||
|
||||
export type ScriptMenuItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
accessKey?: string;
|
||||
tabId: number; //-1表示后台脚本
|
||||
frameId?: number;
|
||||
documentId?: string;
|
||||
};
|
||||
|
||||
export type ScriptMenu = {
|
||||
uuid: string; // 脚本uuid
|
||||
name: string; // 脚本名称
|
||||
storageName: string; // 脚本存储名称
|
||||
enable: boolean; // 脚本是否启用
|
||||
updatetime: number; // 脚本更新时间
|
||||
hasUserConfig: boolean; // 是否有用户配置
|
||||
metadata: Metadata; // 脚本元数据
|
||||
runStatus?: SCRIPT_RUN_STATUS; // 脚本运行状态
|
||||
runNum: number; // 脚本运行次数
|
||||
runNumByIframe: number; // iframe运行次数
|
||||
menus: ScriptMenuItem[]; // 脚本菜单
|
||||
customExclude: string[]; // 自定义排除
|
||||
};
|
||||
|
||||
// 处理popup页面的数据
|
||||
export class PopupService {
|
||||
scriptDAO = new ScriptDAO();
|
||||
|
||||
constructor(
|
||||
private group: Group,
|
||||
private mq: MessageQueue,
|
||||
private runtime: RuntimeService
|
||||
) {}
|
||||
|
||||
genScriptMenuByTabMap(menu: ScriptMenu[]) {
|
||||
let n = 0;
|
||||
menu.forEach((script) => {
|
||||
// 创建脚本菜单
|
||||
if (script.menus.length) {
|
||||
n += script.menus.length;
|
||||
chrome.contextMenus.create({
|
||||
id: `scriptMenu_` + script.uuid,
|
||||
title: script.name,
|
||||
contexts: ["all"],
|
||||
parentId: "scriptMenu",
|
||||
});
|
||||
script.menus.forEach((menu) => {
|
||||
// 创建菜单
|
||||
chrome.contextMenus.create({
|
||||
id: `scriptMenu_menu_${script.uuid}_${menu.id}`,
|
||||
title: menu.name,
|
||||
contexts: ["all"],
|
||||
parentId: `scriptMenu_${script.uuid}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
return n;
|
||||
}
|
||||
|
||||
// 生成chrome菜单
|
||||
async genScriptMenu(tabId: number) {
|
||||
// 移除之前所有的菜单
|
||||
chrome.contextMenus.removeAll();
|
||||
const [menu, backgroundMenu] = await Promise.all([this.getScriptMenu(tabId), this.getScriptMenu(-1)]);
|
||||
if (!menu.length && !backgroundMenu.length) {
|
||||
return;
|
||||
}
|
||||
let n = 0;
|
||||
// 创建根菜单
|
||||
chrome.contextMenus.create({
|
||||
id: "scriptMenu",
|
||||
title: "ScriptCat",
|
||||
contexts: ["all"],
|
||||
});
|
||||
if (menu) {
|
||||
n += this.genScriptMenuByTabMap(menu);
|
||||
}
|
||||
// 后台脚本的菜单
|
||||
if (backgroundMenu) {
|
||||
n += this.genScriptMenuByTabMap(backgroundMenu);
|
||||
}
|
||||
if (n === 0) {
|
||||
// 如果没有菜单,删除菜单
|
||||
chrome.contextMenus.remove("scriptMenu");
|
||||
}
|
||||
}
|
||||
|
||||
async registerMenuCommand(message: ScriptMenuRegisterCallbackValue) {
|
||||
// 给脚本添加菜单
|
||||
return this.txUpdateScriptMenu(message.tabId, async (data) => {
|
||||
const script = data.find((item) => item.uuid === message.uuid);
|
||||
if (script) {
|
||||
const menu = script.menus.find((item) => item.id === message.id);
|
||||
if (!menu) {
|
||||
script.menus.push({
|
||||
id: message.id,
|
||||
name: message.name,
|
||||
accessKey: message.accessKey,
|
||||
tabId: message.tabId,
|
||||
frameId: message.frameId,
|
||||
documentId: message.documentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.updateScriptMenu();
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
async unregisterMenuCommand({ id, uuid, tabId }: { id: number; uuid: string; tabId: number }) {
|
||||
return this.txUpdateScriptMenu(tabId, async (data) => {
|
||||
// 删除脚本菜单
|
||||
const script = data.find((item) => item.uuid === uuid);
|
||||
if (script) {
|
||||
script.menus = script.menus.filter((item) => item.id !== id);
|
||||
}
|
||||
this.updateScriptMenu();
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
updateScriptMenu() {
|
||||
// 获取当前页面并更新菜单
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (!tabs.length) {
|
||||
return;
|
||||
}
|
||||
const tab = tabs[0];
|
||||
// 生成菜单
|
||||
tab.id && this.genScriptMenu(tab.id);
|
||||
});
|
||||
}
|
||||
|
||||
scriptToMenu(script: Script): ScriptMenu {
|
||||
return {
|
||||
uuid: script.uuid,
|
||||
name: script.name,
|
||||
storageName: getStorageName(script),
|
||||
enable: script.status === SCRIPT_STATUS_ENABLE,
|
||||
updatetime: script.updatetime || 0,
|
||||
hasUserConfig: !!script.config,
|
||||
metadata: script.metadata,
|
||||
runStatus: script.runStatus,
|
||||
runNum: script.type === SCRIPT_TYPE_NORMAL ? 0 : script.runStatus === SCRIPT_RUN_STATUS_RUNNING ? 1 : 0,
|
||||
runNumByIframe: 0,
|
||||
menus: [],
|
||||
customExclude: (script as ScriptMatchInfo).customizeExcludeMatches || [],
|
||||
};
|
||||
}
|
||||
|
||||
// 获取popup页面数据
|
||||
async getPopupData(req: GetPopupDataReq): Promise<GetPopupDataRes> {
|
||||
// 获取当前tabId
|
||||
const script = await this.runtime.getPageScriptByUrl(req.url, true);
|
||||
// 与运行时脚本进行合并
|
||||
const runScript = await this.getScriptMenu(req.tabId);
|
||||
// 合并数据
|
||||
const scriptMenu = script.map((script) => {
|
||||
const run = runScript.find((item) => item.uuid === script.uuid);
|
||||
if (run) {
|
||||
// 如果脚本已经存在,则不添加,更新信息
|
||||
run.enable = script.status === SCRIPT_STATUS_ENABLE;
|
||||
run.customExclude = script.customizeExcludeMatches || run.customExclude;
|
||||
run.hasUserConfig = !!script.config;
|
||||
return run;
|
||||
}
|
||||
return this.scriptToMenu(script);
|
||||
});
|
||||
runScript.forEach((script) => {
|
||||
const index = scriptMenu.findIndex((item) => item.uuid === script.uuid);
|
||||
// 把运行了但是不在匹配中的脚本加入菜单
|
||||
if (index === -1) {
|
||||
scriptMenu.push(script);
|
||||
}
|
||||
});
|
||||
// 后台脚本只显示开启或者运行中的脚本
|
||||
return { scriptList: scriptMenu, backScriptList: await this.getScriptMenu(-1) };
|
||||
}
|
||||
|
||||
async getScriptMenu(tabId: number) {
|
||||
return ((await Cache.getInstance().get("tabScript:" + tabId)) || []) as ScriptMenu[];
|
||||
}
|
||||
|
||||
// 事务更新脚本菜单
|
||||
txUpdateScriptMenu(tabId: number, callback: (menu: ScriptMenu[]) => Promise<any>) {
|
||||
return Cache.getInstance().tx<ScriptMenu[]>("tabScript:" + tabId, async (menu) => {
|
||||
return callback(menu || []);
|
||||
});
|
||||
}
|
||||
|
||||
async addScriptRunNumber({
|
||||
tabId,
|
||||
frameId,
|
||||
scripts,
|
||||
}: {
|
||||
tabId: number;
|
||||
frameId: number;
|
||||
scripts: ScriptMatchInfo[];
|
||||
}) {
|
||||
// 设置数据
|
||||
return this.txUpdateScriptMenu(tabId, async (data) => {
|
||||
if (!frameId) {
|
||||
data = [];
|
||||
}
|
||||
// 设置脚本运行次数
|
||||
scripts.forEach((script) => {
|
||||
const scriptMenu = data.find((item) => item.uuid === script.uuid);
|
||||
if (scriptMenu) {
|
||||
scriptMenu.runNum = (scriptMenu.runNum || 0) + 1;
|
||||
if (frameId) {
|
||||
scriptMenu.runNumByIframe = (scriptMenu.runNumByIframe || 0) + 1;
|
||||
}
|
||||
} else {
|
||||
const item = this.scriptToMenu(script);
|
||||
item.runNum = 1;
|
||||
if (frameId) {
|
||||
item.runNumByIframe = 1;
|
||||
}
|
||||
data.push(item);
|
||||
}
|
||||
});
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
dealBackgroundScriptInstall() {
|
||||
// 处理后台脚本
|
||||
subscribeScriptInstall(this.mq, async ({ script }) => {
|
||||
if (script.type === SCRIPT_TYPE_NORMAL) {
|
||||
return;
|
||||
}
|
||||
if (script.status !== SCRIPT_STATUS_ENABLE) {
|
||||
return;
|
||||
}
|
||||
return this.txUpdateScriptMenu(-1, async (menu) => {
|
||||
const scriptMenu = menu.find((item) => item.uuid === script.uuid);
|
||||
// 加入菜单
|
||||
if (!scriptMenu) {
|
||||
const item = this.scriptToMenu(script);
|
||||
menu.push(item);
|
||||
}
|
||||
return menu;
|
||||
});
|
||||
});
|
||||
subscribeScriptEnable(this.mq, async ({ uuid }) => {
|
||||
const script = await this.scriptDAO.get(uuid);
|
||||
if (!script) {
|
||||
return;
|
||||
}
|
||||
if (script.type === SCRIPT_TYPE_NORMAL) {
|
||||
return;
|
||||
}
|
||||
return this.txUpdateScriptMenu(-1, async (menu) => {
|
||||
const index = menu.findIndex((item) => item.uuid === uuid);
|
||||
if (script.status === SCRIPT_STATUS_ENABLE) {
|
||||
// 加入菜单
|
||||
if (index === -1) {
|
||||
const item = this.scriptToMenu(script);
|
||||
menu.push(item);
|
||||
}
|
||||
} else {
|
||||
// 移出菜单
|
||||
if (index !== -1) {
|
||||
menu.splice(index, 1);
|
||||
}
|
||||
}
|
||||
return menu;
|
||||
});
|
||||
});
|
||||
subscribeScriptDelete(this.mq, async ({ uuid }) => {
|
||||
return this.txUpdateScriptMenu(-1, async (menu) => {
|
||||
const index = menu.findIndex((item) => item.uuid === uuid);
|
||||
if (index !== -1) {
|
||||
menu.splice(index, 1);
|
||||
}
|
||||
return menu;
|
||||
});
|
||||
});
|
||||
subscribeScriptRunStatus(this.mq, async ({ uuid, runStatus }) => {
|
||||
return this.txUpdateScriptMenu(-1, async (menu) => {
|
||||
const scriptMenu = menu.find((item) => item.uuid === uuid);
|
||||
if (scriptMenu) {
|
||||
scriptMenu.runStatus = runStatus;
|
||||
if (runStatus === SCRIPT_RUN_STATUS_RUNNING) {
|
||||
scriptMenu.runNum = 1;
|
||||
} else {
|
||||
scriptMenu.runNum = 0;
|
||||
}
|
||||
}
|
||||
return menu;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
menuClick({ uuid, id, sender }: { uuid: string; id: number; sender: ExtMessageSender }) {
|
||||
// 菜单点击事件
|
||||
this.runtime.emitEventToTab(sender, {
|
||||
uuid,
|
||||
event: "menuClick",
|
||||
eventId: id.toString(),
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
init() {
|
||||
// 处理脚本菜单数据
|
||||
subscribeScriptMenuRegister(this.mq, this.registerMenuCommand.bind(this));
|
||||
this.mq.subscribe("unregisterMenuCommand", this.unregisterMenuCommand.bind(this));
|
||||
this.group.on("getPopupData", this.getPopupData.bind(this));
|
||||
this.group.on("menuClick", this.menuClick.bind(this));
|
||||
this.dealBackgroundScriptInstall();
|
||||
|
||||
// 监听tab开关
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
// 清理数据tab关闭需要释放的数据
|
||||
this.txUpdateScriptMenu(tabId, async (script) => {
|
||||
script.forEach((script) => {
|
||||
// 处理GM_saveTab关闭事件, 由于需要用到tab相关的脚本数据,所以需要在这里处理
|
||||
// 避免先删除了数据获取不到
|
||||
Cache.getInstance().tx(`GM_getTab:${script.uuid}`, (tabData: { [key: number]: any }) => {
|
||||
if (tabData) {
|
||||
delete tabData[tabId];
|
||||
}
|
||||
return Promise.resolve(tabData);
|
||||
});
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
// 监听页面切换加载菜单
|
||||
chrome.tabs.onActivated.addListener((activeInfo) => {
|
||||
this.genScriptMenu(activeInfo.tabId);
|
||||
});
|
||||
// 处理chrome菜单点击
|
||||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
const menuIds = (info.menuItemId as string).split("_");
|
||||
if (menuIds.length === 4) {
|
||||
const [, , uuid, id] = menuIds;
|
||||
// 寻找menu信息
|
||||
const menu = await this.getScriptMenu(tab!.id!);
|
||||
let script = menu.find((item) => item.uuid === uuid);
|
||||
let bgscript = false;
|
||||
if (!script) {
|
||||
// 从后台脚本中寻找
|
||||
const backgroundMenu = await this.getScriptMenu(-1);
|
||||
script = backgroundMenu.find((item) => item.uuid === uuid);
|
||||
bgscript = true;
|
||||
}
|
||||
if (script) {
|
||||
const menuItem = script.menus.find((item) => item.id === parseInt(id, 10));
|
||||
if (menuItem) {
|
||||
this.menuClick({
|
||||
uuid: script.uuid,
|
||||
id: menuItem.id,
|
||||
sender: {
|
||||
tabId: bgscript ? -1 : tab!.id!,
|
||||
frameId: menuItem.frameId || 0,
|
||||
documentId: menuItem.documentId || "",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 监听运行次数
|
||||
this.mq.subscribe(
|
||||
"pageLoad",
|
||||
async ({
|
||||
tabId,
|
||||
frameId,
|
||||
scripts,
|
||||
}: {
|
||||
tabId: number;
|
||||
frameId: number;
|
||||
document: string;
|
||||
scripts: ScriptMatchInfo[];
|
||||
}) => {
|
||||
this.addScriptRunNumber({ tabId, frameId, scripts });
|
||||
// 设置角标和脚本
|
||||
chrome.action.getBadgeText(
|
||||
{
|
||||
tabId: tabId,
|
||||
},
|
||||
(res: string) => {
|
||||
if (res || scripts.length) {
|
||||
chrome.action.setBadgeText({
|
||||
text: (scripts.length + (parseInt(res, 10) || 0)).toString(),
|
||||
tabId: tabId,
|
||||
});
|
||||
chrome.action.setBadgeBackgroundColor({
|
||||
color: "#4e5969",
|
||||
tabId: tabId,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
@@ -36,12 +36,22 @@ export class ResourceService {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
cache: Map<string, { [key: string]: Resource }> = new Map();
|
||||
|
||||
public async getScriptResources(script: Script): Promise<{ [key: string]: Resource }> {
|
||||
return Promise.resolve({
|
||||
// 优先从内存中获取
|
||||
if (this.cache.has(script.uuid)) {
|
||||
return Promise.resolve(this.cache.get(script.uuid) || {});
|
||||
}
|
||||
// 资源不存在,重新加载
|
||||
const res = await Promise.resolve({
|
||||
...((await this.getResourceByType(script, "require")) || {}),
|
||||
...((await this.getResourceByType(script, "require-css")) || {}),
|
||||
...((await this.getResourceByType(script, "resource")) || {}),
|
||||
});
|
||||
// 缓存到内存
|
||||
this.cache.set(script.uuid, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
async getResourceByType(script: Script, type: ResourceType): Promise<{ [key: string]: Resource }> {
|
||||
@@ -159,6 +169,8 @@ export class ResourceService {
|
||||
}
|
||||
|
||||
public async addResource(url: string, uuid: string, type: ResourceType): Promise<Resource> {
|
||||
// 删除缓存
|
||||
this.cache.delete(uuid);
|
||||
const u = this.parseUrl(url);
|
||||
let result = await this.getResourceModel(u.url);
|
||||
// 资源不存在,重新加载
|
||||
@@ -193,6 +205,19 @@ export class ResourceService {
|
||||
(u.hash.sha512 && u.hash.sha512 !== resource.hash.sha512)
|
||||
) {
|
||||
resource.content = `console.warn("ScriptCat: couldn't load resource from URL ${url} due to a SRI error ");`;
|
||||
// 尝试重新加载
|
||||
this.loadByUrl(u.url, resource.type).then((reloadRes) => {
|
||||
this.logger.info("reload resource success", {
|
||||
url: u.url,
|
||||
hash: {
|
||||
expected: u.hash,
|
||||
old: resource.hash,
|
||||
new: reloadRes.hash,
|
||||
},
|
||||
});
|
||||
reloadRes.updatetime = new Date().getTime();
|
||||
this.resourceDAO.save(reloadRes);
|
||||
});
|
||||
}
|
||||
}
|
||||
return Promise.resolve(resource);
|
||||
@@ -214,12 +239,13 @@ export class ResourceService {
|
||||
sha512: "",
|
||||
});
|
||||
} else {
|
||||
const wordArray = crypto.lib.WordArray.create(<ArrayBuffer>reader.result);
|
||||
resolve({
|
||||
md5: crypto.MD5(<string>reader.result).toString(),
|
||||
sha1: crypto.SHA1(<string>reader.result).toString(),
|
||||
sha256: crypto.SHA256(<string>reader.result).toString(),
|
||||
sha384: crypto.SHA384(<string>reader.result).toString(),
|
||||
sha512: crypto.SHA512(<string>reader.result).toString(),
|
||||
md5: crypto.MD5(wordArray).toString(),
|
||||
sha1: crypto.SHA1(wordArray).toString(),
|
||||
sha256: crypto.SHA256(wordArray).toString(),
|
||||
sha384: crypto.SHA384(wordArray).toString(),
|
||||
sha512: crypto.SHA512(wordArray).toString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -231,7 +257,7 @@ export class ResourceService {
|
||||
return fetch(u.url)
|
||||
.then(async (resp) => {
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`resource response status not 200:${resp.status}`);
|
||||
throw new Error(`resource response status not 200: ${resp.status}`);
|
||||
}
|
||||
return {
|
||||
data: await resp.blob(),
|
||||
@@ -279,7 +305,20 @@ export class ResourceService {
|
||||
return { url: urls[0], hash };
|
||||
}
|
||||
|
||||
async deleteResource(url: string) {
|
||||
// 删除缓存
|
||||
const res = await this.resourceDAO.get(url);
|
||||
if (!res) {
|
||||
throw new Error("resource not found");
|
||||
}
|
||||
Object.keys(res.link).forEach((key) => {
|
||||
this.cache.delete(key);
|
||||
});
|
||||
return this.resourceDAO.delete(url);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.group.on("getScriptResources", this.getScriptResources.bind(this));
|
||||
this.group.on("deleteResource", this.deleteResource.bind(this));
|
||||
}
|
||||
}
|
||||
|
@@ -1,24 +1,108 @@
|
||||
import { MessageQueue } from "@Packages/message/message_queue";
|
||||
import { ScriptEnableCallbackValue } from "./client";
|
||||
import { Group } from "@Packages/message/server";
|
||||
import { Script, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL, ScriptAndCode, ScriptDAO } from "@App/app/repo/scripts";
|
||||
import { MessageQueue, Unsubscribe } from "@Packages/message/message_queue";
|
||||
import { ExtMessageSender, GetSender, Group, MessageSend } from "@Packages/message/server";
|
||||
import {
|
||||
Script,
|
||||
SCRIPT_STATUS,
|
||||
SCRIPT_STATUS_DISABLE,
|
||||
SCRIPT_STATUS_ENABLE,
|
||||
SCRIPT_TYPE_NORMAL,
|
||||
ScriptDAO,
|
||||
ScriptRunResouce,
|
||||
} from "@App/app/repo/scripts";
|
||||
import { ValueService } from "./value";
|
||||
import GMApi from "./gm_api";
|
||||
import { ServiceWorkerMessageSend } from "@Packages/message/window_message";
|
||||
import { subscribeScriptDelete, subscribeScriptEnable, subscribeScriptInstall } from "../queue";
|
||||
import { ScriptService } from "./script";
|
||||
import { runScript, stopScript } from "../offscreen/client";
|
||||
import { getRunAt } from "./utils";
|
||||
import { isUserScriptsAvailable, randomString } from "@App/pkg/utils/utils";
|
||||
import Cache from "@App/app/cache";
|
||||
import { dealPatternMatches, UrlMatch } from "@App/pkg/utils/match";
|
||||
import { ExtensionContentMessageSend } from "@Packages/message/extension_message";
|
||||
import { sendMessage } from "@Packages/message/client";
|
||||
import { compileInjectScript } from "../content/utils";
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import PermissionVerify from "./permission_verify";
|
||||
import { SystemConfig } from "@App/pkg/config/config";
|
||||
import { ResourceService } from "./resource";
|
||||
import { LocalStorageDAO } from "@App/app/repo/localStorage";
|
||||
|
||||
// 为了优化性能,存储到缓存时删除了code、value与resource
|
||||
export interface ScriptMatchInfo extends ScriptRunResouce {
|
||||
matches: string[];
|
||||
excludeMatches: string[];
|
||||
customizeExcludeMatches: string[];
|
||||
}
|
||||
|
||||
export interface EmitEventRequest {
|
||||
uuid: string;
|
||||
event: string;
|
||||
eventId: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export class RuntimeService {
|
||||
scriptDAO: ScriptDAO = new ScriptDAO();
|
||||
|
||||
scriptMatch: UrlMatch<string> = new UrlMatch<string>();
|
||||
scriptCustomizeMatch: UrlMatch<string> = new UrlMatch<string>();
|
||||
scriptMatchCache: Map<string, ScriptMatchInfo> | null | undefined;
|
||||
|
||||
isEnableDeveloperMode = false;
|
||||
isEnableUserscribe = true;
|
||||
|
||||
constructor(
|
||||
private systemConfig: SystemConfig,
|
||||
private group: Group,
|
||||
private sender: ServiceWorkerMessageSend,
|
||||
private sender: MessageSend,
|
||||
private mq: MessageQueue,
|
||||
private value: ValueService
|
||||
private value: ValueService,
|
||||
private script: ScriptService,
|
||||
private resource: ResourceService
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
// 启动gm api
|
||||
const permission = new PermissionVerify(this.group.group("permission"));
|
||||
const gmApi = new GMApi(this.systemConfig, permission, this.group, this.sender, this.mq, this.value, this);
|
||||
permission.init();
|
||||
gmApi.start();
|
||||
|
||||
this.group.on("stopScript", this.stopScript.bind(this));
|
||||
this.group.on("runScript", this.runScript.bind(this));
|
||||
this.group.on("pageLoad", this.pageLoad.bind(this));
|
||||
|
||||
// 检查是否开启了开发者模式
|
||||
this.isEnableDeveloperMode = isUserScriptsAvailable();
|
||||
if (!this.isEnableDeveloperMode) {
|
||||
// 未开启加上警告引导
|
||||
// 判断是否首次
|
||||
const localStorage = new LocalStorageDAO();
|
||||
localStorage.get("firstShowDeveloperMode").then((res) => {
|
||||
if (!res) {
|
||||
localStorage.save({
|
||||
key: "firstShowDeveloperMode",
|
||||
value: true,
|
||||
});
|
||||
// 打开页面
|
||||
chrome.tabs.create({
|
||||
url: `https://docs.scriptcat.org/docs/use/open-dev/`,
|
||||
});
|
||||
}
|
||||
});
|
||||
chrome.action.setBadgeBackgroundColor({
|
||||
color: "#ff8c00",
|
||||
});
|
||||
chrome.action.setBadgeTextColor({
|
||||
color: "#ffffff",
|
||||
});
|
||||
chrome.action.setBadgeText({
|
||||
text: "!",
|
||||
});
|
||||
}
|
||||
|
||||
// 监听脚本开启
|
||||
this.mq.addListener("enableScript", async (data: ScriptEnableCallbackValue) => {
|
||||
subscribeScriptEnable(this.mq, async (data) => {
|
||||
const script = await this.scriptDAO.getAndCode(data.uuid);
|
||||
if (!script) {
|
||||
return;
|
||||
@@ -26,42 +110,464 @@ export class RuntimeService {
|
||||
// 如果是普通脚本, 在service worker中进行注册
|
||||
// 如果是后台脚本, 在offscreen中进行处理
|
||||
if (script.type === SCRIPT_TYPE_NORMAL) {
|
||||
// 注册入页面脚本
|
||||
if (data.enable) {
|
||||
this.registryPageScript(script);
|
||||
} else {
|
||||
this.unregistryPageScript(script);
|
||||
// 加载页面脚本
|
||||
// 不管开没开启都要加载一次脚本信息
|
||||
await this.loadPageScript(script);
|
||||
if (!data.enable) {
|
||||
await this.unregistryPageScript(script.uuid);
|
||||
}
|
||||
}
|
||||
});
|
||||
// 监听脚本安装
|
||||
subscribeScriptInstall(this.mq, async (data) => {
|
||||
const script = await this.scriptDAO.get(data.script.uuid);
|
||||
if (!script) {
|
||||
return;
|
||||
}
|
||||
if (script.type === SCRIPT_TYPE_NORMAL) {
|
||||
await this.loadPageScript(script);
|
||||
}
|
||||
});
|
||||
// 监听脚本删除
|
||||
subscribeScriptDelete(this.mq, async ({ uuid }) => {
|
||||
await this.unregistryPageScript(uuid);
|
||||
this.deleteScriptMatch(uuid);
|
||||
});
|
||||
|
||||
this.systemConfig.addListener("enable_script", (enable) => {
|
||||
this.isEnableUserscribe = enable;
|
||||
if (enable) {
|
||||
this.registerUserscripts();
|
||||
} else {
|
||||
this.unregisterUserscripts();
|
||||
}
|
||||
});
|
||||
// 检查是否开启
|
||||
this.isEnableUserscribe = await this.systemConfig.getEnableScript();
|
||||
if (this.isEnableUserscribe) {
|
||||
this.registerUserscripts();
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribe: Unsubscribe[] = [];
|
||||
|
||||
// 取消脚本注册
|
||||
unregisterUserscripts() {
|
||||
chrome.userScripts.unregister();
|
||||
this.deleteMessageFlag();
|
||||
}
|
||||
|
||||
async registerUserscripts() {
|
||||
// 读取inject.js注入页面
|
||||
this.registerInjectScript();
|
||||
// 将开启的脚本发送一次enable消息
|
||||
const scriptDao = new ScriptDAO();
|
||||
const list = await scriptDao.all();
|
||||
list.forEach((script) => {
|
||||
if (script.status !== SCRIPT_STATUS_ENABLE || script.type !== SCRIPT_TYPE_NORMAL) {
|
||||
if (script.type !== SCRIPT_TYPE_NORMAL) {
|
||||
return;
|
||||
}
|
||||
this.mq.publish("enableScript", { uuid: script.uuid, enable: true });
|
||||
this.mq.publish("enableScript", { uuid: script.uuid, enable: script.status === SCRIPT_STATUS_ENABLE });
|
||||
});
|
||||
// 监听offscreen环境初始化, 初始化完成后, 再将后台脚本运行起来
|
||||
this.mq.addListener("preparationOffscreen", () => {
|
||||
this.mq.subscribe("preparationOffscreen", () => {
|
||||
list.forEach((script) => {
|
||||
if (script.status !== SCRIPT_STATUS_ENABLE || script.type === SCRIPT_TYPE_NORMAL) {
|
||||
if (script.type === SCRIPT_TYPE_NORMAL) {
|
||||
return;
|
||||
}
|
||||
this.mq.publish("enableScript", { uuid: script.uuid, enable: true });
|
||||
this.mq.publish("enableScript", { uuid: script.uuid, enable: script.status === SCRIPT_STATUS_ENABLE });
|
||||
});
|
||||
});
|
||||
|
||||
// 启动gm api
|
||||
const gmApi = new GMApi(this.group, this.sender, this.value);
|
||||
gmApi.start();
|
||||
this.loadScriptMatchInfo();
|
||||
}
|
||||
|
||||
registryPageScript(script: ScriptAndCode) {
|
||||
console.log(script);
|
||||
messageFlag() {
|
||||
return Cache.getInstance().getOrSet("scriptInjectMessageFlag", () => {
|
||||
return Promise.resolve(randomString(16));
|
||||
});
|
||||
}
|
||||
|
||||
unregistryPageScript(script: Script) {}
|
||||
deleteMessageFlag() {
|
||||
return Cache.getInstance().del("scriptInjectMessageFlag");
|
||||
}
|
||||
|
||||
getMessageFlag() {
|
||||
return Cache.getInstance().get("scriptInjectMessageFlag");
|
||||
}
|
||||
|
||||
// 给指定tab发送消息
|
||||
sendMessageToTab(to: ExtMessageSender, action: string, data: any) {
|
||||
if (to.tabId === -1) {
|
||||
// 如果是-1, 代表给offscreen发送消息
|
||||
return sendMessage(this.sender, "offscreen/runtime/" + action, data);
|
||||
}
|
||||
return sendMessage(
|
||||
new ExtensionContentMessageSend(to.tabId, {
|
||||
documentId: to.documentId,
|
||||
frameId: to.frameId,
|
||||
}),
|
||||
"content/runtime/" + action,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
// 给指定脚本触发事件
|
||||
emitEventToTab(to: ExtMessageSender, req: EmitEventRequest) {
|
||||
if (to.tabId === -1) {
|
||||
// 如果是-1, 代表给offscreen发送消息
|
||||
return sendMessage(this.sender, "offscreen/runtime/emitEvent", req);
|
||||
}
|
||||
return sendMessage(
|
||||
new ExtensionContentMessageSend(to.tabId, {
|
||||
documentId: to.documentId,
|
||||
frameId: to.frameId,
|
||||
}),
|
||||
"content/runtime/emitEvent",
|
||||
req
|
||||
);
|
||||
}
|
||||
|
||||
async getPageScriptUuidByUrl(url: string, includeCustomize?: boolean) {
|
||||
const match = await this.loadScriptMatchInfo();
|
||||
// 匹配当前页面的脚本
|
||||
const matchScriptUuid = match.match(url!);
|
||||
// 包含自定义排除的脚本
|
||||
if (includeCustomize) {
|
||||
const excludeScriptUuid = this.scriptCustomizeMatch.match(url!);
|
||||
const match = new Set<string>();
|
||||
excludeScriptUuid.forEach((uuid) => {
|
||||
match.add(uuid);
|
||||
});
|
||||
matchScriptUuid.forEach((uuid) => {
|
||||
match.add(uuid);
|
||||
});
|
||||
// 转化为数组
|
||||
return Array.from(match);
|
||||
}
|
||||
return matchScriptUuid;
|
||||
}
|
||||
|
||||
async getPageScriptByUrl(url: string, includeCustomize?: boolean) {
|
||||
const matchScriptUuid = await this.getPageScriptUuidByUrl(url, includeCustomize);
|
||||
return matchScriptUuid.map((uuid) => {
|
||||
return Object.assign({}, this.scriptMatchCache?.get(uuid));
|
||||
});
|
||||
}
|
||||
|
||||
async pageLoad(_: any, sender: GetSender) {
|
||||
const [scriptFlag] = await Promise.all([this.messageFlag(), this.loadScriptMatchInfo()]);
|
||||
const chromeSender = sender.getSender() as chrome.runtime.MessageSender;
|
||||
|
||||
// 匹配当前页面的脚本
|
||||
const matchScriptUuid = await this.getPageScriptUuidByUrl(chromeSender.url!);
|
||||
|
||||
const scripts = matchScriptUuid.map((uuid) => {
|
||||
const scriptRes = Object.assign({}, this.scriptMatchCache?.get(uuid));
|
||||
// 判断脚本是否开启
|
||||
if (scriptRes.status === SCRIPT_STATUS_DISABLE) {
|
||||
return undefined;
|
||||
}
|
||||
// 如果是iframe,判断是否允许在iframe里运行
|
||||
if (chromeSender.frameId) {
|
||||
if (scriptRes.metadata.noframes) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
// 获取value
|
||||
return scriptRes;
|
||||
});
|
||||
|
||||
const enableScript = scripts.filter((item) => item) as ScriptMatchInfo[];
|
||||
|
||||
await Promise.all([
|
||||
// 加载value
|
||||
...enableScript.map(async (script) => {
|
||||
const value = await this.value.getScriptValue(script!);
|
||||
script.value = value;
|
||||
}),
|
||||
// 加载resource
|
||||
...enableScript.map(async (script) => {
|
||||
const resource = await this.resource.getScriptResources(script);
|
||||
script.resource = resource;
|
||||
}),
|
||||
]);
|
||||
|
||||
this.mq.emit("pageLoad", {
|
||||
tabId: chromeSender.tab?.id,
|
||||
frameId: chromeSender.frameId,
|
||||
scripts: enableScript,
|
||||
});
|
||||
|
||||
return Promise.resolve({ flag: scriptFlag, scripts: enableScript });
|
||||
}
|
||||
|
||||
// 停止脚本
|
||||
stopScript(uuid: string) {
|
||||
return stopScript(this.sender, uuid);
|
||||
}
|
||||
|
||||
// 运行脚本
|
||||
async runScript(uuid: string) {
|
||||
const script = await this.scriptDAO.get(uuid);
|
||||
if (!script) {
|
||||
return;
|
||||
}
|
||||
const res = await this.script.buildScriptRunResource(script);
|
||||
return runScript(this.sender, res);
|
||||
}
|
||||
|
||||
// 注册inject.js
|
||||
async registerInjectScript() {
|
||||
// 如果没设置过, 则更新messageFlag
|
||||
let messageFlag = await this.getMessageFlag();
|
||||
if (!messageFlag) {
|
||||
messageFlag = await this.messageFlag();
|
||||
const injectJs = await fetch("inject.js").then((res) => res.text());
|
||||
// 替换ScriptFlag
|
||||
const code = `(function (MessageFlag) {\n${injectJs}\n})('${messageFlag}')`;
|
||||
chrome.userScripts.configureWorld({
|
||||
csp: "script-src 'self' 'unsafe-inline' 'unsafe-eval' *",
|
||||
messaging: true,
|
||||
});
|
||||
const scripts: chrome.userScripts.RegisteredUserScript[] = [
|
||||
{
|
||||
id: "scriptcat-inject",
|
||||
js: [{ code }],
|
||||
matches: ["<all_urls>"],
|
||||
allFrames: true,
|
||||
world: "MAIN",
|
||||
runAt: "document_start",
|
||||
},
|
||||
// 注册content
|
||||
{
|
||||
id: "scriptcat-content",
|
||||
js: [{ file: "src/content.js" }],
|
||||
matches: ["<all_urls>"],
|
||||
allFrames: true,
|
||||
runAt: "document_start",
|
||||
world: "USER_SCRIPT",
|
||||
},
|
||||
];
|
||||
try {
|
||||
// 如果使用getScripts来判断, 会出现找不到的问题
|
||||
// 另外如果使用
|
||||
await chrome.userScripts.register(scripts);
|
||||
} catch (e: any) {
|
||||
LoggerCore.logger().error("register inject.js error", {
|
||||
error: e,
|
||||
});
|
||||
if (e.message?.indexOf("Duplicate script ID") !== -1) {
|
||||
// 如果是重复注册, 则更新
|
||||
chrome.userScripts.update(scripts, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
LoggerCore.logger().error("update inject.js error", {
|
||||
error: chrome.runtime.lastError,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadingScript: Promise<void> | null | undefined;
|
||||
|
||||
// 加载脚本匹配信息,由于service_worker的机制,如果由不活动状态恢复过来时,会优先触发事件
|
||||
// 可能当时会没有脚本匹配信息,所以使用脚本信息时,尽量使用此方法获取
|
||||
async loadScriptMatchInfo() {
|
||||
if (this.scriptMatchCache) {
|
||||
return this.scriptMatch;
|
||||
}
|
||||
if (this.loadingScript) {
|
||||
await this.loadingScript;
|
||||
} else {
|
||||
// 如果没有缓存, 则创建一个新的缓存
|
||||
const cache = new Map<string, ScriptMatchInfo>();
|
||||
this.loadingScript = Cache.getInstance()
|
||||
.get("scriptMatch")
|
||||
.then((data: { [key: string]: ScriptMatchInfo }) => {
|
||||
if (data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
const item = data[key];
|
||||
cache.set(item.uuid, item);
|
||||
this.syncAddScriptMatch(item);
|
||||
});
|
||||
}
|
||||
});
|
||||
await this.loadingScript;
|
||||
this.loadingScript = null;
|
||||
this.scriptMatchCache = cache;
|
||||
}
|
||||
return this.scriptMatch;
|
||||
}
|
||||
|
||||
// 保存脚本匹配信息
|
||||
async saveScriptMatchInfo() {
|
||||
if (!this.scriptMatchCache) {
|
||||
return;
|
||||
}
|
||||
const scriptMatch = {} as { [key: string]: ScriptMatchInfo };
|
||||
this.scriptMatchCache.forEach((val, key) => {
|
||||
scriptMatch[key] = val;
|
||||
// 优化性能,将不需要的信息去掉
|
||||
// 而且可能会超过缓存的存储限制
|
||||
scriptMatch[key].code = "";
|
||||
scriptMatch[key].value = {};
|
||||
scriptMatch[key].resource = {};
|
||||
});
|
||||
return await Cache.getInstance().set("scriptMatch", scriptMatch);
|
||||
}
|
||||
|
||||
async addScriptMatch(item: ScriptMatchInfo) {
|
||||
if (!this.scriptMatchCache) {
|
||||
await this.loadScriptMatchInfo();
|
||||
}
|
||||
this.scriptMatchCache!.set(item.uuid, item);
|
||||
this.syncAddScriptMatch(item);
|
||||
this.saveScriptMatchInfo();
|
||||
}
|
||||
|
||||
syncAddScriptMatch(item: ScriptMatchInfo) {
|
||||
// 清理一下老数据
|
||||
this.scriptMatch.del(item.uuid);
|
||||
this.scriptCustomizeMatch.del(item.uuid);
|
||||
// 添加新的数据
|
||||
item.matches.forEach((match) => {
|
||||
this.scriptMatch.add(match, item.uuid);
|
||||
});
|
||||
item.excludeMatches.forEach((match) => {
|
||||
this.scriptMatch.exclude(match, item.uuid);
|
||||
});
|
||||
item.customizeExcludeMatches.forEach((match) => {
|
||||
this.scriptCustomizeMatch.add(match, item.uuid);
|
||||
});
|
||||
}
|
||||
|
||||
async updateScriptStatus(uuid: string, status: SCRIPT_STATUS) {
|
||||
if (!this.scriptMatchCache) {
|
||||
await this.loadScriptMatchInfo();
|
||||
}
|
||||
const script = await this.scriptMatchCache!.get(uuid);
|
||||
if (script) {
|
||||
script.status = status;
|
||||
this.saveScriptMatchInfo();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteScriptMatch(uuid: string) {
|
||||
if (!this.scriptMatchCache) {
|
||||
await this.loadScriptMatchInfo();
|
||||
}
|
||||
this.scriptMatchCache!.delete(uuid);
|
||||
this.scriptMatch.del(uuid);
|
||||
this.scriptCustomizeMatch.del(uuid);
|
||||
this.saveScriptMatchInfo();
|
||||
}
|
||||
|
||||
// 加载页面脚本, 会把脚本信息放入缓存中
|
||||
// 如果脚本开启, 则注册脚本
|
||||
async loadPageScript(script: Script) {
|
||||
const scriptRes = await this.script.buildScriptRunResource(script);
|
||||
const matches = scriptRes.metadata["match"];
|
||||
if (!matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
scriptRes.code = compileInjectScript(scriptRes);
|
||||
|
||||
matches.push(...(scriptRes.metadata["include"] || []));
|
||||
const patternMatches = dealPatternMatches(matches);
|
||||
const scriptMatchInfo: ScriptMatchInfo = Object.assign(
|
||||
{ matches: patternMatches.result, excludeMatches: [], customizeExcludeMatches: [] },
|
||||
scriptRes
|
||||
);
|
||||
|
||||
const registerScript: chrome.userScripts.RegisteredUserScript = {
|
||||
id: scriptRes.uuid,
|
||||
js: [{ code: scriptRes.code }],
|
||||
matches: patternMatches.patternResult,
|
||||
world: "MAIN",
|
||||
};
|
||||
|
||||
// 排除由loadPage时决定, 不使用userScript的excludeMatches处理
|
||||
if (script.metadata["exclude"]) {
|
||||
const excludeMatches = script.metadata["exclude"];
|
||||
const result = dealPatternMatches(excludeMatches, {
|
||||
exclude: true,
|
||||
});
|
||||
|
||||
// registerScript.excludeMatches = result.patternResult;
|
||||
scriptMatchInfo.excludeMatches = result.result;
|
||||
}
|
||||
// 自定义排除
|
||||
if (script.selfMetadata && script.selfMetadata.exclude) {
|
||||
const excludeMatches = script.selfMetadata.exclude;
|
||||
const result = dealPatternMatches(excludeMatches, {
|
||||
exclude: true,
|
||||
});
|
||||
|
||||
if (!registerScript.excludeMatches) {
|
||||
registerScript.excludeMatches = [];
|
||||
}
|
||||
// registerScript.excludeMatches.push(...result.patternResult);
|
||||
scriptMatchInfo.customizeExcludeMatches = result.result;
|
||||
}
|
||||
|
||||
// 将脚本match信息放入缓存中
|
||||
this.addScriptMatch(scriptMatchInfo);
|
||||
|
||||
// 如果脚本开启, 则注册脚本
|
||||
if (this.isEnableDeveloperMode && this.isEnableUserscribe && script.status === SCRIPT_STATUS_ENABLE) {
|
||||
if (scriptRes.metadata["noframes"]) {
|
||||
registerScript.allFrames = false;
|
||||
} else {
|
||||
registerScript.allFrames = true;
|
||||
}
|
||||
if (scriptRes.metadata["run-at"]) {
|
||||
registerScript.runAt = getRunAt(scriptRes.metadata["run-at"]);
|
||||
}
|
||||
const res = await chrome.userScripts.getScripts({ ids: [script.uuid] });
|
||||
const logger = LoggerCore.logger({
|
||||
name: script.name,
|
||||
registerMatch: {
|
||||
matches: registerScript.matches,
|
||||
excludeMatches: registerScript.excludeMatches,
|
||||
},
|
||||
});
|
||||
if (res.length > 0) {
|
||||
await chrome.userScripts.update([registerScript], () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
logger.error("update registerScript error", {
|
||||
error: chrome.runtime.lastError,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await chrome.userScripts.register([registerScript], () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
logger.error("registerScript error", {
|
||||
error: chrome.runtime.lastError,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
await Cache.getInstance().set("registryScript:" + script.uuid, true);
|
||||
}
|
||||
}
|
||||
|
||||
async unregistryPageScript(uuid: string) {
|
||||
if (
|
||||
!this.isEnableDeveloperMode ||
|
||||
!this.isEnableUserscribe ||
|
||||
!(await Cache.getInstance().get("registryScript:" + uuid))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 删除缓存
|
||||
Cache.getInstance().del("registryScript:" + uuid);
|
||||
// 修改脚本状态为disable
|
||||
this.updateScriptStatus(uuid, SCRIPT_STATUS_DISABLE);
|
||||
chrome.userScripts.unregister({ ids: [uuid] });
|
||||
}
|
||||
}
|
||||
|
@@ -1,11 +1,11 @@
|
||||
import { fetchScriptInfo } from "@App/pkg/utils/script";
|
||||
import { fetchScriptInfo, prepareScriptByCode } from "@App/pkg/utils/script";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Group } from "@Packages/message/server";
|
||||
import Logger from "@App/app/logger/logger";
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import Cache from "@App/app/cache";
|
||||
import CacheKey from "@App/app/cache_key";
|
||||
import { openInCurrentTab, randomString } from "@App/pkg/utils/utils";
|
||||
import { checkSilenceUpdate, ltever, openInCurrentTab, randomString } from "@App/pkg/utils/utils";
|
||||
import {
|
||||
Script,
|
||||
SCRIPT_RUN_STATUS,
|
||||
@@ -19,7 +19,9 @@ import { MessageQueue } from "@Packages/message/message_queue";
|
||||
import { InstallSource } from ".";
|
||||
import { ResourceService } from "./resource";
|
||||
import { ValueService } from "./value";
|
||||
import { compileScriptCode } from "@App/runtime/content/utils";
|
||||
import { compileScriptCode } from "../content/utils";
|
||||
import { SystemConfig } from "@App/pkg/config/config";
|
||||
import i18n, { localePath } from "@App/locales/locales";
|
||||
|
||||
export class ScriptService {
|
||||
logger: Logger;
|
||||
@@ -27,6 +29,7 @@ export class ScriptService {
|
||||
scriptCodeDAO: ScriptCodeDAO = new ScriptCodeDAO();
|
||||
|
||||
constructor(
|
||||
private systemConfig: SystemConfig,
|
||||
private group: Group,
|
||||
private mq: MessageQueue,
|
||||
private valueService: ValueService,
|
||||
@@ -57,50 +60,55 @@ export class ScriptService {
|
||||
// 读取脚本url内容, 进行安装
|
||||
const logger = this.logger.with({ url: targetUrl });
|
||||
logger.debug("install script");
|
||||
this.openInstallPageByUrl(targetUrl).catch((e) => {
|
||||
logger.error("install script error", Logger.E(e));
|
||||
// 如果打开失败, 则重定向到安装页
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId: req.tabId },
|
||||
func: function () {
|
||||
history.back();
|
||||
},
|
||||
});
|
||||
// 并不再重定向当前url
|
||||
chrome.declarativeNetRequest.updateDynamicRules(
|
||||
{
|
||||
removeRuleIds: [2],
|
||||
addRules: [
|
||||
{
|
||||
id: 2,
|
||||
priority: 1,
|
||||
action: {
|
||||
type: chrome.declarativeNetRequest.RuleActionType.ALLOW,
|
||||
this.openInstallPageByUrl(targetUrl, "user")
|
||||
.catch((e) => {
|
||||
logger.error("install script error", Logger.E(e));
|
||||
// 不再重定向当前url
|
||||
chrome.declarativeNetRequest.updateDynamicRules(
|
||||
{
|
||||
removeRuleIds: [2],
|
||||
addRules: [
|
||||
{
|
||||
id: 2,
|
||||
priority: 1,
|
||||
action: {
|
||||
type: chrome.declarativeNetRequest.RuleActionType.ALLOW,
|
||||
},
|
||||
condition: {
|
||||
regexFilter: targetUrl,
|
||||
resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME],
|
||||
requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET],
|
||||
},
|
||||
},
|
||||
condition: {
|
||||
regexFilter: targetUrl,
|
||||
resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME],
|
||||
requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
() => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error(chrome.runtime.lastError);
|
||||
],
|
||||
},
|
||||
() => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error(chrome.runtime.lastError);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
// 回退到到安装页
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId: req.tabId },
|
||||
func: function () {
|
||||
history.back();
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
urls: [
|
||||
"https://docs.scriptcat.org/docs/script_installation",
|
||||
"https://docs.scriptcat.org/docs/script_installation/",
|
||||
"https://docs.scriptcat.org/en/docs/script_installation/",
|
||||
"https://www.tampermonkey.net/script_installation.php",
|
||||
],
|
||||
types: ["main_frame"],
|
||||
}
|
||||
);
|
||||
// 获取i18n
|
||||
// 重定向到脚本安装页
|
||||
chrome.declarativeNetRequest.updateDynamicRules(
|
||||
{
|
||||
@@ -112,7 +120,7 @@ export class ScriptService {
|
||||
action: {
|
||||
type: chrome.declarativeNetRequest.RuleActionType.REDIRECT,
|
||||
redirect: {
|
||||
regexSubstitution: "https://docs.scriptcat.org/docs/script_installation#url=\\0",
|
||||
regexSubstitution: `https://docs.scriptcat.org${localePath}/docs/script_installation/#url=\\0`,
|
||||
},
|
||||
},
|
||||
condition: {
|
||||
@@ -133,18 +141,31 @@ export class ScriptService {
|
||||
);
|
||||
}
|
||||
|
||||
public openInstallPageByUrl(url: string) {
|
||||
public openInstallPageByUrl(url: string, source: InstallSource) {
|
||||
const uuid = uuidv4();
|
||||
return fetchScriptInfo(url, "user", false, uuidv4()).then((info) => {
|
||||
return fetchScriptInfo(url, source, false, uuidv4()).then((info) => {
|
||||
Cache.getInstance().set(CacheKey.scriptInstallInfo(uuid), info);
|
||||
setTimeout(() => {
|
||||
// 清理缓存
|
||||
Cache.getInstance().del(CacheKey.scriptInstallInfo(uuid));
|
||||
}, 60 * 1000);
|
||||
}, 30 * 1000);
|
||||
openInCurrentTab(`/src/install.html?uuid=${uuid}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 直接通过url静默安装脚本
|
||||
async installByUrl(url: string, source: InstallSource, subscribeUrl?: string) {
|
||||
const info = await fetchScriptInfo(url, source, false, uuidv4());
|
||||
const prepareScript = await prepareScriptByCode(info.code, url, info.uuid);
|
||||
prepareScript.script.subscribeUrl = subscribeUrl;
|
||||
this.installScript({
|
||||
script: prepareScript.script,
|
||||
code: info.code,
|
||||
upsertBy: source,
|
||||
});
|
||||
return Promise.resolve(prepareScript.script);
|
||||
}
|
||||
|
||||
// 获取安装信息
|
||||
getInstallInfo(uuid: string) {
|
||||
return Cache.getInstance().get(CacheKey.scriptInstallInfo(uuid));
|
||||
@@ -177,8 +198,8 @@ export class ScriptService {
|
||||
});
|
||||
logger.info("install success");
|
||||
// 广播一下
|
||||
this.mq.publish("installScript", { script, update });
|
||||
return Promise.resolve(true);
|
||||
this.mq.publish("installScript", { script, update, upsertBy });
|
||||
return Promise.resolve({ update });
|
||||
})
|
||||
.catch((e: any) => {
|
||||
logger.error("install error", Logger.E(e));
|
||||
@@ -198,7 +219,7 @@ export class ScriptService {
|
||||
.then(() => {
|
||||
logger.info("delete success");
|
||||
this.mq.publish("deleteScript", { uuid });
|
||||
return {};
|
||||
return true;
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("delete error", Logger.E(e));
|
||||
@@ -235,7 +256,6 @@ export class ScriptService {
|
||||
}
|
||||
|
||||
async updateRunStatus(params: { uuid: string; runStatus: SCRIPT_RUN_STATUS; error?: string; nextruntime?: number }) {
|
||||
this.mq.publish("updateRunStatus", params);
|
||||
if (
|
||||
(await this.scriptDAO.update(params.uuid, {
|
||||
runStatus: params.runStatus,
|
||||
@@ -246,6 +266,7 @@ export class ScriptService {
|
||||
) {
|
||||
return Promise.reject("update error");
|
||||
}
|
||||
this.mq.publish("scriptRunStatus", params);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -273,12 +294,207 @@ export class ScriptService {
|
||||
if (!code) {
|
||||
throw new Error("code is null");
|
||||
}
|
||||
ret.code = compileScriptCode(ret, code.code);
|
||||
ret.code = code.code;
|
||||
ret.code = compileScriptCode(ret);
|
||||
|
||||
return Promise.resolve(ret);
|
||||
}
|
||||
|
||||
async init() {
|
||||
async excludeUrl({ uuid, url, remove }: { uuid: string; url: string; remove: boolean }) {
|
||||
const script = await this.scriptDAO.get(uuid);
|
||||
if (!script) {
|
||||
throw new Error("script not found");
|
||||
}
|
||||
script.selfMetadata = script.selfMetadata || {};
|
||||
let excludes = script.selfMetadata.exclude || script.metadata.exclude || [];
|
||||
if (remove) {
|
||||
excludes = excludes.filter((item) => item !== url);
|
||||
} else {
|
||||
excludes.push(url);
|
||||
}
|
||||
script.selfMetadata.exclude = excludes;
|
||||
return this.scriptDAO
|
||||
.update(uuid, script)
|
||||
.then(() => {
|
||||
// 广播一下
|
||||
this.mq.publish("installScript", { script, update: true });
|
||||
return true;
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger.error("exclude url error", Logger.E(e));
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
async resetExclude({ uuid, exclude }: { uuid: string; exclude: string[] | undefined }) {
|
||||
const script = await this.scriptDAO.get(uuid);
|
||||
if (!script) {
|
||||
throw new Error("script not found");
|
||||
}
|
||||
script.selfMetadata = script.selfMetadata || {};
|
||||
if (exclude) {
|
||||
script.selfMetadata.exclude = exclude;
|
||||
} else {
|
||||
delete script.selfMetadata.exclude;
|
||||
}
|
||||
return this.scriptDAO
|
||||
.update(uuid, script)
|
||||
.then(() => {
|
||||
// 广播一下
|
||||
this.mq.publish("installScript", { script, update: true });
|
||||
return true;
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger.error("reset exclude error", Logger.E(e));
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
async resetMatch({ uuid, match }: { uuid: string; match: string[] | undefined }) {
|
||||
const script = await this.scriptDAO.get(uuid);
|
||||
if (!script) {
|
||||
throw new Error("script not found");
|
||||
}
|
||||
script.selfMetadata = script.selfMetadata || {};
|
||||
if (match) {
|
||||
script.selfMetadata.match = match;
|
||||
} else {
|
||||
delete script.selfMetadata.match;
|
||||
}
|
||||
return this.scriptDAO
|
||||
.update(uuid, script)
|
||||
.then(() => {
|
||||
// 广播一下
|
||||
this.mq.publish("installScript", { script, update: true });
|
||||
return true;
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger.error("reset match error", Logger.E(e));
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
async checkUpdate(uuid: string, source: "user" | "system") {
|
||||
// 检查更新
|
||||
const script = await this.scriptDAO.get(uuid);
|
||||
if (!script) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
await this.scriptDAO.update(uuid, { checktime: new Date().getTime() });
|
||||
if (!script.checkUpdateUrl) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const logger = LoggerCore.logger({
|
||||
uuid: script.uuid,
|
||||
name: script.name,
|
||||
});
|
||||
try {
|
||||
const info = await fetchScriptInfo(script.checkUpdateUrl, source, false, script.uuid);
|
||||
const { metadata } = info;
|
||||
if (!metadata) {
|
||||
logger.error("parse metadata failed");
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const newVersion = metadata.version && metadata.version[0];
|
||||
if (!newVersion) {
|
||||
logger.error("parse version failed", { version: metadata.version });
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
let oldVersion = script.metadata.version && script.metadata.version[0];
|
||||
if (!oldVersion) {
|
||||
oldVersion = "0.0.0";
|
||||
}
|
||||
// 对比版本大小
|
||||
if (ltever(newVersion, oldVersion, logger)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
// 进行更新
|
||||
this.openUpdatePage(script, source);
|
||||
} catch (e) {
|
||||
logger.error("check update failed", Logger.E(e));
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
// 打开更新窗口
|
||||
public openUpdatePage(script: Script, source: "user" | "system") {
|
||||
const logger = this.logger.with({
|
||||
uuid: script.uuid,
|
||||
name: script.name,
|
||||
downloadUrl: script.downloadUrl,
|
||||
checkUpdateUrl: script.checkUpdateUrl,
|
||||
});
|
||||
fetchScriptInfo(script.downloadUrl || script.checkUpdateUrl!, source, true, script.uuid)
|
||||
.then(async (info) => {
|
||||
// 是否静默更新
|
||||
if (await this.systemConfig.getSilenceUpdateScript()) {
|
||||
try {
|
||||
const prepareScript = await prepareScriptByCode(
|
||||
info.code,
|
||||
script.downloadUrl || script.checkUpdateUrl!,
|
||||
script.uuid
|
||||
);
|
||||
if (checkSilenceUpdate(prepareScript.oldScript!.metadata, prepareScript.script.metadata)) {
|
||||
logger.info("silence update script");
|
||||
this.installScript({
|
||||
script: prepareScript.script,
|
||||
code: info.code,
|
||||
upsertBy: source,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("prepare script failed", Logger.E(e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 打开安装页面
|
||||
Cache.getInstance().set(CacheKey.scriptInstallInfo(info.uuid), info);
|
||||
chrome.tabs.create({
|
||||
url: `/src/install.html?uuid=${info.uuid}`,
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("fetch script info failed", Logger.E(e));
|
||||
});
|
||||
}
|
||||
|
||||
async checkScriptUpdate() {
|
||||
const checkCycle = await this.systemConfig.getCheckScriptUpdateCycle();
|
||||
if (!checkCycle) {
|
||||
return;
|
||||
}
|
||||
this.scriptDAO.all().then(async (scripts) => {
|
||||
const checkDisableScript = await this.systemConfig.getUpdateDisableScript();
|
||||
scripts.forEach(async (script) => {
|
||||
// 是否检查禁用脚本
|
||||
if (!checkDisableScript && script.status === SCRIPT_STATUS_DISABLE) {
|
||||
return;
|
||||
}
|
||||
// 检查是否符合
|
||||
if (script.checktime + checkCycle * 1000 > Date.now()) {
|
||||
return;
|
||||
}
|
||||
this.checkUpdate(script.uuid, "system");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
requestCheckUpdate(uuid: string) {
|
||||
return this.checkUpdate(uuid, "user");
|
||||
}
|
||||
|
||||
isInstalled({ name, namespace }: { name: string; namespace: string }) {
|
||||
return this.scriptDAO.findByNameAndNamespace(name, namespace).then((script) => {
|
||||
if (script) {
|
||||
return { installed: true, version: script.metadata.version && script.metadata.version[0] };
|
||||
}
|
||||
return { installed: false };
|
||||
});
|
||||
}
|
||||
|
||||
init() {
|
||||
this.listenerScriptInstall();
|
||||
|
||||
this.group.on("getInstallInfo", this.getInstallInfo);
|
||||
@@ -289,5 +505,16 @@ export class ScriptService {
|
||||
this.group.on("updateRunStatus", this.updateRunStatus.bind(this));
|
||||
this.group.on("getCode", this.getCode.bind(this));
|
||||
this.group.on("getScriptRunResource", this.buildScriptRunResource.bind(this));
|
||||
this.group.on("excludeUrl", this.excludeUrl.bind(this));
|
||||
this.group.on("resetMatch", this.resetMatch.bind(this));
|
||||
this.group.on("resetExclude", this.resetExclude.bind(this));
|
||||
this.group.on("requestCheckUpdate", this.requestCheckUpdate.bind(this));
|
||||
this.group.on("isInstalled", this.isInstalled.bind(this));
|
||||
|
||||
// 定时检查更新, 每10分钟检查一次
|
||||
chrome.alarms.create("checkScriptUpdate", {
|
||||
delayInMinutes: 10,
|
||||
periodInMinutes: 10,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
280
src/app/service/service_worker/subscribe.ts
Normal file
280
src/app/service/service_worker/subscribe.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import Logger from "@App/app/logger/logger";
|
||||
import { ScriptDAO } from "@App/app/repo/scripts";
|
||||
import {
|
||||
Subscribe,
|
||||
SUBSCRIBE_STATUS_DISABLE,
|
||||
SUBSCRIBE_STATUS_ENABLE,
|
||||
SubscribeDAO,
|
||||
SubscribeScript,
|
||||
} from "@App/app/repo/subscribe";
|
||||
import { SystemConfig } from "@App/pkg/config/config";
|
||||
import { MessageQueue } from "@Packages/message/message_queue";
|
||||
import { Group } from "@Packages/message/server";
|
||||
import { InstallSource } from ".";
|
||||
import { publishSubscribeInstall, subscribeSubscribeInstall } from "../queue";
|
||||
import { ScriptService } from "./script";
|
||||
import { checkSilenceUpdate, InfoNotification, ltever } from "@App/pkg/utils/utils";
|
||||
import { fetchScriptInfo, prepareSubscribeByCode, ScriptInfo } from "@App/pkg/utils/script";
|
||||
import Cache from "@App/app/cache";
|
||||
import CacheKey from "@App/app/cache_key";
|
||||
|
||||
export class SubscribeService {
|
||||
logger: Logger;
|
||||
subscribeDAO = new SubscribeDAO();
|
||||
scriptDAO = new ScriptDAO();
|
||||
|
||||
constructor(
|
||||
private systemConfig: SystemConfig,
|
||||
private group: Group,
|
||||
private mq: MessageQueue,
|
||||
private scriptService: ScriptService
|
||||
) {
|
||||
this.logger = LoggerCore.logger().with({ service: "subscribe" });
|
||||
}
|
||||
|
||||
async install(param: { subscribe: Subscribe }) {
|
||||
const logger = this.logger.with({
|
||||
subscribeUrl: param.subscribe.url,
|
||||
name: param.subscribe.name,
|
||||
});
|
||||
try {
|
||||
await this.subscribeDAO.save(param.subscribe);
|
||||
logger.info("upsert subscribe success");
|
||||
publishSubscribeInstall(this.mq, {
|
||||
subscribe: param.subscribe,
|
||||
});
|
||||
return Promise.resolve(param.subscribe.url);
|
||||
} catch (e) {
|
||||
logger.error("upsert subscribe error", Logger.E(e));
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(param: { url: string }) {
|
||||
const logger = this.logger.with({
|
||||
subscribeUrl: param.url,
|
||||
});
|
||||
const subscribe = await this.subscribeDAO.get(param.url);
|
||||
if (!subscribe) {
|
||||
logger.warn("subscribe not found");
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
try {
|
||||
// 删除相关脚本
|
||||
const scripts = await this.scriptDAO.find((_, value) => {
|
||||
return value.subscribeUrl === param.url;
|
||||
});
|
||||
scripts.forEach((script) => {
|
||||
this.scriptService.deleteScript(script.uuid);
|
||||
});
|
||||
// 删除订阅
|
||||
await this.subscribeDAO.delete(param.url);
|
||||
logger.info("delete subscribe success");
|
||||
return Promise.resolve(true);
|
||||
} catch (e) {
|
||||
logger.error("uninstall subscribe error", Logger.E(e));
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新订阅的脚本
|
||||
async upsertScript(subscribe: Subscribe) {
|
||||
const logger = this.logger.with({
|
||||
url: subscribe.url,
|
||||
name: subscribe.name,
|
||||
});
|
||||
// 对比脚本是否有变化
|
||||
const addScript: string[] = [];
|
||||
const removeScript: SubscribeScript[] = [];
|
||||
const scriptUrl = subscribe.metadata.scripturl || [];
|
||||
const scripts = Object.keys(subscribe.scripts);
|
||||
scriptUrl.forEach((url) => {
|
||||
// 不存在于已安装的脚本中, 则添加
|
||||
if (!scripts.includes(url)) {
|
||||
addScript.push(url);
|
||||
}
|
||||
});
|
||||
scripts.forEach((url) => {
|
||||
// 不存在于订阅的脚本中, 则删除
|
||||
if (!scriptUrl.includes(url)) {
|
||||
removeScript.push(subscribe.scripts[url]);
|
||||
}
|
||||
});
|
||||
|
||||
const notification: string[][] = [[], []];
|
||||
const result: Promise<any>[] = [];
|
||||
// 添加脚本
|
||||
addScript.forEach((url) => {
|
||||
result.push(
|
||||
(async () => {
|
||||
const script = await this.scriptService.installByUrl(url, "subscribe", subscribe.url);
|
||||
subscribe.scripts[url] = {
|
||||
url,
|
||||
uuid: script.uuid,
|
||||
};
|
||||
notification[0].push(script.name);
|
||||
return Promise.resolve(true);
|
||||
})().catch((e) => {
|
||||
logger.error("install script failed", Logger.E(e));
|
||||
return Promise.resolve(false);
|
||||
})
|
||||
);
|
||||
});
|
||||
// 删除脚本
|
||||
removeScript.forEach((item) => {
|
||||
// 通过uuid查询脚本id
|
||||
result.push(
|
||||
(async () => {
|
||||
const script = await this.scriptDAO.findByUUID(item.uuid);
|
||||
if (script) {
|
||||
notification[1].push(script.name);
|
||||
// 删除脚本
|
||||
this.scriptService.deleteScript(script.uuid);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
})().catch((e) => {
|
||||
logger.error("delete script failed", Logger.E(e));
|
||||
return Promise.resolve(false);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.allSettled(result);
|
||||
|
||||
await this.subscribeDAO.update(subscribe.url, subscribe);
|
||||
|
||||
InfoNotification("订阅更新", `安装了:${notification[0].join(",")}\n删除了:${notification[1].join("\n")}`);
|
||||
|
||||
logger.info("subscribe update", {
|
||||
install: notification[0],
|
||||
update: notification[1],
|
||||
});
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
async checkUpdate(url: string, source: InstallSource) {
|
||||
const subscribe = await this.subscribeDAO.get(url);
|
||||
if (!subscribe) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const logger = this.logger.with({
|
||||
url: subscribe.url,
|
||||
name: subscribe.name,
|
||||
});
|
||||
await this.subscribeDAO.update(url, { checktime: new Date().getTime() });
|
||||
try {
|
||||
const info = await fetchScriptInfo(subscribe.url, source, false, subscribe.url);
|
||||
const { metadata } = info;
|
||||
if (!metadata) {
|
||||
logger.error("parse metadata failed");
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const newVersion = metadata.version && metadata.version[0];
|
||||
if (!newVersion) {
|
||||
logger.error("parse version failed", { version: metadata.version });
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
let oldVersion = subscribe.metadata.version && subscribe.metadata.version[0];
|
||||
if (!oldVersion) {
|
||||
oldVersion = "0.0.0";
|
||||
}
|
||||
// 对比版本大小
|
||||
if (ltever(newVersion, oldVersion, logger)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
// 进行更新
|
||||
this.openUpdatePage(info);
|
||||
return Promise.resolve(true);
|
||||
} catch (e) {
|
||||
logger.error("check update failed", Logger.E(e));
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
async openUpdatePage(info: ScriptInfo) {
|
||||
const logger = this.logger.with({
|
||||
url: info.url,
|
||||
});
|
||||
// 是否静默更新
|
||||
const silenceUpdate = await this.systemConfig.getSilenceUpdateScript();
|
||||
if (silenceUpdate) {
|
||||
try {
|
||||
const newSubscribe = await prepareSubscribeByCode(info.code, info.url);
|
||||
if (checkSilenceUpdate(newSubscribe.oldSubscribe!.metadata, newSubscribe.subscribe.metadata)) {
|
||||
logger.info("silence update subscribe");
|
||||
this.install({
|
||||
subscribe: newSubscribe.subscribe,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("prepare script failed", Logger.E(e));
|
||||
}
|
||||
}
|
||||
Cache.getInstance().set(CacheKey.scriptInstallInfo(info.uuid), info);
|
||||
chrome.tabs.create({
|
||||
url: `/src/install.html?uuid=${info.uuid}`,
|
||||
});
|
||||
}
|
||||
|
||||
async checkSubscribeUpdate() {
|
||||
const checkCycle = await this.systemConfig.getCheckScriptUpdateCycle();
|
||||
if (!checkCycle) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug("start check update");
|
||||
const checkDisable = await this.systemConfig.getUpdateDisableScript();
|
||||
const list = await this.subscribeDAO.find((_, value) => {
|
||||
return value.checktime + checkCycle * 1000 < Date.now();
|
||||
});
|
||||
|
||||
list.forEach((subscribe) => {
|
||||
if (!checkDisable && subscribe.status === SUBSCRIBE_STATUS_ENABLE) {
|
||||
return;
|
||||
}
|
||||
this.checkUpdate(subscribe.url, "system");
|
||||
});
|
||||
}
|
||||
|
||||
requestCheckUpdate(url: string) {
|
||||
return this.checkUpdate(url, "user");
|
||||
}
|
||||
|
||||
enable(param: { url: string; enable: boolean }) {
|
||||
const logger = this.logger.with({
|
||||
url: param.url,
|
||||
});
|
||||
return this.subscribeDAO
|
||||
.update(param.url, {
|
||||
status: param.enable ? SUBSCRIBE_STATUS_ENABLE : SUBSCRIBE_STATUS_DISABLE,
|
||||
})
|
||||
.then(() => {
|
||||
logger.info("enable subscribe success");
|
||||
return Promise.resolve(true);
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("enable subscribe error", Logger.E(e));
|
||||
return Promise.reject(e);
|
||||
});
|
||||
}
|
||||
|
||||
init() {
|
||||
this.group.on("install", this.install.bind(this));
|
||||
this.group.on("delete", this.delete.bind(this));
|
||||
this.group.on("checkUpdate", this.requestCheckUpdate.bind(this));
|
||||
this.group.on("enable", this.enable.bind(this));
|
||||
|
||||
subscribeSubscribeInstall(this.mq, (message) => {
|
||||
this.upsertScript(message.subscribe);
|
||||
});
|
||||
|
||||
// 定时检查更新, 每10分钟检查一次
|
||||
chrome.alarms.create("checkSubscribeUpdate", {
|
||||
delayInMinutes: 10,
|
||||
periodInMinutes: 10,
|
||||
});
|
||||
}
|
||||
}
|
551
src/app/service/service_worker/synchronize.ts
Normal file
551
src/app/service/service_worker/synchronize.ts
Normal file
@@ -0,0 +1,551 @@
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import Logger from "@App/app/logger/logger";
|
||||
import { Resource } from "@App/app/repo/resource";
|
||||
import { Script, SCRIPT_STATUS_ENABLE, ScriptCodeDAO, ScriptDAO } from "@App/app/repo/scripts";
|
||||
import BackupExport from "@App/pkg/backup/export";
|
||||
import { BackupData, ResourceBackup, ScriptBackupData, ScriptOptions, ValueStorage } from "@App/pkg/backup/struct";
|
||||
import FileSystem, { File } from "@Packages/filesystem/filesystem";
|
||||
import ZipFileSystem from "@Packages/filesystem/zip/zip";
|
||||
import { Group, MessageSend } from "@Packages/message/server";
|
||||
import JSZip from "jszip";
|
||||
import { ValueService } from "./value";
|
||||
import { ResourceService } from "./resource";
|
||||
import dayjs from "dayjs";
|
||||
import { createObjectURL } from "../offscreen/client";
|
||||
import FileSystemFactory, { FileSystemType } from "@Packages/filesystem/factory";
|
||||
import { CloudSyncConfig, SystemConfig } from "@App/pkg/config/config";
|
||||
import { MessageQueue } from "@Packages/message/message_queue";
|
||||
import { subscribeScriptDelete, subscribeScriptInstall } from "../queue";
|
||||
import { isWarpTokenError } from "@Packages/filesystem/error";
|
||||
import { errorMsg, InfoNotification } from "@App/pkg/utils/utils";
|
||||
import { t } from "i18next";
|
||||
import ChromeStorage from "@App/pkg/config/chrome_storage";
|
||||
import { ScriptService } from "./script";
|
||||
import { prepareScriptByCode } from "@App/pkg/utils/script";
|
||||
import { InstallSource } from ".";
|
||||
|
||||
export type SynchronizeTarget = "local";
|
||||
|
||||
type SyncFiles = {
|
||||
script: File;
|
||||
meta: File;
|
||||
};
|
||||
|
||||
export type SyncMeta = {
|
||||
uuid: string;
|
||||
origin?: string; // 脚本来源
|
||||
downloadUrl?: string;
|
||||
checkUpdateUrl?: string;
|
||||
isDeleted?: boolean;
|
||||
};
|
||||
|
||||
export class SynchronizeService {
|
||||
logger: Logger;
|
||||
|
||||
scriptDAO = new ScriptDAO();
|
||||
scriptCodeDAO = new ScriptCodeDAO();
|
||||
|
||||
storage: ChromeStorage = new ChromeStorage("sync", true);
|
||||
|
||||
constructor(
|
||||
private send: MessageSend,
|
||||
private group: Group,
|
||||
private script: ScriptService,
|
||||
private value: ValueService,
|
||||
private resource: ResourceService,
|
||||
private mq: MessageQueue,
|
||||
private systemConfig: SystemConfig
|
||||
) {
|
||||
this.logger = LoggerCore.logger().with({ service: "synchronize" });
|
||||
}
|
||||
|
||||
// 生成备份文件到文件系统
|
||||
async backup(fs: FileSystem, uuids?: string[]) {
|
||||
// 生成导出数据
|
||||
const data: BackupData = {
|
||||
script: await this.getScriptBackupData(uuids),
|
||||
subscribe: [],
|
||||
};
|
||||
|
||||
await new BackupExport(fs).export(data);
|
||||
}
|
||||
|
||||
// 获取脚本备份数据
|
||||
async getScriptBackupData(uuids?: string[]) {
|
||||
if (uuids) {
|
||||
const rets: Promise<ScriptBackupData>[] = [];
|
||||
uuids.forEach((uuid) => {
|
||||
rets.push(
|
||||
this.scriptDAO.get(uuid).then((script) => {
|
||||
if (script) {
|
||||
return this.generateScriptBackupData(script);
|
||||
}
|
||||
return Promise.reject(new Error(`Script ${uuid} not found`));
|
||||
})
|
||||
);
|
||||
});
|
||||
return Promise.all(rets);
|
||||
}
|
||||
// 获取所有脚本
|
||||
const list = await this.scriptDAO.all();
|
||||
return Promise.all(list.map(async (script): Promise<ScriptBackupData> => this.generateScriptBackupData(script)));
|
||||
}
|
||||
|
||||
async generateScriptBackupData(script: Script): Promise<ScriptBackupData> {
|
||||
const code = await this.scriptCodeDAO.get(script.uuid);
|
||||
if (!code) {
|
||||
throw new Error(`Script ${script.uuid} code not found`);
|
||||
}
|
||||
const ret = {
|
||||
code: code.code,
|
||||
options: {
|
||||
options: this.scriptOption(script),
|
||||
settings: {
|
||||
enabled: script.status === SCRIPT_STATUS_ENABLE,
|
||||
position: script.sort,
|
||||
},
|
||||
meta: {
|
||||
name: script.name,
|
||||
uuid: script.uuid,
|
||||
sc_uuid: script.uuid,
|
||||
modified: script.updatetime,
|
||||
file_url: script.downloadUrl,
|
||||
subscribe_url: script.subscribeUrl,
|
||||
},
|
||||
},
|
||||
// storage,
|
||||
requires: [],
|
||||
requiresCss: [],
|
||||
resources: [],
|
||||
} as unknown as ScriptBackupData;
|
||||
const storage: ValueStorage = {
|
||||
data: {},
|
||||
ts: new Date().getTime(),
|
||||
};
|
||||
const values = await this.value.getScriptValue(script);
|
||||
Object.keys(values).forEach((key) => {
|
||||
storage.data[key] = values[key];
|
||||
});
|
||||
|
||||
const requires = await this.resource.getResourceByType(script, "require");
|
||||
const requiresCss = await this.resource.getResourceByType(script, "require-css");
|
||||
const resources = await this.resource.getResourceByType(script, "resource");
|
||||
|
||||
ret.requires = this.resourceToBackdata(requires);
|
||||
ret.requiresCss = this.resourceToBackdata(requiresCss);
|
||||
ret.resources = this.resourceToBackdata(resources);
|
||||
|
||||
ret.storage = storage;
|
||||
return Promise.resolve(ret);
|
||||
}
|
||||
|
||||
resourceToBackdata(resource: { [key: string]: Resource }) {
|
||||
const ret: ResourceBackup[] = [];
|
||||
Object.keys(resource).forEach((key) => {
|
||||
ret.push({
|
||||
meta: {
|
||||
name: this.getUrlName(resource[key].url),
|
||||
url: resource[key].url,
|
||||
ts: resource[key].updatetime || resource[key].createtime,
|
||||
mimetype: resource[key].contentType,
|
||||
},
|
||||
source: resource[key]!.content || undefined,
|
||||
base64: resource[key]!.base64,
|
||||
});
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
|
||||
getUrlName(url: string): string {
|
||||
let index = url.indexOf("?");
|
||||
if (index !== -1) {
|
||||
url = url.substring(0, index);
|
||||
}
|
||||
index = url.lastIndexOf("/");
|
||||
if (index !== -1) {
|
||||
url = url.substring(index + 1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// 为了兼容tm
|
||||
scriptOption(script: Script): ScriptOptions {
|
||||
return {
|
||||
check_for_updates: false,
|
||||
comment: null,
|
||||
compat_foreach: false,
|
||||
compat_metadata: false,
|
||||
compat_prototypes: false,
|
||||
compat_wrappedjsobject: false,
|
||||
compatopts_for_requires: true,
|
||||
noframes: null,
|
||||
override: {
|
||||
merge_connects: true,
|
||||
merge_excludes: true,
|
||||
merge_includes: true,
|
||||
merge_matches: true,
|
||||
orig_connects: script.metadata.connect || [],
|
||||
orig_excludes: script.metadata.exclude || [],
|
||||
orig_includes: script.metadata.include || [],
|
||||
orig_matches: script.metadata.match || [],
|
||||
orig_noframes: script.metadata.noframe ? true : null,
|
||||
orig_run_at: (script.metadata.run_at && script.metadata.run_at[0]) || "document-idle",
|
||||
use_blockers: [],
|
||||
use_connects: [],
|
||||
use_excludes: [],
|
||||
use_includes: [],
|
||||
use_matches: [],
|
||||
},
|
||||
run_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 请求导出文件
|
||||
async requestExport(uuids?: string[]) {
|
||||
const zip = new JSZip();
|
||||
const fs = new ZipFileSystem(zip);
|
||||
await this.backup(fs, uuids);
|
||||
// 生成文件,并下载
|
||||
const files = await zip.generateAsync({
|
||||
type: "blob",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: {
|
||||
level: 9,
|
||||
},
|
||||
comment: "Created by Scriptcat",
|
||||
});
|
||||
const url = await createObjectURL(this.send, files);
|
||||
chrome.downloads.download({
|
||||
url,
|
||||
saveAs: true,
|
||||
filename: `scriptcat-backup-${dayjs().format("YYYY-MM-DDTHH-mm-ss")}.zip`,
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 备份到云端
|
||||
async backupToCloud({ type, params }: { type: FileSystemType; params: any }) {
|
||||
// 首先生成zip文件
|
||||
const zip = new JSZip();
|
||||
const fs = new ZipFileSystem(zip);
|
||||
await this.backup(fs);
|
||||
this.logger.info("backup to cloud");
|
||||
// 然后创建云端文件系统
|
||||
let cloudFs = await FileSystemFactory.create(type, params);
|
||||
try {
|
||||
await cloudFs.createDir("ScriptCat");
|
||||
cloudFs = await cloudFs.openDir("ScriptCat");
|
||||
// 云端文件系统写入文件
|
||||
const file = await cloudFs.create(`scriptcat-backup-${dayjs().format("YYYY-MM-DDTHH-mm-ss")}.zip`);
|
||||
await file.write(
|
||||
await zip.generateAsync({
|
||||
type: "blob",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: {
|
||||
level: 9,
|
||||
},
|
||||
comment: "Created by Scriptcat",
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error("backup to cloud error", Logger.E(e));
|
||||
return Promise.reject(e);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 开始一次云同步
|
||||
async buildFileSystem(config: CloudSyncConfig) {
|
||||
let fs: FileSystem;
|
||||
try {
|
||||
fs = await FileSystemFactory.create(config.filesystem, config.params[config.filesystem]);
|
||||
// 创建base目录
|
||||
await FileSystemFactory.mkdirAll(fs, "ScriptCat/sync");
|
||||
fs = await fs.openDir("ScriptCat/sync");
|
||||
} catch (e: any) {
|
||||
this.logger.error("create filesystem error", Logger.E(e), {
|
||||
type: config.filesystem,
|
||||
});
|
||||
// 判断错误是不是网络类型的错误, 网络类型的错误不做任何处理
|
||||
// 如果是token失效之类的错误,通知用户并关闭云同步
|
||||
if (isWarpTokenError(e)) {
|
||||
InfoNotification(
|
||||
`${t("sync_system_connect_failed")}, ${t("sync_system_closed")}`,
|
||||
`${t("sync_system_closed_description")}\n${errorMsg(e)}`
|
||||
);
|
||||
this.systemConfig.setCloudSync({
|
||||
...config,
|
||||
enable: false,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return fs;
|
||||
}
|
||||
|
||||
// 同步一次
|
||||
async syncOnce(fs: FileSystem) {
|
||||
this.logger.info("start sync once");
|
||||
// 获取文件列表
|
||||
const list = await fs.list();
|
||||
// 根据文件名生成一个map
|
||||
const uuidMap = new Map<
|
||||
string,
|
||||
{
|
||||
script?: File;
|
||||
meta?: File;
|
||||
}
|
||||
>();
|
||||
// 储存文件摘要,用于检测文件是否有变化
|
||||
const fileDigestMap =
|
||||
((await this.storage.get("file_digest")) as {
|
||||
[key: string]: string;
|
||||
}) || {};
|
||||
|
||||
list.forEach((file) => {
|
||||
if (file.name.endsWith(".user.js")) {
|
||||
const uuid = file.name.substring(0, file.name.length - 8);
|
||||
let files = uuidMap.get(uuid);
|
||||
if (!files) {
|
||||
files = {};
|
||||
uuidMap.set(uuid, files);
|
||||
}
|
||||
files.script = file;
|
||||
} else if (file.name.endsWith(".meta.json")) {
|
||||
const uuid = file.name.substring(0, file.name.length - 10);
|
||||
let files = uuidMap.get(uuid);
|
||||
if (!files) {
|
||||
files = {};
|
||||
uuidMap.set(uuid, files);
|
||||
}
|
||||
files.meta = file;
|
||||
}
|
||||
});
|
||||
|
||||
// 获取脚本列表
|
||||
const scriptList = await this.scriptDAO.all();
|
||||
// 遍历脚本列表生成一个map
|
||||
const scriptMap = new Map<string, Script>();
|
||||
scriptList.forEach((script) => {
|
||||
scriptMap.set(script.uuid, script);
|
||||
});
|
||||
// 对比脚本列表和文件列表,进行同步
|
||||
const result: Promise<void>[] = [];
|
||||
uuidMap.forEach((file, uuid) => {
|
||||
const script = scriptMap.get(uuid);
|
||||
if (script) {
|
||||
// 脚本存在但是文件不存在,则读取.meta.json内容判断是否需要删除脚本
|
||||
if (!file.script) {
|
||||
result.push(
|
||||
new Promise((resolve) => {
|
||||
const handler = async () => {
|
||||
// 读取meta文件
|
||||
const meta = await fs.open(file.meta!);
|
||||
const metaJson = (await meta.read("string")) as string;
|
||||
const metaObj = JSON.parse(metaJson) as SyncMeta;
|
||||
if (metaObj.isDeleted) {
|
||||
if (script) {
|
||||
this.script.deleteScript(script.uuid);
|
||||
InfoNotification("脚本删除同步", `脚本${script.name}已被删除`);
|
||||
}
|
||||
scriptMap.delete(uuid);
|
||||
} else {
|
||||
// 否则认为是一个无效的.meta文件,进行删除
|
||||
await fs.delete(file.meta!.path);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
handler();
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 过滤掉无变动的文件
|
||||
if (fileDigestMap[file.script!.name] === file.script!.digest) {
|
||||
// 删除了之后,剩下的就是需要上传的脚本了
|
||||
scriptMap.delete(uuid);
|
||||
return;
|
||||
}
|
||||
const updatetime = script.updatetime || script.createtime;
|
||||
// 对比脚本更新时间和文件更新时间
|
||||
if (updatetime > file.script!.updatetime) {
|
||||
// 如果脚本更新时间大于文件更新时间,则上传文件
|
||||
result.push(this.pushScript(fs, script));
|
||||
} else {
|
||||
// 如果脚本更新时间小于文件更新时间,则更新脚本
|
||||
result.push(this.pullScript(fs, file as SyncFiles, script));
|
||||
}
|
||||
scriptMap.delete(uuid);
|
||||
return;
|
||||
}
|
||||
// 如果脚本不存在,且文件存在,则安装脚本
|
||||
if (file.script) {
|
||||
result.push(this.pullScript(fs, file as SyncFiles));
|
||||
}
|
||||
});
|
||||
// 上传剩下的脚本
|
||||
scriptMap.forEach((script) => {
|
||||
result.push(this.pushScript(fs, script));
|
||||
});
|
||||
// 忽略错误
|
||||
await Promise.allSettled(result);
|
||||
// 重新获取文件列表,保存文件摘要
|
||||
await this.updateFileDigest(fs);
|
||||
this.logger.info("sync complete");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async updateFileDigest(fs: FileSystem) {
|
||||
const newList = await fs.list();
|
||||
const newFileDigestMap: { [key: string]: string } = {};
|
||||
newList.forEach((file) => {
|
||||
newFileDigestMap[file.name] = file.digest;
|
||||
});
|
||||
await this.storage.set("file_digest", newFileDigestMap);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 删除云端脚本数据
|
||||
async deleteCloudScript(fs: FileSystem, uuid: string, syncDelete: boolean) {
|
||||
const filename = `${uuid}.user.js`;
|
||||
const logger = this.logger.with({
|
||||
uuid: uuid,
|
||||
file: filename,
|
||||
});
|
||||
try {
|
||||
await fs.delete(filename);
|
||||
if (syncDelete) {
|
||||
// 留下一个.meta.json删除标记
|
||||
const meta = await fs.create(`${uuid}.meta.json`);
|
||||
await meta.write(
|
||||
JSON.stringify(<SyncMeta>{
|
||||
uuid: uuid,
|
||||
// origin: script.origin,
|
||||
// downloadUrl: script.downloadUrl,
|
||||
// checkUpdateUrl: script.checkUpdateUrl,
|
||||
isDeleted: true,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// 直接删除所有相关文件
|
||||
await fs.delete(filename);
|
||||
await fs.delete(`${uuid}.meta.json`);
|
||||
}
|
||||
logger.info("delete success");
|
||||
} catch (e) {
|
||||
logger.error("delete file error", Logger.E(e));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 上传脚本
|
||||
async pushScript(fs: FileSystem, script: Script) {
|
||||
const filename = `${script.uuid}.user.js`;
|
||||
const logger = this.logger.with({
|
||||
uuid: script.uuid,
|
||||
name: script.name,
|
||||
file: filename,
|
||||
});
|
||||
try {
|
||||
const w = await fs.create(filename);
|
||||
// 获取脚本代码
|
||||
const code = await this.scriptCodeDAO.get(script.uuid);
|
||||
await w.write(code!.code);
|
||||
const meta = await fs.create(`${script.uuid}.meta.json`);
|
||||
await meta.write(
|
||||
JSON.stringify(<SyncMeta>{
|
||||
uuid: script.uuid,
|
||||
origin: script.origin,
|
||||
downloadUrl: script.downloadUrl,
|
||||
checkUpdateUrl: script.checkUpdateUrl,
|
||||
})
|
||||
);
|
||||
logger.info("push script success");
|
||||
} catch (e) {
|
||||
logger.error("push script error", Logger.E(e));
|
||||
throw e;
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async pullScript(fs: FileSystem, file: SyncFiles, script?: Script) {
|
||||
const logger = this.logger.with({
|
||||
uuid: script?.uuid || "",
|
||||
name: script?.name || "",
|
||||
file: file.script.name,
|
||||
});
|
||||
try {
|
||||
// 读取代码文件
|
||||
const r = await fs.open(file.script);
|
||||
const code = (await r.read("string")) as string;
|
||||
// 读取meta文件
|
||||
const meta = await fs.open(file.meta);
|
||||
const metaJson = (await meta.read("string")) as string;
|
||||
const metaObj = JSON.parse(metaJson) as SyncMeta;
|
||||
const prepareScript = await prepareScriptByCode(
|
||||
code,
|
||||
script?.downloadUrl || metaObj.downloadUrl || "",
|
||||
script?.uuid || metaObj.uuid
|
||||
);
|
||||
prepareScript.script.origin = prepareScript.script.origin || metaObj.origin;
|
||||
this.script.installScript({
|
||||
script: prepareScript.script,
|
||||
code: code,
|
||||
upsertBy: "sync",
|
||||
});
|
||||
logger.info("pull script success");
|
||||
} catch (e) {
|
||||
logger.error("pull script error", Logger.E(e));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
cloudSyncConfigChange(value: CloudSyncConfig) {
|
||||
if (value.enable) {
|
||||
// 开启云同步同步
|
||||
this.buildFileSystem(value).then(async (fs) => {
|
||||
await this.syncOnce(fs);
|
||||
// 开启定时器, 一小时一次
|
||||
chrome.alarms.create("cloudSync", {
|
||||
periodInMinutes: 60,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 停止计时器
|
||||
chrome.alarms.clear("cloudSync");
|
||||
}
|
||||
}
|
||||
|
||||
async scriptInstall(params: { script: Script; update: boolean; upsertBy: InstallSource }) {
|
||||
if (params.upsertBy === "sync") {
|
||||
return;
|
||||
}
|
||||
// 判断是否开启了同步
|
||||
const config = await this.systemConfig.getCloudSync();
|
||||
if (config.enable) {
|
||||
this.buildFileSystem(config).then(async (fs) => {
|
||||
await this.pushScript(fs, params.script);
|
||||
this.updateFileDigest(fs);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async scriptDelete(script: { uuid: string }) {
|
||||
// 判断是否开启了同步
|
||||
const config = await this.systemConfig.getCloudSync();
|
||||
if (config.enable) {
|
||||
this.buildFileSystem(config).then(async (fs) => {
|
||||
await this.deleteCloudScript(fs, script.uuid, config.syncDelete);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.group.on("export", this.requestExport.bind(this));
|
||||
this.group.on("backupToCloud", this.backupToCloud.bind(this));
|
||||
// this.group.on("import", this.openImportWindow.bind(this));
|
||||
// 监听脚本变化, 进行同步
|
||||
subscribeScriptInstall(this.mq, this.scriptInstall.bind(this));
|
||||
subscribeScriptDelete(this.mq, this.scriptDelete.bind(this));
|
||||
}
|
||||
}
|
61
src/app/service/service_worker/utils.ts
Normal file
61
src/app/service/service_worker/utils.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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("")))
|
||||
);
|
||||
}
|
||||
|
||||
export function getRunAt(runAts: string[]): chrome.userScripts.RunAt {
|
||||
if (runAts.length === 0) {
|
||||
return "document_idle";
|
||||
}
|
||||
const runAt = runAts[0];
|
||||
if (runAt === "document-start") {
|
||||
return "document_start";
|
||||
} else if (runAt === "document-end") {
|
||||
return "document_end";
|
||||
}
|
||||
return "document_idle";
|
||||
}
|
||||
|
||||
export function mapToObject(map: Map<string, any>): { [key: string]: any } {
|
||||
const obj: { [key: string]: any } = {};
|
||||
map.forEach((value, key) => {
|
||||
if (value instanceof Map) {
|
||||
obj[key] = mapToObject(value);
|
||||
} else if (obj[key] instanceof Array) {
|
||||
obj[key].push(value);
|
||||
} else {
|
||||
obj[key] = value;
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function objectToMap(obj: { [key: string]: any }): Map<string, any> {
|
||||
const map = new Map<string, any>();
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (obj[key] instanceof Map) {
|
||||
map.set(key, objectToMap(obj[key]));
|
||||
} else if (obj[key] instanceof Array) {
|
||||
map.set(key, obj[key]);
|
||||
} else {
|
||||
map.set(key, obj[key]);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
export function arrayToObject(arr: Array<any>): any[] {
|
||||
const obj: any[] = [];
|
||||
arr.forEach((item) => {
|
||||
if (item instanceof Map) {
|
||||
obj.push(mapToObject(item));
|
||||
} else if (item instanceof Array) {
|
||||
obj.push(arrayToObject(item));
|
||||
} else {
|
||||
obj.push(item);
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
}
|
@@ -1,55 +1,115 @@
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import Logger from "@App/app/logger/logger";
|
||||
import { Script, ScriptDAO } from "@App/app/repo/scripts";
|
||||
import { Script, SCRIPT_TYPE_NORMAL, ScriptDAO } from "@App/app/repo/scripts";
|
||||
import { ValueDAO } from "@App/app/repo/value";
|
||||
import { storageKey } from "@App/runtime/utils";
|
||||
import { MessageQueue } from "@Packages/message/message_queue";
|
||||
import { Group } from "@Packages/message/server";
|
||||
import { GetSender, Group, MessageSend } from "@Packages/message/server";
|
||||
import { RuntimeService } from "./runtime";
|
||||
import { PopupService } from "./popup";
|
||||
import { sendMessage } from "@Packages/message/client";
|
||||
import Cache from "@App/app/cache";
|
||||
import { getStorageName } from "@App/pkg/utils/utils";
|
||||
import { ValueUpdateData, ValueUpdateSender } from "../content/exec_script";
|
||||
|
||||
export class ValueService {
|
||||
logger: Logger;
|
||||
scriptDAO: ScriptDAO = new ScriptDAO();
|
||||
valueDAO: ValueDAO = new ValueDAO();
|
||||
private popup: PopupService | undefined;
|
||||
private runtime: RuntimeService | undefined;
|
||||
|
||||
constructor(
|
||||
private group: Group,
|
||||
private mq: MessageQueue
|
||||
private send: MessageSend
|
||||
) {
|
||||
this.logger = LoggerCore.logger().with({ service: "value" });
|
||||
}
|
||||
|
||||
async getScriptValue(script: Script) {
|
||||
const ret = await this.valueDAO.get(storageKey(script));
|
||||
const ret = await this.valueDAO.get(getStorageName(script));
|
||||
if (!ret) {
|
||||
return {};
|
||||
}
|
||||
return Promise.resolve(ret?.data);
|
||||
return ret.data;
|
||||
}
|
||||
|
||||
async setValue(uuid: string, key: string, value: any): Promise<boolean> {
|
||||
async setValue(uuid: string, key: string, value: any, sender: ValueUpdateSender): Promise<boolean> {
|
||||
// 查询出脚本
|
||||
const script = await this.scriptDAO.get(uuid);
|
||||
if (!script) {
|
||||
return Promise.reject(new Error("script not found"));
|
||||
}
|
||||
// 查询老的值
|
||||
const oldValue = await this.valueDAO.get(storageKey(script));
|
||||
if (!oldValue) {
|
||||
this.valueDAO.save(storageKey(script), {
|
||||
uuid: script.uuid,
|
||||
storageName: storageKey(script),
|
||||
data: { [key]: value },
|
||||
createtime: Date.now(),
|
||||
updatetime: Date.now(),
|
||||
const storageName = getStorageName(script);
|
||||
let oldValue;
|
||||
// 使用事务来保证数据一致性
|
||||
await Cache.getInstance().tx("setValue:" + storageName, async () => {
|
||||
const valueModel = await this.valueDAO.get(storageName);
|
||||
if (!valueModel) {
|
||||
await this.valueDAO.save(storageName, {
|
||||
uuid: script.uuid,
|
||||
storageName: storageName,
|
||||
data: { [key]: value },
|
||||
createtime: Date.now(),
|
||||
updatetime: Date.now(),
|
||||
});
|
||||
} else {
|
||||
oldValue = valueModel.data[key];
|
||||
if (value === undefined) {
|
||||
delete valueModel.data[key];
|
||||
} else {
|
||||
valueModel.data[key] = value;
|
||||
}
|
||||
await this.valueDAO.save(storageName, valueModel);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const sendData: ValueUpdateData = {
|
||||
oldValue,
|
||||
sender,
|
||||
value,
|
||||
key,
|
||||
uuid,
|
||||
storageName: storageName,
|
||||
};
|
||||
|
||||
chrome.tabs.query({}, (tabs) => {
|
||||
// 推送到所有加载了本脚本的tab中
|
||||
tabs.forEach(async (tab) => {
|
||||
const scriptMenu = await this.popup!.getScriptMenu(tab.id!);
|
||||
if (scriptMenu.find((item) => item.storageName === storageName)) {
|
||||
this.runtime!.sendMessageToTab(
|
||||
{
|
||||
tabId: tab.id!,
|
||||
},
|
||||
"valueUpdate",
|
||||
sendData
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
oldValue.data[key] = value;
|
||||
this.valueDAO.save(storageKey(script), oldValue);
|
||||
}
|
||||
});
|
||||
// 推送到offscreen中
|
||||
this.runtime!.sendMessageToTab(
|
||||
{
|
||||
tabId: -1,
|
||||
},
|
||||
"valueUpdate",
|
||||
sendData
|
||||
);
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
init() {
|
||||
setScriptValue(data: { uuid: string; key: string; value: any }, sender: GetSender) {
|
||||
return this.setValue(data.uuid, data.key, data.value, {
|
||||
runFlag: "user",
|
||||
tabId: -2,
|
||||
});
|
||||
}
|
||||
|
||||
init(runtime: RuntimeService, popup: PopupService) {
|
||||
this.popup = popup;
|
||||
this.runtime = runtime;
|
||||
this.group.on("getScriptValue", this.getScriptValue.bind(this));
|
||||
this.group.on("setScriptValue", this.setScriptValue.bind(this));
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,10 @@
|
||||
"scriptcat": {
|
||||
"message": "ScriptCat"
|
||||
},
|
||||
"scriptcat_beta": {
|
||||
"message": "ScriptCat Beta"
|
||||
},
|
||||
"scriptcat_description": {
|
||||
"message": "Everything can be scripted, allowing your browser to do more!"
|
||||
}
|
||||
}
|
||||
}
|
@@ -5,6 +5,9 @@
|
||||
"scriptcat": {
|
||||
"message": "ScriptCat"
|
||||
},
|
||||
"scriptcat_beta": {
|
||||
"message": "ScriptCat Beta"
|
||||
},
|
||||
"scriptcat_description": {
|
||||
"message": "Mọi thứ đều có thể viết được, cho phép trình duyệt của bạn làm được nhiều việc hơn!"
|
||||
}
|
||||
|
@@ -5,6 +5,9 @@
|
||||
"scriptcat": {
|
||||
"message": "脚本猫"
|
||||
},
|
||||
"scriptcat_beta": {
|
||||
"message": "脚本猫 Beta"
|
||||
},
|
||||
"scriptcat_description": {
|
||||
"message": "万物皆可脚本化,让你的浏览器可以做更多的事情!"
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user