Compare commits

..

54 Commits

Author SHA1 Message Date
ec28795dbb 脚本开启总开关 2025-04-29 14:14:10 +08:00
44041f4735 打开文档页面/更新日志 2025-04-29 11:57:16 +08:00
ffabe268b1 修复匹配问题与优化批量开启速度 2025-04-29 11:53:59 +08:00
ddd3219bae 更大范围的脚本匹配 2025-04-29 11:28:01 +08:00
14baa176d9 🐛 修复隐藏排序问题 #317 2025-04-29 10:42:29 +08:00
3c1e30182f 优化打包体积 2025-04-29 10:25:15 +08:00
1aaf1bbd4a 修复首次打开浏览器加载脚本的问题 2025-04-28 23:24:11 +08:00
8a216933ca vscode reconnect 2025-04-28 18:04:20 +08:00
51fe2a89e1 开启开发者模式引导 2025-04-28 15:20:26 +08:00
a26f1c5014 优化细节 2025-04-27 18:02:57 +08:00
e1a890a400 处理gm log和新建脚本问题 2025-04-25 16:44:00 +08:00
79e8b8869a 脚本设置 2025-04-25 15:41:02 +08:00
d761c62500 value设置 2025-04-24 22:48:22 +08:00
d200809fee temp 2025-04-24 18:05:12 +08:00
67ba515b2c 数据迁移 2025-04-24 17:22:02 +08:00
d9fdded7fb 数据迁移 2025-04-23 18:07:46 +08:00
498d36567b todo: 优化加载的脚本资源保存 2025-04-22 18:01:20 +08:00
d7adffcd9f 脚本订阅功能 2025-04-22 17:42:54 +08:00
44066d9543 升级版本 2025-04-22 10:40:05 +08:00
9a53c4e4e9 处理备份列表报错问题 2025-04-22 00:28:05 +08:00
1de1ba6373 云同步功能 2025-04-21 18:02:35 +08:00
185ba6e5cc 云同步配置 2025-04-18 18:01:05 +08:00
07c4518cba 导入导出 2025-04-17 00:58:08 +08:00
e2832093f0 synchronize服务 2025-04-16 18:01:52 +08:00
44e1449e03 检查更新 2025-04-16 16:48:58 +08:00
1a531dfad5 CAT API 2025-04-16 14:01:26 +08:00
071e728f06 权限操作 2025-04-16 10:31:16 +08:00
44b6f11b19 权限确认 2025-04-15 18:06:31 +08:00
2a0286e47d eslint 2025-04-15 15:35:35 +08:00
c7763227d0 setting 2025-04-15 00:52:23 +08:00
b76a685988 添加filesystem 2025-04-14 18:04:04 +08:00
3b2e72127f 处理 gm add element csp问题 2025-04-14 16:24:04 +08:00
1965137191 修复一些资源加载问题 2025-04-14 15:02:01 +08:00
d697928fb0 基本实现完成GM API 2025-04-12 02:05:10 +08:00
5c0d4a2560 alpha.2版本 2025-04-11 17:48:23 +08:00
7ca85801ef 添加GM element API 2025-04-11 17:40:25 +08:00
a2870eb18e 处理后台脚本API 2025-04-10 18:07:35 +08:00
239f961485 添加包管理器
Some checks failed
build / Build (push) Failing after 4s
test / Run tests (push) Failing after 3s
2025-04-10 11:48:50 +08:00
088f5ae68f 添加pnpm
Some checks failed
build / Build (push) Failing after 7s
test / Run tests (push) Failing after 8s
2025-04-10 11:44:13 +08:00
e94045572d 添加action
Some checks failed
build / Build (push) Failing after 8s
test / Run tests (push) Failing after 8s
2025-04-10 11:38:06 +08:00
259917545e 脚本打包
Some checks failed
build / Build (push) Failing after 6s
test / Run tests (push) Failing after 8s
2025-04-09 22:33:46 +08:00
0d86dae710 value问题处理
Some checks failed
test / Run tests (push) Failing after 3s
build / Build (push) Failing after 6s
2025-04-09 18:05:59 +08:00
9f70b7eb7a value变更
Some checks failed
test / Run tests (push) Failing after 3s
build / Build (push) Failing after 5s
2025-04-08 23:54:06 +08:00
3e660a2ea8 运行状态
Some checks failed
build / Build (push) Failing after 5s
test / Run tests (push) Failing after 8s
2025-04-08 18:07:13 +08:00
d97a64c644 popup细节
Some checks failed
test / Run tests (push) Failing after 3s
build / Build (push) Failing after 5s
2025-04-08 18:04:48 +08:00
42975d47cf gm 菜单响应
Some checks failed
test / Run tests (push) Failing after 8s
build / Build (push) Failing after 10s
2025-04-08 13:59:08 +08:00
f26aecd10f chrome菜单
Some checks failed
test / Run tests (push) Failing after 3s
build / Build (push) Failing after 5s
2025-04-07 22:20:22 +08:00
c43afb0a94 popup页与注册菜单
Some checks failed
test / Run tests (push) Failing after 3s
build / Build (push) Failing after 6s
2025-04-07 18:01:44 +08:00
a7620dd7e5 popup页面
Some checks failed
build / Build (push) Failing after 7s
test / Run tests (push) Failing after 8s
2025-04-07 01:35:43 +08:00
1a55bb348f 实现gmxhr
Some checks failed
build / Build (push) Failing after 6s
test / Run tests (push) Failing after 8s
2025-04-07 00:37:58 +08:00
a8054451ac inject的GM API调用
Some checks failed
build / Build (push) Failing after 7s
test / Run tests (push) Failing after 8s
2025-04-06 00:32:20 +08:00
651384f12c 脚本匹配与注入
Some checks failed
build / Build (push) Failing after 5s
test / Run tests (push) Failing after 8s
2025-04-05 00:53:59 +08:00
9ce1826a34 脚本注入逻辑
Some checks failed
test / Run tests (push) Failing after 7s
build / Build (push) Failing after 10s
2025-04-03 16:59:39 +08:00
eea3b43e0b match
Some checks failed
build / Build (push) Failing after 6s
test / Run tests (push) Failing after 8s
2025-04-03 16:56:53 +08:00
148 changed files with 11938 additions and 3777 deletions

View File

@@ -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: |

View File

@@ -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"

View File

@@ -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
View File

@@ -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

View File

@@ -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([

View File

@@ -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"

View File

@@ -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/",

View File

@@ -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"
);

View File

@@ -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());

View 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());

View 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();
});

View 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);

View 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()
});

View File

@@ -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": "",
},

View File

@@ -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,26 +26,28 @@
"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",
"@types/chrome": "^0.0.279",
"@types/crypto-js": "^4.2.2",
@@ -57,23 +60,27 @@
"@unocss/postcss": "0.65.0-beta.2",
"@vitest/coverage-v8": "2.1.4",
"autoprefixer": "^10.4.20",
"compression-webpack-plugin": "^11.1.0",
"cross-env": "^7.0.3",
"eslint": "^9.19.0",
"crx": "^5.0.1",
"eslint": "^9.24.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-userscripts": "^0.2.12",
"fake-indexeddb": "^6.0.0",
"globals": "^15.14.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.4.2",
"prettier": "^3.5.3",
"react-refresh": "^0.16.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.22.0",
"typescript-eslint": "^8.29.0",
"unocss": "0.65.0-beta.2",
"vitest": "^2.1.4"
}
},
"packageManager": "pnpm@10.8.0"
}

View File

@@ -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" },

View File

@@ -195,6 +195,7 @@ const compatMap = {
exportValue: [],
exportCookie: [],
scriptUrl: [],
storageName: [],
},
};

View File

@@ -126,6 +126,6 @@ const config = {
};
// 以文本形式导出默认规则
const defaultConfig = JSON.stringify(config);
const defaultConfig = JSON.stringify(config, null, 2);
export { defaultConfig, userscriptsConfig, userscriptsRules };

View File

@@ -0,0 +1,8 @@
# 文件系统
用于同步和备份至云端
- zip
- webdav
- 百度网盘
- onedrive

144
packages/filesystem/auth.ts Normal file
View 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);
}

View 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)}`);
}
}

View 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();
});
}
}

View 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;
}

View 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();
});
}
}

View 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>;
}

View 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.");
}
}

View 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,
});
}
}

View 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;
}

View 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"));
}
}

View 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);
}
}

View 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();
}
}

View 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.");
}
}

View File

@@ -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;
}
}
}

View File

@@ -17,7 +17,7 @@ export class CustomEventMessage implements Message {
EE: EventEmitter = new EventEmitter();
// 关联dom目标
relatedTarget: Map<number, Element> = new Map();
relatedTarget: Map<number, EventTarget> = new Map();
constructor(
protected flag: string,
@@ -25,7 +25,7 @@ export class CustomEventMessage implements Message {
) {
window.addEventListener((isContent ? "ct" : "fd") + flag, (event) => {
if (event instanceof MouseEvent) {
this.relatedTarget.set(event.clientX, <Element>event.relatedTarget);
this.relatedTarget.set(event.clientX, event.relatedTarget!);
return;
} else if (event instanceof CustomEvent) {
this.messageHandle(event.detail, new CustomEventPostMessage(this));
@@ -82,23 +82,7 @@ export class CustomEventMessage implements Message {
});
}
nativeSend(data: any) {
let detail = data;
// 特殊处理relatedTarget
if (detail.data && typeof detail.data.relatedTarget === "object") {
// 先将relatedTarget转换成id发送过去
const target = detail.data.relatedTarget;
delete detail.data.relatedTarget;
detail.data.relatedTarget = Math.ceil(Math.random() * 1000000);
// 可以使用此种方式交互element
const ev = new MouseEvent((this.isContent ? "fd" : "ct") + this.flag, {
clientX: detail.data.relatedTarget,
relatedTarget: target,
});
window.dispatchEvent(ev);
}
nativeSend(detail: any) {
if (typeof cloneInto !== "undefined") {
try {
LoggerCore.logger().info("nativeSend");
@@ -130,4 +114,44 @@ export class CustomEventMessage implements Message {
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;
}
}

View File

@@ -1,4 +1,6 @@
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() {}
@@ -44,6 +46,10 @@ export class ServiceWorkerMessage extends ExtensionMessageSend implements Messag
}
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) => {
@@ -52,6 +58,16 @@ export class ExtensionMessage extends ExtensionMessageSend implements Message {
};
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才能处理异步请求
@@ -62,6 +78,15 @@ export class ExtensionMessage extends ExtensionMessageSend implements Message {
}
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);
});
}
}
}
@@ -84,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));
});
}
}

View File

@@ -1,4 +1,5 @@
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;
@@ -17,7 +18,13 @@ export interface MessageConnect {
onDisconnect(callback: () => void): void;
}
export type MessageSender = any;
export type MessageSender = chrome.runtime.MessageSender;
export type ExtMessageSender = {
tabId: number;
frameId?: number;
documentId?: string;
};
export class GetSender {
constructor(private sender: MessageConnect | MessageSender) {}
@@ -26,12 +33,22 @@ export class GetSender {
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();
@@ -39,9 +56,7 @@ export class Server {
private logger = LoggerCore.getInstance().logger({ service: "messageServer" });
constructor(prefix: string, message: Message) {
console.log("constructor", prefix, message);
message.onConnect((msg: any, con: MessageConnect) => {
console.log("onConnect", this.apiFunctionMap, this.apiFunctionMap.size);
this.logger.trace("server onConnect", { msg });
if (msg.action.startsWith(prefix)) {
return this.connectHandle(msg.action.slice(prefix.length + 1), msg.data, con);
@@ -50,7 +65,6 @@ export class Server {
});
message.onMessage((msg: { action: string; data: any }, sendResponse, sender) => {
console.log("onConnect", this.apiFunctionMap, this.apiFunctionMap.size);
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);
@@ -74,8 +88,8 @@ export class Server {
}
}
private messageHandle(msg: string, params: any, sendResponse: (response: any) => void, sender?: MessageSender) {
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, new GetSender(sender!));
@@ -91,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 });
}
}
}
@@ -117,12 +131,17 @@ export class Group {
}
// 转发消息
export function forwardMessage(prefix: string, path: string, from: Server, to: MessageSend) {
from.on(path, (params, fromCon) => {
console.log("forwardMessage", path, prefix, params);
if (fromCon) {
const fromConnect = fromCon.getConnect();
to.connect({ action: prefix + "/" + path, data: params }).then((toCon) => {
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);
});
@@ -137,7 +156,24 @@ export function forwardMessage(prefix: string, path: string, from: Server, to: M
});
});
} else {
return to.sendMessage({ action: prefix + "/" + 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);
});
}

View File

@@ -168,11 +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) => {
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) {
@@ -187,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(),

2203
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,8 @@ import * as path from "path";
import { defineConfig } from "@rspack/cli";
import { rspack } from "@rspack/core";
import { version } from "./package.json";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const CompressionPlugin = require("compression-webpack-plugin");
const NodePolyfillPlugin = require("node-polyfill-webpack-plugin");
const isDev = process.env.NODE_ENV === "development";
const isBeta = version.includes("-");
@@ -22,7 +22,10 @@ export default defineConfig({
mode: "development",
devtool: "inline-source-map",
}
: {}),
: {
mode: "production",
devtool: false,
}),
context: __dirname,
entry: {
service_worker: `${src}/service_worker.ts`,
@@ -32,9 +35,12 @@ export default defineConfig({
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`,
@@ -46,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,
@@ -118,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` },
@@ -144,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`,
@@ -154,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,
@@ -174,20 +199,25 @@ export default defineConfig({
minify: true,
chunks: ["sandbox"],
}),
new CompressionPlugin({
test: /ts.worker.js$/,
filename: () => "ts.worker.js",
deleteOriginalAssets: true,
}),
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
View 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);
});

View File

@@ -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!;
}
}

View File

@@ -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}`;
}
}

View File

@@ -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";

View File

@@ -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;

View File

@@ -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);
}
}
}

View File

@@ -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.info(msg);
console.info(msg, newLabel);
break;
default:
console.info(msg);
console.info(msg, newLabel);
break;
}
}

View File

@@ -5,13 +5,16 @@ import { MessageSend } from "@Packages/message/server";
export default class MessageWriter implements Writer {
send: MessageSend;
constructor(connect: MessageSend) {
constructor(
connect: MessageSend,
private action: string = "logger"
) {
this.send = connect;
}
write(level: LogLevel, message: string, label: LogLabel): void {
this.send.sendMessage({
action: "logger",
action: this.action,
data: {
id: 0,
level,

View File

@@ -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();
}

View File

@@ -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) {

View 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);
}
}

View File

@@ -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,

View File

@@ -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);
}
}

View 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 });
}
}

View File

@@ -2,7 +2,8 @@ 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 { initTestEnv } from "@Tests/utils";
import { describe, expect, it } from "vitest";
initTestEnv();

View File

@@ -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;

View File

@@ -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;
}

View 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;
}
}

View 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();
}
}

View File

@@ -3,6 +3,7 @@ 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): string {
@@ -29,9 +30,7 @@ export function compileScript(code: string): ScriptFunc {
}
export function compileInjectScript(script: ScriptRunResouce): string {
return (
`console.log(window,'` + script.flag + `');window['${script.flag}']=function(context,GM_info){\n${script.code}\n}`
);
return `window['${script.flag}']=function(context,GM_info){\n${script.code}\n}`;
}
// 设置api依赖
@@ -55,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,
};

View File

@@ -1,7 +1,7 @@
import { WindowMessage } from "@Packages/message/window_message";
import { SCRIPT_RUN_STATUS, ScriptRunResouce } from "@App/app/repo/scripts";
import { sendMessage } from "@Packages/message/client";
import { MessageSend } from "@Packages/message/server";
import { MessageSend } from "@Packages/message/server";
export function preparationSandbox(msg: WindowMessage) {
return sendMessage(msg, "offscreen/preparationSandbox");
@@ -31,3 +31,7 @@ export function runScript(msg: MessageSend, data: ScriptRunResouce) {
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);
}

View File

@@ -1,19 +1,75 @@
import { Group, MessageConnect } 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" });
constructor(private group: Group) {}
dealXhrResponse(con: MessageConnect, details: GMSend.XHRDetails, event: string, xhr: XMLHttpRequest, data?: any) {
async dealXhrResponse(
con: MessageConnect,
details: GMSend.XHRDetails,
event: string,
xhr: XMLHttpRequest,
data?: any
) {
const finalUrl = xhr.responseURL || details.url;
// 判断是否有headerFlag-final-url,有则替换finalUrl
let response: GMTypes.XHRResponse = {
finalUrl,
readyState: <any>xhr.readyState,
status: xhr.status,
statusText: xhr.statusText,
// responseHeaders: xhr.getAllResponseHeaders().replace(removeXCat, ""),
// 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);
}
@@ -24,17 +80,38 @@ export default class GMApi {
return response;
}
xmlHttpRequest(details: GMSend.XHRDetails, con: MessageConnect | null) {
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();
xhr.open(details.method || "GET", details.url);
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);
this.dealXhrResponse(con, details, "onload", xhr);
};
xhr.onloadstart = () => {
this.dealXhrResponse(con!, details, "onloadstart", xhr);
@@ -64,20 +141,69 @@ export default class GMApi {
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);
}
if (details.timeout) {
xhr.timeout = details.timeout;
}
if (details.overrideMimeType) {
xhr.overrideMimeType(details.overrideMimeType);
}
xhr.send();
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.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));
}
}

View File

@@ -14,12 +14,16 @@ export class OffscreenManager {
private windowMessage = new WindowMessage(window, sandbox, true);
private windowApi: Server = new Server("offscreen", this.windowMessage);
private windowServer: Server = new Server("offscreen", this.windowMessage);
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);
@@ -36,36 +40,32 @@ export class OffscreenManager {
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));
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.windowApi.group("script"),
this.windowServer.group("script"),
this.extensionMessage,
this.windowMessage,
this.messageQueue
);
script.init();
// 转发从sandbox来的gm api请求
forwardMessage("serviceWorker", "runtime/gmApi", this.windowApi, this.extensionMessage);
// 转发message queue请求
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.windowApi.group("gmApi"));
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);
});
}
}

View File

@@ -1,9 +1,11 @@
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 }) => void
callback: (message: { script: Script; update: boolean; upsertBy: InstallSource }) => void
) {
return messageQueue.subscribe("installScript", callback);
}
@@ -12,6 +14,17 @@ export function subscribeScriptDelete(messageQueue: MessageQueue, callback: (mes
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(
@@ -27,3 +40,20 @@ export function subscribeScriptRunStatus(
) {
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);
}

View File

@@ -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();
@@ -159,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);
@@ -204,7 +206,7 @@ export class Runtime {
} else {
this.cronJob.set(script.uuid, cronJobList);
}
return Promise.resolve(!flag);
return !flag;
}
crontabExec(script: ScriptRunResouce, oncePos: number) {
@@ -282,7 +284,6 @@ export class Runtime {
}
async runScript(script: ScriptRunResouce) {
console.log("runScript", script);
const exec = this.execScripts.get(script.uuid);
// 如果正在运行,先释放
if (exec) {
@@ -291,10 +292,30 @@ export class Runtime {
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));
}
}

View File

@@ -3,6 +3,14 @@ import { Client } from "@Packages/message/client";
import { InstallSource } from ".";
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) {
@@ -47,6 +55,24 @@ 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 {
@@ -57,6 +83,10 @@ export class ResourceClient extends Client {
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 {
@@ -64,9 +94,13 @@ export class ValueClient extends Client {
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 class RuntimeClient extends Client {
@@ -85,4 +119,123 @@ export class RuntimeClient extends Client {
pageLoad(): Promise<{ flag: string; scripts: ScriptRunResouce[] }> {
return this.do("pageLoad");
}
scriptLoad(flag: string, uuid: string) {
return this.do("scriptLoad", { flag, uuid });
}
}
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 class PermissionClient extends Client {
constructor(msg: MessageSend) {
super(msg, "serviceWorker/runtime/permission");
}
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 });
}
}

View File

@@ -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 { GetSender, Group, MessageConnect, MessageSend, 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 { connect } from "@Packages/message/client";
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,71 +29,348 @@ 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: MessageSend,
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: GetSender) {
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("gm 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.getConnect());
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(reqeustId: number, 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 = [
{
@@ -94,14 +380,45 @@ export default class GMApi {
] 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];
}
});
// 判断是否是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 = reqeustId;
const rule = {} as chrome.declarativeNetRequest.Rule;
rule.id = ruleId;
@@ -119,22 +436,117 @@ 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,
};
await chrome.declarativeNetRequest.updateSessionRules({
removeRuleIds: [ruleId],
addRules: [rule],
});
return ruleId;
return headers;
}
gmXhrHeadersReceived: EventEmitter = new EventEmitter();
@PermissionVerify.API()
async GM_xmlhttpRequest(request: Request, con: MessageConnect) {
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) {
return Promise.reject(new Error("param is failed"));
throw new Error("param is failed");
}
const params = request.params[0] as GMSend.XHRDetails;
// 先处理unsafe hearder
@@ -145,29 +557,325 @@ export default class GMApi {
params.headers = {};
}
params.headers["X-Scriptcat-GM-XHR-Request-Id"] = requestId.toString();
await this.buildDNRRule(requestId, request.params[0]);
let responseHeader = "";
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) => {
responseHeader += header.name + ": " + header.value + "\n";
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.sender, "gmApi/xmlHttpRequest", request.params[0]);
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 = responseHeader;
con.sendMessage(msg);
msg.data.responseHeaders = resultParam.responseHeader || msg.data.responseHeaders;
sender.getConnect().sendMessage(msg);
});
sender.getConnect().onDisconnect(() => {
// 关闭连接
offscreenCon.disconnect();
});
}
start() {
this.group.on("gmApi", this.handlerRequest.bind(this));
@PermissionVerify.API()
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,
});
}
@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,
});
}
@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) {
@@ -212,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}`);
}
});
}
}

View File

@@ -5,6 +5,12 @@ 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";
@@ -22,14 +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"));
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, script);
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();
// 定时器处理
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 }));
}
});
});
}
}

View File

@@ -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));
}
}

View 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,
});
}
}
);
}
);
}
}

View File

@@ -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));
}
}

View File

@@ -1,39 +1,106 @@
import { MessageQueue } from "@Packages/message/message_queue";
import { GetSender, Group, MessageSend } from "@Packages/message/server";
import { Script, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL, ScriptDAO, ScriptRunResouce } 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 { subscribeScriptDelete, subscribeScriptEnable, subscribeScriptInstall } from "../queue";
import { ScriptService } from "./script";
import { runScript, stopScript } from "../offscreen/client";
import { getRunAt } from "./utils";
import { randomString } from "@App/pkg/utils/utils";
import { compileInjectScript, compileScriptCode } from "@App/runtime/content/utils";
import { isUserScriptsAvailable, randomString } from "@App/pkg/utils/utils";
import Cache from "@App/app/cache";
import { dealMatches } from "@App/pkg/utils/match";
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: MessageSend,
private mq: MessageQueue,
private value: ValueService,
private script: ScriptService
private script: ScriptService,
private resource: ResourceService
) {}
async init() {
// 启动gm api
const gmApi = new GMApi(this.group, this.sender, this.value);
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));
// 读取inject.js注入页面
this.registerInjectScript();
// 检查是否开启了开发者模式
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: "!",
});
}
// 监听脚本开启
subscribeScriptEnable(this.mq, async (data) => {
const script = await this.scriptDAO.getAndCode(data.uuid);
@@ -43,11 +110,11 @@ 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);
}
}
});
@@ -58,52 +125,181 @@ export class RuntimeService {
return;
}
if (script.type === SCRIPT_TYPE_NORMAL) {
this.registryPageScript(script);
await this.loadPageScript(script);
}
});
// 监听脚本删除
subscribeScriptDelete(this.mq, async (data) => {
const script = await this.scriptDAO.get(data.uuid);
if (!script) {
return;
}
if (script.type === SCRIPT_TYPE_NORMAL) {
this.unregistryPageScript(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.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 });
});
});
this.loadScriptMatchInfo();
}
scriptFlag() {
return Cache.getInstance().getOrSet("scriptInjectFlag", () => {
messageFlag() {
return Cache.getInstance().getOrSet("scriptInjectMessageFlag", () => {
return Promise.resolve(randomString(16));
});
}
async pageLoad(_, sender: GetSender) {
const scriptFlag = await this.scriptFlag();
const chromeSender = sender.getSender() as chrome.runtime.MessageSender;
// 匹配当前页面的脚本
deleteMessageFlag() {
return Cache.getInstance().del("scriptInjectMessageFlag");
}
return Promise.resolve({ flag: scriptFlag });
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 });
}
// 停止脚本
@@ -121,95 +317,257 @@ export class RuntimeService {
return runScript(this.sender, res);
}
registerInjectScript() {
chrome.userScripts.getScripts({ ids: ["scriptcat-inject"] }).then((res) => {
if (res.length == 0) {
fetch("inject.js")
.then((res) => res.text())
.then(async (injectJs) => {
// 替换ScriptFlag
const code = `(function (ScriptFlag) {\n${injectJs}\n})('${await this.scriptFlag()}')`;
chrome.userScripts.register([
{
id: "scriptcat-inject",
js: [{ code }],
matches: ["<all_urls>"],
allFrames: true,
world: "MAIN",
runAt: "document_start",
},
]);
// 注册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,
});
}
});
chrome.scripting.registerContentScripts([
{
id: "scriptcat-content",
js: ["src/content.js"],
matches: ["<all_urls>"],
allFrames: true,
runAt: "document_start",
world: "ISOLATED",
},
]);
}
}
}
}
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 registryPageScript(script: Script) {
if (await Cache.getInstance().has("registryScript:" + script.uuid)) {
return;
async updateScriptStatus(uuid: string, status: SCRIPT_STATUS) {
if (!this.scriptMatchCache) {
await this.loadScriptMatchInfo();
}
const matches = script.metadata["match"];
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;
}
const scriptRes = await this.script.buildScriptRunResource(script);
scriptRes.code = compileScriptCode(scriptRes);
scriptRes.code = compileInjectScript(scriptRes);
matches.push(...(script.metadata["include"] || []));
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: dealMatches(matches),
matches: patternMatches.patternResult,
world: "MAIN",
};
if (!script.metadata["noframes"]) {
registerScript.allFrames = true;
// 排除由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.metadata["exclude-match"]) {
const excludeMatches = script.metadata["exclude-match"];
excludeMatches.push(...(script.metadata["exclude"] || []));
registerScript.excludeMatches = dealMatches(excludeMatches);
// 自定义排除
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;
}
if (script.metadata["run-at"]) {
registerScript.runAt = getRunAt(script.metadata["run-at"]);
// 将脚本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);
}
chrome.userScripts.register([registerScript], () => {
// 标记为已注册
Cache.getInstance().set("registryScript:" + script.uuid, true);
});
console.log(registerScript);
// 把脚本uuid注册到content页面
chrome.userScripts.register([
{
id: "content-" + scriptRes.uuid,
js: [{ code: "window.a=1;console.log('window.a',window,window.b)" }],
matches: dealMatches(matches),
runAt: "document_start",
},
]);
}
unregistryPageScript(script: Script) {
chrome.userScripts.unregister(
{
ids: [script.uuid],
},
() => {
// 删除缓存
Cache.getInstance().del("registryScript:" + script.uuid);
}
);
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] });
}
}

View File

@@ -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,7 +198,7 @@ export class ScriptService {
});
logger.info("install success");
// 广播一下
this.mq.publish("installScript", { script, update });
this.mq.publish("installScript", { script, update, upsertBy });
return Promise.resolve({ update });
})
.catch((e: any) => {
@@ -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));
@@ -279,6 +300,200 @@ export class ScriptService {
return Promise.resolve(ret);
}
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();
@@ -290,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,
});
}
}

View 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,
});
}
}

View 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));
}
}

View File

@@ -17,3 +17,45 @@ export function getRunAt(runAts: string[]): chrome.userScripts.RunAt {
}
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;
}

View File

@@ -1,51 +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 { 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) {
constructor(
private group: Group,
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));
}
}

View File

@@ -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!"
}
}
}

View File

@@ -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!"
}

View File

@@ -5,6 +5,9 @@
"scriptcat": {
"message": "脚本猫"
},
"scriptcat_beta": {
"message": "脚本猫 Beta"
},
"scriptcat_description": {
"message": "万物皆可脚本化,让你的浏览器可以做更多的事情!"
}

View File

@@ -1,9 +1,10 @@
import LoggerCore from "./app/logger/core";
import MessageWriter from "./app/logger/message_writer";
import { ExtensionMessageSend } from "@Packages/message/extension_message";
import { ExtensionMessage, ExtensionMessageSend } from "@Packages/message/extension_message";
import { CustomEventMessage } from "@Packages/message/custom_event_message";
import { RuntimeClient } from "./app/service/service_worker/client";
import ContentRuntime from "./runtime/content/content";
import { Server } from "@Packages/message/server";
import ContentRuntime from "./app/service/content/content";
// 建立与service_worker页面的连接
const send = new ExtensionMessageSend();
@@ -17,13 +18,11 @@ const loggerCore = new LoggerCore({
const client = new RuntimeClient(send);
client.pageLoad().then((data) => {
loggerCore.logger().debug("content start");
console.log("content", data);
const extMsg = new ExtensionMessage();
const msg = new CustomEventMessage(data.flag, true);
const server = new Server("content", msg);
const extServer = new Server("content", extMsg);
// 初始化运行环境
const runtime = new ContentRuntime(send, msg);
const runtime = new ContentRuntime(extServer, server, send, msg);
runtime.start(data.scripts);
});
chrome.storage.local.get((data) => {
console.log(data);
});

View File

@@ -2,13 +2,10 @@ import LoggerCore from "./app/logger/core";
import MessageWriter from "./app/logger/message_writer";
import { CustomEventMessage } from "@Packages/message/custom_event_message";
import { Server } from "@Packages/message/server";
import { InjectRuntime } from "./runtime/content/inject";
import { ScriptRunResouce } from "./app/repo/scripts";
import { InjectRuntime } from "./app/service/content/inject";
// 通过flag与content建立通讯,这个ScriptFlag是后端注入时候生成的
const flag = ScriptFlag;
const msg = new CustomEventMessage(flag, false);
const msg = new CustomEventMessage(MessageFlag, false);
// 加载logger组件
const logger = new LoggerCore({
@@ -18,9 +15,9 @@ const logger = new LoggerCore({
const server = new Server("inject", msg);
server.on("pageLoad", (data: ScriptRunResouce[]) => {
server.on("pageLoad", (data: { scripts: ScriptRunResouce[] }) => {
logger.logger().debug("inject start");
console.log("inject", data);
const runtime = new InjectRuntime(msg, data);
// 监听事件
const runtime = new InjectRuntime(server, msg, data.scripts);
runtime.start();
});

92
src/linter.worker.ts Normal file
View File

@@ -0,0 +1,92 @@
//@ts-ignore
import { Linter } from "eslint-linter-browserify";
import { userscriptsRules } from "../packages/eslint/linter-config";
// eslint语法检查,使用webworker
const linter = new Linter();
// 额外定义 userscripts 规则
linter.defineRules(userscriptsRules);
const rules = linter.getRules();
const severityMap = {
2: 8, // 2 for ESLint is error
1: 4, // 1 for ESLint is warning
};
function getTextBlock(text: string, startPosition: number, endPosition: number) {
if (startPosition > endPosition || startPosition < 0 || endPosition > text.length) {
throw new Error("Invalid positions provided");
}
let startLineNumber = 1;
let startColumn = 1;
let endLineNumber = 1;
let endColumn = 1;
for (let i = 0, currentLine = 1, currentColumn = 1; i < text.length; i += 1) {
if (i === startPosition) {
startLineNumber = currentLine;
startColumn = currentColumn;
}
if (i === endPosition) {
endLineNumber = currentLine;
endColumn = currentColumn;
break;
}
if (text[i] === "\n") {
currentLine += 1;
currentColumn = 0;
}
currentColumn += 1;
}
return {
startLineNumber,
startColumn,
endLineNumber,
endColumn,
};
}
self.addEventListener("message", (event) => {
const { code, id, config } = event.data;
const errs = linter.verify(code, config);
const markers = errs.map((err: any) => {
const rule = rules.get(err.ruleId);
let target = "";
if (rule) {
target = rule.meta.docs.url;
}
let fix: any;
if (err.fix) {
fix = {
range: getTextBlock(code, err.fix.range[0], err.fix.range[1]),
text: err.fix.text,
};
}
return {
code: {
value: err.ruleId || "",
target,
},
startLineNumber: err.line,
endLineNumber: err.endLine || err.line,
startColumn: err.column,
endColumn: err.endColumn || err.column,
message: err.message,
// 设置错误的等级此处ESLint与monaco的存在差异做一层映射
// @ts-ignore
severity: severityMap[err.severity],
source: "ESLint",
fix,
};
});
// 发回主进程
self.postMessage({ markers, id });
});

View File

@@ -358,5 +358,6 @@
"collapse": "Collapse",
"expand": "Expand",
"menu_expand_num_before": "Menu item more than",
"menu_expand_num_after": "Auto-hide."
"menu_expand_num_after": "Auto-hide.",
"eslint_config_format_error": "ESLint configuration format error"
}

View File

@@ -10,10 +10,11 @@ import zhTW from "./zh-TW/translation.json";
import achUG from "./ach-UG/translation.json";
import "dayjs/locale/zh-cn";
import "dayjs/locale/zh-tw";
import { systemConfig } from "@App/pages/store/global";
i18n.use(initReactI18next).init({
fallbackLng: "zh-CN",
lng: localStorage.language || chrome.i18n.getUILanguage(),
lng: chrome.i18n.getUILanguage(),
interpolation: {
escapeValue: false, // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape
},
@@ -26,33 +27,29 @@ i18n.use(initReactI18next).init({
},
});
if (!localStorage.language) {
chrome.i18n.getAcceptLanguages((lngs) => {
// 遍历数组寻找匹配语言
for (let i = 0; i < lngs.length; i += 1) {
const lng = lngs[i];
if (i18n.hasResourceBundle(lng, "translation")) {
localStorage.language = lng;
i18n.changeLanguage(lng);
dayjs.locale(lng.toLocaleLowerCase());
break;
}
export let localePath = "";
chrome.i18n.getAcceptLanguages((lngs) => {
systemConfig.getLanguage(lngs).then((lng) => {
i18n.changeLanguage(lng);
dayjs.locale(lng.toLocaleLowerCase());
if (lng !== "zh-CN") {
localePath = "en";
}
});
} else {
dayjs.locale((localStorage.language as string).toLocaleLowerCase());
}
});
dayjs.extend(relativeTime);
export function i18nName(script: { name: string; metadata: Metadata }) {
return script.metadata[`name:${i18n.language.toLowerCase()}`]
? script.metadata[`name:${i18n.language.toLowerCase()}`][0]
? script.metadata[`name:${i18n.language.toLowerCase()}`]![0]
: script.name;
}
export function i18nDescription(script: { metadata: Metadata }) {
return script.metadata[`description:${i18n.language.toLowerCase()}`]
? script.metadata[`description:${i18n.language.toLowerCase()}`][0]
? script.metadata[`description:${i18n.language.toLowerCase()}`]![0]
: script.metadata.description;
}

View File

@@ -68,6 +68,7 @@
"confirm_delete_backup_file": "确认删除备份文件",
"confirm_update": "确认更新",
"delete_success": "删除成功",
"deleting": "删除中",
"backup_strategy": "备份策略",
"under_construction": "建设中",
"development_debugging": "开发调试",
@@ -363,5 +364,12 @@
"expand": "展开",
"menu_expand_num_before": "菜单项超过",
"menu_expand_num_after": "个时,自动隐藏",
"script_name_cannot_be_set_to_empty": "脚本name不可以设置为空"
"script_name_cannot_be_set_to_empty": "脚本name不可以设置为空",
"eslint_config_format_error": "eslint配置格式错误",
"export_success": "导出成功",
"get_backup_dir_url_failed": "获取备份目录地址失败",
"get_backup_files_failed": "获取备份文件失败",
"develop_mode_guide": "检测到当前未开启开发者模式,您的脚本无法正常使用,<a href=\"https://docs.scriptcat.org/docs/use/open-dev/\" target=\"black\" style=\"color: var(--color-text-1)\">👉点我了解如何开启</a>",
"enable_script_failed": "脚本开启失败",
"disable_script_failed": "脚本关闭失败"
}

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "__MSG_scriptcat__",
"version": "0.17.0.1001",
"version": "0.17.0.1005",
"author": "CodFrm",
"description": "__MSG_scriptcat_description__",
"options_ui": {
@@ -23,16 +23,23 @@
"default_locale": "zh_CN",
"permissions": [
"tabs",
"alarms",
"storage",
"cookies",
"offscreen",
"scripting",
"downloads",
"activeTab",
"webRequest",
"userScripts",
"contextMenus",
"notifications",
"clipboardWrite",
"unlimitedStorage",
"declarativeNetRequest"
],
"host_permissions": [
"*://*/*"
"<all_urls>"
],
"sandbox": {
"pages": [

View File

@@ -1,20 +1,19 @@
import migrate from "./app/migrate";
import { MessageSend } from "@Packages/message/server";
import LoggerCore from "./app/logger/core";
import DBWriter from "./app/logger/db_writer";
import { LoggerDAO } from "./app/repo/logger";
import MessageWriter from "./app/logger/message_writer";
import { OffscreenManager } from "./app/service/offscreen";
import { ExtensionMessageSend } from "@Packages/message/extension_message";
function main() {
// 初始化数据库
migrate();
// 初始化日志组件
const extensionMessage: MessageSend = new ExtensionMessageSend();
const loggerCore = new LoggerCore({
writer: new DBWriter(new LoggerDAO()),
writer: new MessageWriter(extensionMessage),
labels: { env: "offscreen" },
});
loggerCore.logger().debug("offscreen start");
// 初始化管理器
const manager = new OffscreenManager();
const manager = new OffscreenManager(extensionMessage);
manager.initManager();
}

View File

@@ -129,7 +129,7 @@ const CloudScriptPlan: React.FC<{
const url = URL.createObjectURL(files);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 60 * 1000);
}, 30 * 1000);
chrome.downloads.download({
url,
saveAs: true,

View File

@@ -3,6 +3,7 @@ import { LinterWorker } from "@App/pkg/utils/monaco-editor";
import { useAppSelector } from "@App/pages/store/hooks";
import { editor, Range } from "monaco-editor";
import React, { useEffect, useImperativeHandle, useRef, useState } from "react";
import { globalCache, systemConfig } from "@App/pages/store/global";
type Props = {
className?: string;
@@ -16,12 +17,27 @@ const CodeEditor: React.ForwardRefRenderFunction<{ editor: editor.IStandaloneCod
{ id, className, code, diffCode, editable },
ref
) => {
const settings = useAppSelector((state) => state.setting);
const [monacoEditor, setEditor] = useState<editor.IStandaloneCodeEditor>();
const [enableEslint, setEnableEslint] = useState(false);
const [eslintConfig, setEslintConfig] = useState("");
const div = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => ({
editor: monacoEditor,
}));
useEffect(() => {
const loadConfigs = async () => {
const [eslintConfig, enableEslint] = await Promise.all([
systemConfig.getEslintConfig(),
systemConfig.getEnableEslint(),
]);
setEslintConfig(eslintConfig);
setEnableEslint(enableEslint);
};
loadConfigs();
}, []);
useEffect(() => {
if (diffCode === undefined || code === undefined || !div.current) {
return () => {};
@@ -70,8 +86,7 @@ const CodeEditor: React.ForwardRefRenderFunction<{ editor: editor.IStandaloneCod
}, [div, code, diffCode, editable, id]);
useEffect(() => {
return () => {};
if (!settings.eslint.enable) {
if (!enableEslint) {
return () => {};
}
if (!monacoEditor) {
@@ -89,7 +104,7 @@ const CodeEditor: React.ForwardRefRenderFunction<{ editor: editor.IStandaloneCod
LinterWorker.sendLinterMessage({
code: model.getValue(),
id,
config: JSON.parse(settings.eslint.config),
config: JSON.parse(eslintConfig),
});
}, 500);
};
@@ -183,7 +198,7 @@ const CodeEditor: React.ForwardRefRenderFunction<{ editor: editor.IStandaloneCod
}
}
);
Cache.getInstance().set("eslint-fix", fix);
globalCache.set("eslint-fix", fix);
// 在行号旁显示ESLint错误/警告图标
const formatMarkers = message.markers.map(
@@ -203,7 +218,7 @@ const CodeEditor: React.ForwardRefRenderFunction<{ editor: editor.IStandaloneCod
return () => {
LinterWorker.hook.removeListener("message", handler);
};
}, [id, monacoEditor, settings.eslint.config, settings.eslint.enable]);
}, [id, monacoEditor, enableEslint, eslintConfig]);
return (
<div

View File

@@ -1,6 +1,8 @@
import React from "react";
import { Input, Select, Space } from "@arco-design/web-react";
import FileSystemFactory, { FileSystemType } from "@Packages/filesystem/factory";
const fsParams = FileSystemFactory.params();
const fileSystemList: {
key: FileSystemType;
@@ -65,10 +67,7 @@ const FileSystemParams: React.FC<{
<>
<span>{fsParams[fileSystemType][key].title}</span>
<Select
value={
fileSystemParams[key] ||
fsParams[fileSystemType][key].options![0]
}
value={fileSystemParams[key] || fsParams[fileSystemType][key].options![0]}
onChange={(value) => {
onChangeFileSystemParams({
...fileSystemParams,

View File

@@ -1,29 +1,29 @@
import React, { useState } from "react";
import {
Button,
Card,
Collapse,
Link,
Message,
Space,
Typography,
} from "@arco-design/web-react";
import React, { useEffect, useState } from "react";
import { Button, Card, Collapse, Link, Message, Space, Typography } from "@arco-design/web-react";
import { useTranslation } from "react-i18next";
import FileSystemParams from "../FileSystemParams";
import { systemConfig } from "@App/pages/store/global";
import FileSystemFactory, { FileSystemType } from "@Packages/filesystem/factory";
import { set } from "node_modules/yaml/dist/schema/yaml-1.1/set";
const CollapseItem = Collapse.Item;
const GMApiSetting: React.FC = () => {
const systemConfig = IoC.instance(SystemConfig) as SystemConfig;
const [status, setStatus] = useState(systemConfig.catFileStorage.status);
const [fileSystemType, setFilesystemType] = useState<FileSystemType>(
systemConfig.catFileStorage.filesystem
);
const [status, setStatus] = useState("unset");
const [fileSystemType, setFilesystemType] = useState<FileSystemType>("webdav");
const [fileSystemParams, setFilesystemParam] = useState<{
[key: string]: any;
}>(systemConfig.catFileStorage.params[fileSystemType] || {});
}>({});
const { t } = useTranslation();
useEffect(() => {
systemConfig.getCatFileStorage().then((res) => {
setStatus(res.status);
setFilesystemType(res.filesystem);
setFilesystemParam(res.params[res.filesystem] || {});
});
}, []);
return (
<Card title={t("gm_api")} bordered={false}>
<Collapse bordered={false} defaultActiveKey={["storage"]}>
@@ -48,21 +48,18 @@ const GMApiSetting: React.FC = () => {
type="primary"
onClick={async () => {
try {
await FileSystemFactory.create(
fileSystemType,
fileSystemParams
);
await FileSystemFactory.create(fileSystemType, fileSystemParams);
} catch (e) {
Message.error(`${t("account_validation_failed")}: ${e}`);
return;
}
const params = { ...systemConfig.catFileStorage.params };
const params = { ...fileSystemParams };
params[fileSystemType] = fileSystemParams;
systemConfig.catFileStorage = {
systemConfig.setCatFileStorage({
status: "success",
filesystem: fileSystemType,
params,
};
});
setStatus("success");
Message.success(t("save_success")!);
}}
@@ -72,10 +69,14 @@ const GMApiSetting: React.FC = () => {
<Button
key="reset"
onClick={() => {
const config = systemConfig.catFileStorage;
config.status = "unset";
systemConfig.catFileStorage = config;
systemConfig.setCatFileStorage({
status: "unset",
filesystem: "webdav",
params: {},
});
setStatus("unset");
setFilesystemParam({});
setFilesystemType("webdav");
}}
type="primary"
status="danger"
@@ -87,10 +88,7 @@ const GMApiSetting: React.FC = () => {
type="secondary"
onClick={async () => {
try {
let fs = await FileSystemFactory.create(
fileSystemType,
fileSystemParams
);
let fs = await FileSystemFactory.create(fileSystemType, fileSystemParams);
fs = await fs.openDir("ScriptCat/app");
window.open(await fs.getDirUrl(), "_black");
} catch (e) {
@@ -110,17 +108,9 @@ const GMApiSetting: React.FC = () => {
setFilesystemParam(params);
}}
/>
{status === "unset" && (
<Typography.Text type="secondary">{t("not_set")}</Typography.Text>
)}
{status === "success" && (
<Typography.Text type="success">{t("in_use")}</Typography.Text>
)}
{status === "error" && (
<Typography.Text type="error">
{t("storage_error")}
</Typography.Text>
)}
{status === "unset" && <Typography.Text type="secondary">{t("not_set")}</Typography.Text>}
{status === "success" && <Typography.Text type="success">{t("in_use")}</Typography.Text>}
{status === "error" && <Typography.Text type="error">{t("storage_error")}</Typography.Text>}
</Space>
</CollapseItem>
</Collapse>

View File

@@ -1,17 +1,5 @@
/* eslint-disable no-nested-ternary */
import React, { useEffect, useState } from "react";
import MessageInternal from "@App/app/message/internal";
import { MessageSender } from "@App/app/message/message";
import { ScriptMenu } from "@App/runtime/service_worker/runtime";
import {
Button,
Collapse,
Empty,
Message,
Popconfirm,
Space,
Switch,
} from "@arco-design/web-react";
import { Button, Collapse, Empty, Message, Popconfirm, Space, Switch } from "@arco-design/web-react";
import {
IconCaretDown,
IconCaretUp,
@@ -21,14 +9,16 @@ import {
IconMinus,
IconSettings,
} from "@arco-design/web-react/icon";
import IoC from "@App/app/ioc";
import ScriptController from "@App/app/service/script/controller";
import { SCRIPT_RUN_STATUS_RUNNING } from "@App/app/repo/scripts";
import { RiPlayFill, RiStopFill } from "react-icons/ri";
import RuntimeController from "@App/runtime/content/runtime";
import { useTranslation } from "react-i18next";
import { SystemConfig } from "@App/pkg/config/config";
import { ScriptIcons } from "@App/pages/options/routes/utils";
import { ScriptMenu, ScriptMenuItem } from "@App/app/service/service_worker/popup";
import { useAppSelector } from "@App/pages/store/hooks";
import { popupClient, runtimeClient, scriptClient } from "@App/pages/store/features/script";
import { i18nName } from "@App/locales/locales";
import { subscribeScriptRunStatus } from "@App/app/service/queue";
import { messageQueue, systemConfig } from "@App/pages/store/global";
const CollapseItem = Collapse.Item;
@@ -37,7 +27,7 @@ function isExclude(script: ScriptMenu, host: string) {
return false;
}
for (let i = 0; i < script.customExclude.length; i += 1) {
if (script.customExclude[i] === `*://${host}*`) {
if (script.customExclude[i] === `*://${host}/*`) {
return true;
}
}
@@ -51,20 +41,17 @@ const ScriptMenuList: React.FC<{
currentUrl: string;
}> = ({ script, isBackscript, currentUrl }) => {
const [list, setList] = useState([] as ScriptMenu[]);
const message = IoC.instance(MessageInternal) as MessageInternal;
const scriptCtrl = IoC.instance(ScriptController) as ScriptController;
const runtimeCtrl = IoC.instance(RuntimeController) as RuntimeController;
const systemConfig = IoC.instance(SystemConfig) as SystemConfig;
const [expandMenuIndex, setExpandMenuIndex] = useState<{
[key: string]: boolean;
}>({});
const { t } = useTranslation();
const [menuExpandNum, setMenuExpandNum] = useState(5);
let url: URL;
try {
url = new URL(currentUrl);
} catch (e) {
// ignore error
} catch (e: any) {
console.error("Invalid URL:", e);
}
useEffect(() => {
setList(script);
@@ -72,55 +59,43 @@ const ScriptMenuList: React.FC<{
useEffect(() => {
// 监听脚本运行状态
const channel = runtimeCtrl.watchRunStatus();
channel.setHandler(([id, status]: any) => {
const unsub = subscribeScriptRunStatus(messageQueue, ({ uuid, runStatus }) => {
setList((prev) => {
const newList = [...prev];
const index = newList.findIndex((item) => item.id === id);
const index = newList.findIndex((item) => item.uuid === uuid);
if (index !== -1) {
newList[index].runStatus = status;
newList[index].runStatus = runStatus;
}
return newList;
});
});
// 获取配置
systemConfig.getMenuExpandNum().then((num) => {
setMenuExpandNum(num);
});
return () => {
channel.disChannel();
unsub();
};
}, []);
const sendMenuAction = (sender: MessageSender, channelFlag: string) => {
let id = sender.tabId;
if (sender.frameId) {
id = sender.frameId;
}
message.broadcastChannel(
{
tag: sender.targetTag,
id: [id!],
},
channelFlag,
"click"
);
window.close();
const sendMenuAction = (uuid: string, menu: ScriptMenuItem) => {
popupClient.menuClick(uuid, menu).then(() => {
window.close();
});
};
// 监听菜单按键
// 菜单展开
return (
<>
{list.length === 0 && <Empty />}
{list.map((item, index) => (
<Collapse bordered={false} expandIconPosition="right" key={item.id}>
<Collapse bordered={false} expandIconPosition="right" key={item.uuid}>
<CollapseItem
header={
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
onClick={(e) => {
e.stopPropagation();
}}
title={
// eslint-disable-next-line no-nested-ternary
item.enable
? item.runNumByIframe
? t("script_total_runs", {
@@ -136,21 +111,15 @@ const ScriptMenuList: React.FC<{
size="small"
checked={item.enable}
onChange={(checked) => {
let p: Promise<any>;
if (checked) {
p = scriptCtrl.enable(item.id).then(() => {
item.enable = true;
scriptClient
.enable(item.uuid, checked)
.then(() => {
item.enable = checked;
setList([...list]);
})
.catch((err) => {
Message.error(err);
});
} else {
p = scriptCtrl.disable(item.id).then(() => {
item.enable = false;
});
}
p.catch((err) => {
Message.error(err);
}).finally(() => {
setList([...list]);
});
}}
/>
<span
@@ -164,12 +133,12 @@ const ScriptMenuList: React.FC<{
}}
>
<ScriptIcons script={item} size={20} />
{item.name}
{i18nName(item)}
</span>
</Space>
</div>
}
name={item.id.toString()}
name={item.uuid}
contentStyle={{ padding: "0 0 0 40px" }}
>
<div className="flex flex-col">
@@ -177,24 +146,16 @@ const ScriptMenuList: React.FC<{
<Button
className="text-left"
type="secondary"
icon={
item.runStatus !== SCRIPT_RUN_STATUS_RUNNING ? (
<RiPlayFill />
) : (
<RiStopFill />
)
}
icon={item.runStatus !== SCRIPT_RUN_STATUS_RUNNING ? <RiPlayFill /> : <RiStopFill />}
onClick={() => {
if (item.runStatus !== SCRIPT_RUN_STATUS_RUNNING) {
runtimeCtrl.startScript(item.id);
runtimeClient.runScript(item.uuid);
} else {
runtimeCtrl.stopScript(item.id);
runtimeClient.stopScript(item.uuid);
}
}}
>
{item.runStatus !== SCRIPT_RUN_STATUS_RUNNING
? t("run_once")
: t("stop")}
{item.runStatus !== SCRIPT_RUN_STATUS_RUNNING ? t("run_once") : t("stop")}
</Button>
)}
<Button
@@ -202,10 +163,7 @@ const ScriptMenuList: React.FC<{
type="secondary"
icon={<IconEdit />}
onClick={() => {
window.open(
`/src/options.html#/script/editor/${item.id}`,
"_blank"
);
window.open(`/src/options.html#/script/editor/${item.uuid}`, "_blank");
window.close();
}}
>
@@ -218,20 +176,12 @@ const ScriptMenuList: React.FC<{
type="secondary"
icon={<IconMinus />}
onClick={() => {
scriptCtrl
.exclude(
item.id,
`*://${url.host}*`,
isExclude(item, url.host)
)
.finally(() => {
window.close();
});
scriptClient.excludeUrl(item.uuid, `*://${url.host}/*`, isExclude(item, url.host)).finally(() => {
window.close();
});
}}
>
{isExclude(item, url.host)
? t("exclude_on")
: t("exclude_off")}
{isExclude(item, url.host) ? t("exclude_on") : t("exclude_off")}
{` ${url.host} ${t("exclude_execution")}`}
</Button>
)}
@@ -239,38 +189,30 @@ const ScriptMenuList: React.FC<{
title={t("confirm_delete_script")}
icon={<IconDelete />}
onOk={() => {
setList(list.filter((i) => i.id !== item.id));
scriptCtrl.delete(item.id).catch((e) => {
setList(list.filter((i) => i.uuid !== item.uuid));
scriptClient.delete(item.uuid).catch((e) => {
Message.error(`{t('delete_failed')}: ${e}`);
});
}}
>
<Button
className="text-left"
status="danger"
type="secondary"
icon={<IconDelete />}
>
<Button className="text-left" status="danger" type="secondary" icon={<IconDelete />}>
{t("delete")}
</Button>
</Popconfirm>
</div>
</CollapseItem>
<div
className="arco-collapse-item-content-box flex flex-col"
style={{ padding: "0 0 0 40px" }}
>
<div className="arco-collapse-item-content-box flex flex-col" style={{ padding: "0 0 0 40px" }}>
{/* 判断菜单数量,再判断是否展开 */}
{(item.menus && item.menus?.length > systemConfig.menuExpandNum
{(item.menus.length > menuExpandNum
? expandMenuIndex[index]
? item.menus
: item.menus?.slice(0, systemConfig.menuExpandNum)
: item.menus?.slice(0, menuExpandNum)
: item.menus
)?.map((menu) => {
if (menu.accessKey) {
document.addEventListener("keypress", (e) => {
if (e.key.toUpperCase() === menu.accessKey!.toUpperCase()) {
sendMenuAction(menu.sender, menu.channelFlag);
sendMenuAction(item.uuid, menu);
}
});
}
@@ -281,7 +223,7 @@ const ScriptMenuList: React.FC<{
type="secondary"
icon={<IconMenu />}
onClick={() => {
sendMenuAction(menu.sender, menu.channelFlag);
sendMenuAction(item.uuid, menu);
}}
>
{menu.name}
@@ -289,14 +231,12 @@ const ScriptMenuList: React.FC<{
</Button>
);
})}
{item.menus && item.menus?.length > systemConfig.menuExpandNum && (
{item.menus.length > menuExpandNum && (
<Button
className="text-left"
key="expand"
type="secondary"
icon={
expandMenuIndex[index] ? <IconCaretUp /> : <IconCaretDown />
}
icon={expandMenuIndex[index] ? <IconCaretUp /> : <IconCaretDown />}
onClick={() => {
setExpandMenuIndex({
...expandMenuIndex,
@@ -314,10 +254,7 @@ const ScriptMenuList: React.FC<{
type="secondary"
icon={<IconSettings />}
onClick={() => {
window.open(
`/src/options.html#/?userConfig=${item.id}`,
"_blank"
);
window.open(`/src/options.html#/?userConfig=${item.uuid}`, "_blank");
window.close();
}}
>

View File

@@ -1,22 +1,12 @@
import { Resource } from "@App/app/repo/resource";
import { Script } from "@App/app/repo/scripts";
import { ResourceClient } from "@App/app/service/service_worker/client";
import { message } from "@App/pages/store/global";
import { base64ToBlob } from "@App/pkg/utils/script";
import {
Button,
Drawer,
Input,
Message,
Popconfirm,
Space,
Table,
} from "@arco-design/web-react";
import { Button, Drawer, Input, Message, Popconfirm, Space, Table } from "@arco-design/web-react";
import { RefInputType } from "@arco-design/web-react/es/Input/interface";
import { ColumnProps } from "@arco-design/web-react/es/Table";
import {
IconDelete,
IconDownload,
IconSearch,
} from "@arco-design/web-react/icon";
import { IconDelete, IconDownload, IconSearch } from "@arco-design/web-react/icon";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -33,14 +23,14 @@ const ScriptResource: React.FC<{
}> = ({ script, visible, onCancel, onOk }) => {
const [data, setData] = useState<ResourceListItem[]>([]);
const inputRef = useRef<RefInputType>(null);
// const resourceCtrl = IoC.instance(ResourceController) as ResourceController;
const { t } = useTranslation();
const resourceClient = new ResourceClient(message);
useEffect(() => {
if (!script) {
return () => {};
}
resourceCtrl.getResource(script).then((res) => {
resourceClient.getScriptResources(script).then((res) => {
const arr: ResourceListItem[] = [];
Object.keys(res).forEach((key) => {
// @ts-ignore
@@ -120,10 +110,21 @@ const ScriptResource: React.FC<{
title={t("confirm_delete_resource")}
onOk={() => {
Message.info({
content: t("delete_success"),
content: t("deleting"),
});
resourceCtrl.deleteResource(value.id);
setData(data.filter((_, i) => i !== index));
resourceClient
.deleteResource(value.url)
.then(() => {
Message.info({
content: t("delete_success"),
});
setData(data.filter((_, i) => i !== index));
})
.catch((e) => {
Message.error({
content: t("delete_failed") + ": " + e.message,
});
});
}}
>
<Button type="text" iconOnly icon={<IconDelete />} />
@@ -154,7 +155,7 @@ const ScriptResource: React.FC<{
onOk={() => {
setData((prev) => {
prev.forEach((v) => {
resourceCtrl.deleteResource(v.id);
resourceClient.deleteResource(v.url);
});
Message.info({
content: t("clear_success"),

View File

@@ -1,31 +1,24 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Script } from "@App/app/repo/scripts";
import {
Space,
Popconfirm,
Button,
Divider,
Typography,
Modal,
Input,
} from "@arco-design/web-react";
import { Script, ScriptDAO } from "@App/app/repo/scripts";
import { Space, Popconfirm, Button, Divider, Typography, Modal, Input } from "@arco-design/web-react";
import Table, { ColumnProps } from "@arco-design/web-react/es/Table";
import { IconDelete } from "@arco-design/web-react/icon";
import { scriptClient } from "@App/pages/store/features/script";
type MatchItem = {
// id是为了避免match重复
id: number;
match: string;
self: boolean;
hasMatch: boolean;
isExclude: boolean;
byUser: boolean;
hasMatch: boolean; // 是否已经匹配
isExclude: boolean; // 是否是排除项
};
const Match: React.FC<{
script: Script;
}> = ({ script }) => {
// const scriptCtrl = IoC.instance(ScriptController) as ScriptController;
const scriptDAO = new ScriptDAO();
const [match, setMatch] = useState<MatchItem[]>([]);
const [exclude, setExclude] = useState<MatchItem[]>([]);
const [matchValue, setMatchValue] = useState<string>("");
@@ -37,7 +30,7 @@ const Match: React.FC<{
useEffect(() => {
if (script) {
// 从数据库中获取是简单处理数据一致性的问题
scriptCtrl.scriptDAO.findById(script.id).then((res) => {
scriptDAO.get(script.uuid).then((res) => {
if (!res) {
return;
}
@@ -48,28 +41,17 @@ const Match: React.FC<{
});
const v: MatchItem[] = [];
matchArr.forEach((value, index) => {
if (matchMap.has(value)) {
v.push({
id: index,
match: value,
self: false,
hasMatch: false,
isExclude: false,
});
} else {
v.push({
id: index,
match: value,
self: true,
hasMatch: false,
isExclude: false,
});
}
v.push({
id: index,
match: value,
byUser: !matchMap.has(value),
hasMatch: false,
isExclude: false,
});
});
setMatch(v);
const excludeArr =
res.selfMetadata?.exclude || res.metadata.exclude || [];
const excludeArr = res.selfMetadata?.exclude || res.metadata.exclude || [];
const excludeMap = new Map<string, boolean>();
res.metadata.exclude?.forEach((m) => {
excludeMap.set(m, true);
@@ -77,23 +59,13 @@ const Match: React.FC<{
const e: MatchItem[] = [];
excludeArr.forEach((value, index) => {
const hasMatch = matchMap.has(value);
if (excludeMap.has(value)) {
e.push({
id: index,
match: value,
self: false,
hasMatch,
isExclude: true,
});
} else {
e.push({
id: index,
match: value,
self: true,
hasMatch,
isExclude: true,
});
}
e.push({
id: index,
match: value,
byUser: !excludeMap.has(value),
hasMatch,
isExclude: true,
});
});
setExclude(e);
});
@@ -108,8 +80,8 @@ const Match: React.FC<{
},
{
title: t("user_setting"),
dataIndex: "self",
key: "self",
dataIndex: "byUser",
key: "byUser",
width: 100,
render(col) {
if (col) {
@@ -125,23 +97,24 @@ const Match: React.FC<{
return (
<Space>
<Popconfirm
title={`${t("confirm_delete_exclude")}${
item.hasMatch ? ` ${t("after_deleting_match_item")}` : ""
}`}
title={`${t("confirm_delete_exclude")}${item.hasMatch ? ` ${t("after_deleting_match_item")}` : ""}`}
onOk={() => {
exclude.splice(exclude.indexOf(item), 1);
scriptCtrl
// 删除所有排除
scriptClient
.resetExclude(
script.id,
script.uuid,
exclude.map((m) => m.match)
)
.then(() => {
setExclude([...exclude]);
// 如果包含在里面再加回match
if (item.hasMatch) {
match.push(item);
scriptCtrl
// 重置匹配
scriptClient
.resetMatch(
script.id,
script.uuid,
match.map((m) => m.match)
)
.then(() => {
@@ -159,24 +132,22 @@ const Match: React.FC<{
return (
<Space>
<Popconfirm
title={`${t("confirm_delete_match")}${
item.self ? "" : ` ${t("after_deleting_exclude_item")}`
}`}
title={`${t("confirm_delete_match")}${item.byUser ? "" : ` ${t("after_deleting_exclude_item")}`}`}
onOk={() => {
match.splice(match.indexOf(item), 1);
scriptCtrl
scriptClient
.resetMatch(
script.id,
script.uuid,
match.map((m) => m.match)
)
.then(() => {
setMatch([...match]);
// 添加到exclue
if (!item.self) {
if (!item.byUser) {
exclude.push(item);
scriptCtrl
scriptClient
.resetExclude(
script.id,
script.uuid,
exclude.map((m) => m.match)
)
.then(() => {
@@ -205,13 +176,13 @@ const Match: React.FC<{
match.push({
id: Math.random(),
match: matchValue,
self: true,
byUser: true,
hasMatch: false,
isExclude: false,
});
scriptCtrl
scriptClient
.resetMatch(
script.id,
script.uuid,
match.map((m) => m.match)
)
.then(() => {
@@ -237,13 +208,13 @@ const Match: React.FC<{
exclude.push({
id: Math.random(),
match: excludeValue,
self: true,
byUser: true,
hasMatch: false,
isExclude: true,
});
scriptCtrl
scriptClient
.resetExclude(
script.id,
script.uuid,
exclude.map((m) => m.match)
)
.then(() => {
@@ -276,7 +247,7 @@ const Match: React.FC<{
<Popconfirm
title={t("confirm_reset")}
onOk={() => {
scriptCtrl.resetMatch(script.id, undefined).then(() => {
scriptClient.resetMatch(script.uuid, undefined).then(() => {
setMatch([]);
});
}}
@@ -305,7 +276,7 @@ const Match: React.FC<{
<Popconfirm
title={t("confirm_reset")}
onOk={() => {
scriptCtrl.resetExclude(script.id, undefined).then(() => {
scriptClient.resetExclude(script.uuid, undefined).then(() => {
setExclude([]);
});
}}

View File

@@ -2,26 +2,14 @@ import React, { useEffect, useState } from "react";
import { Permission } from "@App/app/repo/permission";
import { Script } from "@App/app/repo/scripts";
import { useTranslation } from "react-i18next";
import {
Space,
Popconfirm,
Message,
Button,
Checkbox,
Input,
Modal,
Select,
Typography,
} from "@arco-design/web-react";
import { Space, Popconfirm, Message, Button, Checkbox, Input, Modal, Select, Typography } from "@arco-design/web-react";
import Table, { ColumnProps } from "@arco-design/web-react/es/Table";
import { IconDelete } from "@arco-design/web-react/icon";
import { permissionClient } from "@App/pages/store/features/script";
const PermissionManager: React.FC<{
script: Script;
}> = ({ script }) => {
// const permissionCtrl = IoC.instance(
// PermissionController
// ) as PermissionController;
const [permission, setPermission] = useState<Permission[]>([]);
const [permissionVisible, setPermissionVisible] = useState<boolean>(false);
const [permissionValue, setPermissionValue] = useState<Permission>();
@@ -59,14 +47,15 @@ const PermissionManager: React.FC<{
<Popconfirm
title={t("confirm_delete_permission")}
onOk={() => {
permissionCtrl
.deletePermission(script!.id, {
permission: item.permission,
permissionValue: item.permissionValue,
})
permissionClient
.deletePermission(script.uuid, item.permission, item.permissionValue)
.then(() => {
Message.success(t("delete_success")!);
setPermission(permission.filter((i) => i.id !== item.id));
setPermission(
permission.filter(
(i) => !(i.permission == item.permission && i.permissionValue == item.permissionValue)
)
);
})
.catch(() => {
Message.error(t("delete_failed")!);
@@ -83,7 +72,7 @@ const PermissionManager: React.FC<{
useEffect(() => {
if (script) {
permissionCtrl.getPermissions(script.id).then((list) => {
permissionClient.getScriptPermissions(script.uuid).then((list) => {
setPermission(list);
});
}
@@ -98,20 +87,17 @@ const PermissionManager: React.FC<{
onOk={() => {
if (permissionValue) {
permission.push({
id: 0,
uuid: script.id,
uuid: script.uuid,
permission: permissionValue.permission,
permissionValue: permissionValue.permissionValue,
allow: permissionValue.allow,
createtime: new Date().getTime(),
updatetime: 0,
});
permissionCtrl
.addPermission(script.id, permissionValue)
.then(() => {
setPermission([...permission]);
setPermissionVisible(false);
});
permissionClient.addPermission(permissionValue).then(() => {
setPermission([...permission]);
setPermissionVisible(false);
});
}
}}
>
@@ -119,27 +105,22 @@ const PermissionManager: React.FC<{
<Select
value={permissionValue?.permission}
onChange={(e) => {
permissionValue &&
setPermissionValue({ ...permissionValue, permission: e });
permissionValue && setPermissionValue({ ...permissionValue, permission: e });
}}
>
<Select.Option value="cors">{t("permission_cors")}</Select.Option>
<Select.Option value="cookie">
{t("permission_cookie")}
</Select.Option>
<Select.Option value="cookie">{t("permission_cookie")}</Select.Option>
</Select>
<Input
value={permissionValue?.permissionValue}
onChange={(e) => {
permissionValue &&
setPermissionValue({ ...permissionValue, permissionValue: e });
permissionValue && setPermissionValue({ ...permissionValue, permissionValue: e });
}}
/>
<Checkbox
checked={permissionValue?.allow}
onChange={(e) => {
permissionValue &&
setPermissionValue({ ...permissionValue, allow: e });
permissionValue && setPermissionValue({ ...permissionValue, allow: e });
}}
>
{t("allow")}
@@ -147,17 +128,14 @@ const PermissionManager: React.FC<{
</Space>
</Modal>
<div className="flex flex-row justify-between pb-2">
<Typography.Title heading={6}>
{t("permission_management")}
</Typography.Title>
<Typography.Title heading={6}>{t("permission_management")}</Typography.Title>
<Space>
<Button
type="primary"
size="small"
onClick={() => {
setPermissionValue({
id: 0,
uuid: script.id,
uuid: script.uuid,
permission: "cors",
permissionValue: "",
allow: true,
@@ -172,7 +150,7 @@ const PermissionManager: React.FC<{
<Popconfirm
title={t("confirm_reset")}
onOk={() => {
permissionCtrl.resetPermission(script.id).then(() => {
permissionClient.resetPermission(script.uuid).then(() => {
setPermission([]);
});
}}
@@ -183,12 +161,7 @@ const PermissionManager: React.FC<{
</Popconfirm>
</Space>
</div>
<Table
columns={columns}
data={permission}
rowKey="id"
pagination={false}
/>
<Table columns={columns} data={permission} rowKey="id" pagination={false} />
</>
);
};

View File

@@ -1,13 +1,6 @@
import { Script } from "@App/app/repo/scripts";
import { Script, ScriptDAO } from "@App/app/repo/scripts";
import { formatUnixTime } from "@App/pkg/utils/utils";
import {
Descriptions,
Divider,
Drawer,
Empty,
Input,
Message,
} from "@arco-design/web-react";
import { Descriptions, Divider, Drawer, Empty, Input, Message } from "@arco-design/web-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Match from "./Match";
@@ -19,14 +12,14 @@ const ScriptSetting: React.FC<{
onOk: () => void;
onCancel: () => void;
}> = ({ script, visible, onCancel, onOk }) => {
// const scriptCtrl = IoC.instance(ScriptController) as ScriptController;
const scriptDAO = new ScriptDAO();
const [checkUpdateUrl, setCheckUpdateUrl] = useState<string>("");
const { t } = useTranslation();
useEffect(() => {
if (script) {
scriptCtrl.scriptDAO.findById(script.id).then((v) => {
scriptDAO.get(script.uuid).then((v) => {
setCheckUpdateUrl(v?.downloadUrl || "");
});
}
@@ -56,9 +49,7 @@ const ScriptSetting: React.FC<{
data={[
{
label: t("last_updated"),
value: formatUnixTime(
(script?.updatetime || script?.createtime || 0) / 1000
),
value: formatUnixTime((script?.updatetime || script?.createtime || 0) / 1000),
},
{
label: "UUID",
@@ -83,8 +74,8 @@ const ScriptSetting: React.FC<{
setCheckUpdateUrl(e);
}}
onBlur={() => {
scriptCtrl
.updateCheckUpdateUrl(script!.id, checkUpdateUrl)
scriptDAO
.update(script.uuid, { downloadUrl: checkUpdateUrl, checkUpdateUrl: checkUpdateUrl })
.then(() => {
Message.success(t("update_success")!);
});

View File

@@ -1,5 +1,6 @@
import { Script } from "@App/app/repo/scripts";
import { Value } from "@App/app/repo/value";
import { valueClient } from "@App/pages/store/features/script";
import { valueType } from "@App/pkg/utils/utils";
import { Button, Drawer, Form, Input, Message, Modal, Popconfirm, Select, Space, Table } from "@arco-design/web-react";
import { RefInputType } from "@arco-design/web-react/es/Input/interface";
@@ -10,16 +11,20 @@ import { useTranslation } from "react-i18next";
const FormItem = Form.Item;
interface ValueModel {
key: string;
value: any;
}
const ScriptStorage: React.FC<{
// eslint-disable-next-line react/require-default-props
script?: Script;
visible: boolean;
onOk: () => void;
onCancel: () => void;
}> = ({ script, visible, onCancel, onOk }) => {
const [data, setData] = useState<Value[]>([]);
const [data, setData] = useState<ValueModel[]>([]);
const inputRef = useRef<RefInputType>(null);
const [currentValue, setCurrentValue] = useState<Value>();
const [currentValue, setCurrentValue] = useState<ValueModel>();
const [visibleEdit, setVisibleEdit] = useState(false);
const [form] = Form.useForm();
const { t } = useTranslation();
@@ -28,31 +33,13 @@ const ScriptStorage: React.FC<{
if (!script) {
return () => {};
}
// valueCtrl.getValues(script).then((values) => {
// setData(values);
// });
// Monitor value changes
// const channel = valueCtrl.watchValue(script);
// channel.setHandler((value: Value) => {
// setData((prev) => {
// const index = prev.findIndex((item) => item.key === value.key);
// if (index === -1) {
// if (value.value === undefined) {
// return prev;
// }
// return [value, ...prev];
// }
// if (value.value === undefined) {
// prev.splice(index, 1);
// return [...prev];
// }
// prev[index] = value;
// return [...prev];
// });
// });
return () => {
// channel.disChannel();
};
valueClient.getScriptValue(script).then((value) => {
setData(
Object.keys(value).map((key) => {
return { key: key, value: value[key] };
})
);
});
}, [script]);
const columns: ColumnProps[] = [
{
@@ -61,7 +48,6 @@ const ScriptStorage: React.FC<{
key: "key",
filterIcon: <IconSearch />,
width: 140,
// eslint-disable-next-line react/no-unstable-nested-components
filterDropdown: ({ filterKeys, setFilterKeys, confirm }: any) => {
return (
<div className="arco-table-custom-filter">
@@ -120,7 +106,7 @@ const ScriptStorage: React.FC<{
},
{
title: t("action"),
render(_col, value: Value, index) {
render(_col, value: { key: string; value: string }, index) {
return (
<Space>
<Button
@@ -136,7 +122,7 @@ const ScriptStorage: React.FC<{
iconOnly
icon={<IconDelete />}
onClick={() => {
valueCtrl.setValue(script!.id, value.key, undefined);
valueClient.setScriptValue(script!.uuid, value.key, undefined);
Message.info({
content: t("delete_success"),
});
@@ -179,7 +165,7 @@ const ScriptStorage: React.FC<{
default:
break;
}
valueCtrl.setValue(script!.id, value.key, value.value);
valueClient.setScriptValue(script!.uuid, value.key, value.value);
if (currentValue) {
Message.info({
content: t("update_success"),
@@ -201,13 +187,8 @@ const ScriptStorage: React.FC<{
});
setData([
{
id: 0,
scriptId: script!.id,
storageName: (script?.metadata.storagename && script?.metadata.storagename[0]) || "",
key: value.key,
value: value.value,
createtime: Date.now(),
updatetime: 0,
},
...data,
]);
@@ -254,7 +235,7 @@ const ScriptStorage: React.FC<{
onOk={() => {
setData((prev) => {
prev.forEach((v) => {
valueCtrl.setValue(script!.id, v.key, undefined);
valueClient.setScriptValue(script!.uuid, v.key, undefined);
});
Message.info({
content: t("clear_success"),

View File

@@ -1,18 +1,10 @@
import React, { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; // 添加这行导入语句
import { Script, UserConfig } from "@App/app/repo/scripts";
import {
Checkbox,
Form,
FormInstance,
Input,
InputNumber,
Message,
Modal,
Select,
Tabs,
} from "@arco-design/web-react";
import { Checkbox, Form, FormInstance, Input, InputNumber, Message, Modal, Select, Tabs } from "@arco-design/web-react";
import TabPane from "@arco-design/web-react/es/Tabs/tab-pane";
import { ValueClient } from "@App/app/service/service_worker/client";
import { message } from "@App/pages/store/global";
const FormItem = Form.Item;
@@ -41,17 +33,13 @@ const UserConfigPanel: React.FC<{
if (formRefs.current[tab]) {
const saveValues = formRefs.current[tab].getFieldsValue();
// 更新value
const valueCtrl = IoC.instance(ValueController) as ValueController;
const valueClient = new ValueClient(message);
Object.keys(saveValues).forEach((key) => {
Object.keys(saveValues[key]).forEach((valueKey) => {
if (saveValues[key][valueKey] === undefined) {
return;
}
valueCtrl.setValue(
script.id,
`${key}.${valueKey}`,
saveValues[key][valueKey]
);
valueClient.setScriptValue(script.uuid, `${key}.${valueKey}`, saveValues[key][valueKey]);
});
});
Message.success(t("save_success")!); // 替换为键值对应的英文文本
@@ -73,7 +61,7 @@ const UserConfigPanel: React.FC<{
return (
<TabPane key={itemKey} title={itemKey}>
<Form
key={script.id}
key={script.uuid}
style={{
width: "100%",
}}
@@ -85,11 +73,7 @@ const UserConfigPanel: React.FC<{
}}
>
{Object.keys(value).map((key) => (
<FormItem
key={key}
label={value[key].title}
field={`${itemKey}.${key}`}
>
<FormItem key={key} label={value[key].title} field={`${itemKey}.${key}`}>
{() => {
const item = value[key];
let { type } = item;
@@ -112,20 +96,9 @@ const UserConfigPanel: React.FC<{
switch (type) {
case "text":
if (item.password) {
return (
<Input.Password
placeholder={item.description}
maxLength={item.max}
/>
);
return <Input.Password placeholder={item.description} maxLength={item.max} />;
}
return (
<Input
placeholder={item.description}
maxLength={item.max}
showWordLimit
/>
);
return <Input placeholder={item.description} maxLength={item.max} showWordLimit />;
case "number":
return (
<InputNumber
@@ -136,13 +109,7 @@ const UserConfigPanel: React.FC<{
/>
);
case "checkbox":
return (
<Checkbox
defaultChecked={values[`${itemKey}.${key}`]}
>
{item.description}
</Checkbox>
);
return <Checkbox defaultChecked={values[`${itemKey}.${key}`]}>{item.description}</Checkbox>;
case "select":
case "mult-select":
// eslint-disable-next-line no-case-declarations
@@ -159,11 +126,7 @@ const UserConfigPanel: React.FC<{
}
return (
<Select
mode={
item.type === "mult-select"
? "multiple"
: undefined
}
mode={item.type === "mult-select" ? "multiple" : undefined}
placeholder={item.description}
>
{options!.map((option) => (

Some files were not shown because too many files have changed in this diff Show More