├── packages ├── cloudscript │ ├── README.md │ ├── factory.ts │ ├── local.ts │ └── cloudscript.ts ├── chrome-extension-mock │ ├── README.md │ ├── extension.ts │ ├── downloads.ts │ ├── i18n.ts │ ├── storage.ts │ ├── cookies.ts │ ├── declarativ_net_request.ts │ ├── index.ts │ ├── permissions.ts │ ├── web_reqeuest.ts │ ├── tab.ts │ └── notifications.ts ├── filesystem │ ├── README.md │ ├── utils.ts │ ├── error.ts │ ├── zip │ │ ├── rw.ts │ │ └── zip.ts │ ├── filesystem.ts │ ├── webdav │ │ ├── rw.ts │ │ └── webdav.ts │ ├── factory.ts │ └── onedrive │ │ └── rw.ts ├── message │ ├── README.md │ ├── types.ts │ ├── client.ts │ └── mock_message.ts └── eslint │ ├── compat-headers.js │ └── compat-grant.js ├── .prettierignore ├── src ├── app │ ├── repo │ │ ├── metadata.ts │ │ ├── value.ts │ │ ├── localStorage.ts │ │ ├── export.ts │ │ ├── permission.ts │ │ ├── logger.ts │ │ ├── subscribe.ts │ │ ├── sync.ts │ │ ├── resource.ts │ │ └── dao.ts │ ├── service │ │ ├── gm_api.test.ts │ │ ├── sandbox │ │ │ ├── index.ts │ │ │ └── client.ts │ │ ├── content │ │ │ ├── types.ts │ │ │ ├── gm_context.ts │ │ │ ├── gm_info.ts │ │ │ └── exec_warp.ts │ │ ├── queue.ts │ │ ├── offscreen │ │ │ ├── client.ts │ │ │ ├── script.ts │ │ │ └── index.ts │ │ └── service_worker │ │ │ ├── system.ts │ │ │ └── types.ts │ ├── cache_key.ts │ ├── const.ts │ ├── logger │ │ ├── db_writer.ts │ │ ├── message_writer.ts │ │ └── core.ts │ └── types.d.ts ├── pages │ ├── import │ │ ├── index.css │ │ └── main.tsx │ ├── sandbox.html │ ├── offscreen.html │ ├── options │ │ ├── routes │ │ │ └── script │ │ │ │ └── index.css │ │ ├── main.tsx │ │ └── index.css │ ├── popup │ │ ├── index.css │ │ └── main.tsx │ ├── components │ │ ├── layout │ │ │ └── index.css │ │ ├── LogLabel │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── CustomLink │ │ │ └── index.tsx │ │ └── CustomTrans │ │ │ └── index.tsx │ ├── store │ │ ├── global.ts │ │ ├── subscribe.ts │ │ ├── hooks.ts │ │ ├── store.ts │ │ ├── features │ │ │ └── config.ts │ │ └── utils.ts │ ├── options.html │ ├── template.html │ ├── popup.html │ ├── install │ │ ├── index.css │ │ └── main.tsx │ └── confirm │ │ └── main.tsx ├── assets │ ├── logo.png │ ├── logo │ │ ├── gf.png │ │ └── github.png │ ├── logo-beta.png │ ├── images │ │ └── edge_mobile_qrcode.png │ └── _locales │ │ ├── zh_CN │ │ └── messages.json │ │ ├── zh_TW │ │ └── messages.json │ │ ├── ja │ │ └── messages.json │ │ ├── de │ │ └── messages.json │ │ ├── en │ │ └── messages.json │ │ ├── ru │ │ └── messages.json │ │ └── vi │ │ └── messages.json ├── template │ ├── cloudcat-package │ │ ├── index.tpl │ │ ├── package.tpl │ │ └── utils.tpl │ ├── background.tpl │ ├── crontab.tpl │ └── normal.tpl ├── pkg │ ├── utils │ │ ├── dayjs.ts │ │ ├── monaco-editor │ │ │ └── config.ts │ │ ├── queue.ts │ │ ├── timer.ts │ │ ├── scriptInstall.ts │ │ ├── semver.ts │ │ ├── cron.ts │ │ ├── yaml.ts │ │ ├── crypto.test.ts │ │ ├── filehandle-db.ts │ │ ├── day_format.ts │ │ ├── crypto.ts │ │ ├── file-tracker.ts │ │ └── utils.test.ts │ ├── backup │ │ ├── utils.ts │ │ └── struct.ts │ └── config │ │ └── chrome_storage.ts ├── index.css ├── sandbox.ts ├── offscreen.ts ├── locales │ ├── arco.ts │ └── README.md ├── manifest.json ├── inject.ts ├── types │ └── main.d.ts ├── content.ts └── linter.worker.ts ├── postcss.config.mjs ├── .codecov.yml ├── uno.config.ts ├── crowdin.yml ├── example ├── usersubscribe.user.sub.js ├── run-in │ ├── run-in_none.js │ ├── run-in_normal-tabs.js │ ├── run-in_incognito-tabs.js │ └── run-in_both.js ├── sandbox_window.js ├── gm_clipboard.js ├── gm_log.js ├── vscode.user.js ├── grant_none.js ├── match_local_file.js ├── require_local_file.js ├── cloudcat.js ├── error_retry.js ├── gm_tab.js ├── gm_value │ ├── gm_value_1_bg.js │ ├── gm_value_1.js │ ├── gm_value_2.js │ ├── listener_change.js │ └── gm_value_2_bg.js ├── gm_bg_menu.js ├── gm_add_element.js ├── gm_save_tab.js ├── inject-into.js ├── gm_get_resource.js ├── gm_add_style.js ├── gm_download.js ├── gm_async.js ├── gm_menu.js ├── gm_xhr.js ├── early-start.js ├── cat_file_storage.js ├── gm_cookie.js ├── cat_bg_input_menu.js ├── gm_notification.js └── userconfig.js ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── feature_request_en.md │ ├── bug_report.yaml │ └── bug_report_en.yaml └── workflows │ ├── test.yaml │ ├── packageRelease.yml │ └── build.yaml ├── tests ├── mocks │ └── monaco-editor.ts ├── utils.test.ts ├── test-utils.tsx ├── utils.ts └── pages │ └── components │ └── ScriptMenuList.test.tsx ├── .gitignore ├── .prettierrc ├── SECURITY.md ├── docs └── AI prompt.md ├── tsconfig.json ├── vitest.config.ts ├── scripts ├── crowdin-download.js └── changlog.js └── eslint.config.mjs /packages/cloudscript/README.md: -------------------------------------------------------------------------------- 1 | # 脚本上云 2 | 3 | 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Lock files 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /src/app/repo/metadata.ts: -------------------------------------------------------------------------------- 1 | export type SCMetadata = Partial>; 2 | -------------------------------------------------------------------------------- /src/pages/import/index.css: -------------------------------------------------------------------------------- 1 | .import-list .arco-typography { 2 | margin: 0; 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodFrm/scriptcat/main/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/logo/gf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodFrm/scriptcat/main/src/assets/logo/gf.png -------------------------------------------------------------------------------- /src/assets/logo-beta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodFrm/scriptcat/main/src/assets/logo-beta.png -------------------------------------------------------------------------------- /src/template/cloudcat-package/index.tpl: -------------------------------------------------------------------------------- 1 | const utils = require('./utils'); 2 | 3 | utils.run(); 4 | -------------------------------------------------------------------------------- /packages/chrome-extension-mock/README.md: -------------------------------------------------------------------------------- 1 | # mock一个chrome扩展环境 2 | > 只针对自己的项目做了一些简单的封装,如果有需要可以自己修改 3 | 4 | -------------------------------------------------------------------------------- /src/assets/logo/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodFrm/scriptcat/main/src/assets/logo/github.png -------------------------------------------------------------------------------- /packages/filesystem/README.md: -------------------------------------------------------------------------------- 1 | # 文件系统 2 | 3 | 用于同步和备份至云端 4 | 5 | - zip 6 | - webdav 7 | - 百度网盘 8 | - onedrive 9 | -------------------------------------------------------------------------------- /packages/chrome-extension-mock/extension.ts: -------------------------------------------------------------------------------- 1 | export default class Extension { 2 | inIncognitoContext = false; 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | import UnoCSS from "@unocss/postcss"; 2 | 3 | export default { 4 | plugins: [UnoCSS()], 5 | }; 6 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | # Codecov configuration 2 | coverage: 3 | status: 4 | # 禁用补丁覆盖率检查 5 | patch: off 6 | 7 | comment: false 8 | -------------------------------------------------------------------------------- /src/assets/images/edge_mobile_qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodFrm/scriptcat/main/src/assets/images/edge_mobile_qrcode.png -------------------------------------------------------------------------------- /packages/chrome-extension-mock/downloads.ts: -------------------------------------------------------------------------------- 1 | export default class Downloads { 2 | download(_: any, callback: () => void) { 3 | callback && callback(); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/pkg/utils/dayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | 3 | // 为了发挥 ESM 的 Tree-Shaking 等功能,日后应转用 data-fns 之类的 ESM 库 4 | 5 | export function semTime(time: Date) { 6 | return dayjs().to(dayjs(time)); 7 | } 8 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, presetUno } from "unocss"; 2 | 3 | export default defineConfig({ 4 | content: { 5 | filesystem: ["./src/**/*.{html,js,ts,jsx,tsx}"], 6 | }, 7 | presets: [presetUno()], 8 | }); 9 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | project_id: "620320" 2 | api_token_env: CROWDIN_PERSONAL_TOKEN 3 | preserve_hierarchy: true 4 | 5 | files: 6 | # JSON 翻译文件 7 | - source: /src/locales/zh-CN/**/* 8 | translation: /src/locales/%locale%/**/%original_file_name% 9 | -------------------------------------------------------------------------------- /src/pages/sandbox.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sandbox 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /example/usersubscribe.user.sub.js: -------------------------------------------------------------------------------- 1 | // ==UserSubscribe== 2 | // @name 订阅脚本 3 | // @description 可以通过指定脚本url订阅一系列的脚本 4 | // @version 1.0.0 5 | // @author You 6 | // @connect www.baidu.com 7 | // @scriptURL https://scriptcat.org/scripts/code/22/test.user.js 8 | // ==/UserSubscribe== 9 | -------------------------------------------------------------------------------- /src/assets/_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n": { 3 | "message": "i18n" 4 | }, 5 | "scriptcat": { 6 | "message": "脚本猫" 7 | }, 8 | "scriptcat_beta": { 9 | "message": "脚本猫 Beta" 10 | }, 11 | "scriptcat_description": { 12 | "message": "万物皆可脚本化,让你的浏览器可以做更多的事情!" 13 | } 14 | } -------------------------------------------------------------------------------- /src/assets/_locales/zh_TW/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n": { 3 | "message": "i18n" 4 | }, 5 | "scriptcat": { 6 | "message": "腳本貓" 7 | }, 8 | "scriptcat_beta": { 9 | "message": "腳本貓 Beta" 10 | }, 11 | "scriptcat_description": { 12 | "message": "萬物皆可腳本化,讓你的瀏覽器可以做更多的事情!" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "新功能请求" 3 | about: 期望能够增加的功能 4 | title: "[Feature] " 5 | labels: "enhancement" 6 | assignees: "" 7 | --- 8 | 9 | ### 功能描述 10 | 11 | 请清晰描述你想要的功能: 12 | 13 | ### 使用场景 14 | 15 | 在什么情况下需要这个功能?(例如:处理特定网站时,提升操作效率等) 16 | 17 | ### 附加说明 18 | 19 | (可选)补充截图、示例代码或其他参考信息 20 | -------------------------------------------------------------------------------- /src/assets/_locales/ja/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n": { 3 | "message": "i18n" 4 | }, 5 | "scriptcat": { 6 | "message": "ScriptCat" 7 | }, 8 | "scriptcat_beta": { 9 | "message": "ScriptCat Beta" 10 | }, 11 | "scriptcat_description": { 12 | "message": "すべてをスクリプト化し、ブラウザでより多くのことができるようになります!" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/pkg/backup/utils.ts: -------------------------------------------------------------------------------- 1 | import ZipFileSystem from "@Packages/filesystem/zip/zip"; 2 | import type JSZip from "jszip"; 3 | import BackupImport from "./import"; 4 | 5 | // 解析备份文件 6 | export function parseBackupZipFile(zip: JSZip) { 7 | const fs = new ZipFileSystem(zip); 8 | // 解析文件 9 | return new BackupImport(fs).parse(); 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/_locales/de/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n": { 3 | "message": "i18n" 4 | }, 5 | "scriptcat": { 6 | "message": "ScriptCat" 7 | }, 8 | "scriptcat_beta": { 9 | "message": "ScriptCat Beta" 10 | }, 11 | "scriptcat_description": { 12 | "message": "Alles kann geskriptet werden, damit Ihr Browser mehr kann!" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/assets/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n": { 3 | "message": "i18n" 4 | }, 5 | "scriptcat": { 6 | "message": "ScriptCat" 7 | }, 8 | "scriptcat_beta": { 9 | "message": "ScriptCat Beta" 10 | }, 11 | "scriptcat_description": { 12 | "message": "Everything can be scripted, allowing your browser to do more!" 13 | } 14 | } -------------------------------------------------------------------------------- /example/run-in/run-in_none.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Test @run-in none 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 不设置@run-in默认既注入正常标签又注入隐身标签 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/* 8 | // ==/UserScript== 9 | console.log(GM_info.script["run-in"]); 10 | -------------------------------------------------------------------------------- /src/pages/offscreen.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Offscreen 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/assets/_locales/ru/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n": { 3 | "message": "i18n" 4 | }, 5 | "scriptcat": { 6 | "message": "ScriptCat" 7 | }, 8 | "scriptcat_beta": { 9 | "message": "ScriptCat Beta" 10 | }, 11 | "scriptcat_description": { 12 | "message": "Всё можно автоматизировать скриптами, позволяя вашему браузеру делать больше!" 13 | } 14 | } -------------------------------------------------------------------------------- /example/sandbox_window.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name New Userscript 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description try to take over the world! 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @grant window.close 9 | // ==/UserScript== 10 | 11 | window.close(); 12 | -------------------------------------------------------------------------------- /src/assets/_locales/vi/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n": { 3 | "message": "I18n" 4 | }, 5 | "scriptcat": { 6 | "message": "ScriptCat" 7 | }, 8 | "scriptcat_beta": { 9 | "message": "ScriptCat Beta" 10 | }, 11 | "scriptcat_description": { 12 | "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!" 13 | } 14 | } -------------------------------------------------------------------------------- /src/pages/options/routes/script/index.css: -------------------------------------------------------------------------------- 1 | .edit-tabs .arco-tabs-header-title-text { 2 | max-width: 200px; 3 | overflow: hidden; 4 | text-overflow: ellipsis; 5 | white-space: nowrap; 6 | } 7 | 8 | .script-code-editor { 9 | margin: 0; 10 | padding: 0; 11 | border: 0; 12 | width: 100%; 13 | height: 100%; 14 | overflow: hidden; 15 | box-sizing: border-box; 16 | } 17 | -------------------------------------------------------------------------------- /example/gm_clipboard.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm clipboard 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description try to take over the world! 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @grant GM_setClipboard 9 | // ==/UserScript== 10 | 11 | GM_setClipboard("我爱ScriptCat"); 12 | -------------------------------------------------------------------------------- /example/gm_log.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm log 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 日志功能,为你的脚本加上丰富的日志吧,支持日志分级与日志标签 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @grant GM_log 9 | // ==/UserScript== 10 | 11 | GM_log("log message", "info", { component: "example" }); -------------------------------------------------------------------------------- /example/vscode.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name vscode 同步测试 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description vscode scriptcat 插件同步测试 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // ==/UserScript== 9 | 10 | (function() { 11 | 'use strict'; 12 | // Your code here... 13 | })(); -------------------------------------------------------------------------------- /src/template/background.tpl: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name New Userscript 3 | // @namespace https://docs.scriptcat.org/ 4 | // @version 0.1.0 5 | // @description try to take over the world! 6 | // @author You 7 | // @background 8 | // ==/UserScript== 9 | 10 | return new Promise((resolve, reject) => { 11 | // Your code here... 12 | resolve(); 13 | }); 14 | -------------------------------------------------------------------------------- /example/grant_none.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Grant None 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description try to take over the world! 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @grant none 9 | // ==/UserScript== 10 | 11 | console.log("Grant None", this, GM_info); 12 | 13 | -------------------------------------------------------------------------------- /src/app/service/gm_api.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, beforeEach, describe, expect, it } from "vitest"; 2 | 3 | // serviceWorker环境 4 | 5 | beforeAll(() => {}); 6 | 7 | describe("GM xhr", () => { 8 | beforeEach(() => { 9 | // See https://webext-core.aklinker1.io/fake-browser/reseting-state 10 | }); 11 | it("123123", async () => { 12 | expect(1).toBe(1); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/pages/popup/index.css: -------------------------------------------------------------------------------- 1 | .arco-collapse-item-header-title { 2 | width: 100%; 3 | } 4 | 5 | .arco-collapse-item-header-title .arco-space { 6 | width: 100%; 7 | } 8 | 9 | .arco-space-item:last-child { 10 | overflow: hidden; 11 | } 12 | 13 | .arco-collapse[class] { 14 | border-bottom: 1px solid var(--color-neutral-3); 15 | } 16 | 17 | .arco-collapse-item { 18 | border: 0; 19 | } 20 | -------------------------------------------------------------------------------- /src/template/crontab.tpl: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name New Userscript 3 | // @namespace https://docs.scriptcat.org/ 4 | // @version 0.1.0 5 | // @description try to take over the world! 6 | // @author You 7 | // @crontab * * once * * 8 | // ==/UserScript== 9 | 10 | return new Promise((resolve, reject) => { 11 | // Your code here... 12 | resolve(); 13 | }); 14 | -------------------------------------------------------------------------------- /example/run-in/run-in_normal-tabs.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Test @run-in normal-tabs 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description @run-in normal-tabs 只注入正常标签 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/* 8 | // @run-in normal-tabs 9 | // ==/UserScript== 10 | console.log(GM_info.script["run-in"]); 11 | -------------------------------------------------------------------------------- /example/run-in/run-in_incognito-tabs.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Test @run-in incognito-tabs 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description @run-in incognito-tabs 只注入隐身标签 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/* 8 | // @run-in incognito-tabs 9 | // ==/UserScript== 10 | console.log(GM_info.script["run-in"]); 11 | -------------------------------------------------------------------------------- /src/template/cloudcat-package/package.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudcat-package", 3 | "version": "1.0.0", 4 | "description": "scriptcat后台脚本打包项目", 5 | "main": "index.js", 6 | "scripts": { 7 | "run": "node index.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "CodFrm", 11 | "license": "MIT", 12 | "dependencies": { 13 | "scriptcat-nodejs": "^0.1.7" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/cache_key.ts: -------------------------------------------------------------------------------- 1 | export const CACHE_KEY_IMPORT_FILE = "importFile:"; // importFile 导入文件 2 | export const CACHE_KEY_TAB_SCRIPT = "tabScript:"; 3 | export const CACHE_KEY_SCRIPT_INFO = "scriptInfo:"; // 加载脚本信息时的缓存 4 | export const CACHE_KEY_FAVICON = "favicon:"; 5 | export const CACHE_KEY_SET_VALUE = "setValue:"; 6 | export const CACHE_KEY_REGISTRY_SCRIPT = "registryScript:"; 7 | export const CACHE_KEY_PERMISSION = "permission:"; 8 | -------------------------------------------------------------------------------- /example/match_local_file.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name 在本地文件中运行 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description try to take over the world! 6 | // @author You 7 | // @match file:///Users/codfrm/Desktop/test.html 8 | // ==/UserScript== 9 | 10 | (function () { 11 | "use strict"; 12 | 13 | // Your code here... 14 | console.log("hello"); 15 | })(); 16 | -------------------------------------------------------------------------------- /packages/filesystem/utils.ts: -------------------------------------------------------------------------------- 1 | export function joinPath(...paths: string[]): string { 2 | let path = ""; 3 | for (let value of paths) { 4 | if (!value) { 5 | continue; 6 | } 7 | if (!value.startsWith("/")) { 8 | value = `/${value}`; 9 | } 10 | if (value.endsWith("/")) { 11 | value = value.substring(0, value.length - 1); 12 | } 13 | path += value; 14 | } 15 | return path; 16 | } 17 | -------------------------------------------------------------------------------- /src/template/normal.tpl: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name New Userscript 3 | // @namespace https://docs.scriptcat.org/ 4 | // @version 0.1.0 5 | // @description try to take over the world! 6 | // @author You 7 | // @match {{match}} 8 | // @icon {{icon}} 9 | // @grant none 10 | // ==/UserScript== 11 | 12 | (function() { 13 | 'use strict'; 14 | 15 | // Your code here... 16 | })(); 17 | -------------------------------------------------------------------------------- /tests/mocks/monaco-editor.ts: -------------------------------------------------------------------------------- 1 | // Mock for monaco-editor 2 | export const editor = { 3 | setTheme: () => {}, 4 | create: () => ({ 5 | dispose: () => {}, 6 | getValue: () => "", 7 | setValue: () => {}, 8 | }), 9 | createModel: () => ({ 10 | dispose: () => {}, 11 | getValue: () => "", 12 | setValue: () => {}, 13 | }), 14 | setModelLanguage: () => {}, 15 | }; 16 | 17 | export default { editor }; 18 | -------------------------------------------------------------------------------- /src/app/repo/value.ts: -------------------------------------------------------------------------------- 1 | import { Repo } from "./repo"; 2 | 3 | export interface Value { 4 | uuid: string; 5 | storageName?: string; 6 | data: { [key: string]: any }; 7 | createtime: number; 8 | updatetime: number; 9 | } 10 | 11 | export class ValueDAO extends Repo { 12 | constructor() { 13 | super("value"); 14 | } 15 | 16 | save(key: string, value: Value) { 17 | return super._save(key, value); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /example/require_local_file.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name 引用本地文件 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description try to take over the world! 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @require file:///Users/codfrm/Desktop/test.js 9 | // ==/UserScript== 10 | 11 | (function() { 12 | 'use strict'; 13 | 14 | // Your code here... 15 | })(); -------------------------------------------------------------------------------- /example/cloudcat.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name cloudscript 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 可以导出成nodejs可执行的包,在云端执行 6 | // @author You 7 | // @crontab * * once * * 8 | // @cloudCat 9 | // @exportCookie domain=.scriptscat.org 10 | // ==/UserScript== 11 | 12 | return new Promise((resolve, reject) => { 13 | // Your code here... 14 | resolve(); 15 | }); 16 | 17 | -------------------------------------------------------------------------------- /src/app/repo/localStorage.ts: -------------------------------------------------------------------------------- 1 | import { Repo } from "./repo"; 2 | 3 | export interface LocalStorageItem { 4 | key: string; 5 | value: any; 6 | } 7 | 8 | // 由于service worker不能使用localStorage,这里新建一个类来实现localStorage的功能 9 | export class LocalStorageDAO extends Repo { 10 | constructor() { 11 | super("localStorage"); 12 | } 13 | 14 | save(value: LocalStorageItem) { 15 | return super._save(value.key, value); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /example/run-in/run-in_both.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Test @run-in both 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description @run-in normal-tabs & @run-in incognito-tabs 既注入正常标签又注入隐身标签 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/* 8 | // @run-in normal-tabs 9 | // @run-in incognito-tabs 10 | // ==/UserScript== 11 | console.log(GM_info.script["run-in"]); 12 | -------------------------------------------------------------------------------- /packages/chrome-extension-mock/i18n.ts: -------------------------------------------------------------------------------- 1 | export default class I18n { 2 | getUILanguage() { 3 | return "zh-CN"; 4 | } 5 | 6 | getAcceptLanguages(callback?: (lngs: string[]) => void) { 7 | const languages = ["zh-CN", "en"]; 8 | if (callback) { 9 | callback(languages); 10 | } 11 | return Promise.resolve(languages); 12 | } 13 | 14 | getMessage(key: string, _substitutions?: string | string[]) { 15 | // 简单返回key作为测试值 16 | return key; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | coverage 27 | CHANGELOG.md 28 | 29 | tailwind.config.js 30 | 31 | .env 32 | 33 | package-lock.json 34 | yarn.lock 35 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "embeddedLanguageFormatting": "auto", 6 | "htmlWhitespaceSensitivity": "css", 7 | "insertPragma": false, 8 | "jsxSingleQuote": false, 9 | "printWidth": 120, 10 | "proseWrap": "always", 11 | "quoteProps": "as-needed", 12 | "requirePragma": false, 13 | "semi": true, 14 | "singleQuote": false, 15 | "tabWidth": 2, 16 | "trailingComma": "es5", 17 | "useTabs": false, 18 | "endOfLine": "auto" 19 | } -------------------------------------------------------------------------------- /example/error_retry.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name 重试示例 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description try to take over the world! 6 | // @author You 7 | // @crontab * * once * * 8 | // @grant GM_notification 9 | // ==/UserScript== 10 | 11 | return new Promise((resolve, reject) => { 12 | // Your code here... 13 | GM_notification({ 14 | title: "retry", 15 | text: "10秒后重试" 16 | }); 17 | reject(new CATRetryError("xxx错误", 10)); 18 | }); 19 | -------------------------------------------------------------------------------- /example/gm_tab.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm open tab 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 打开一个标签页 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @grant GM_openInTab 9 | // ==/UserScript== 10 | 11 | const tab = GM_openInTab("https://scriptcat.org/search"); 12 | 13 | tab.onclose = () => { 14 | console.log("close"); 15 | } 16 | 17 | setTimeout(() => { 18 | tab.close(); 19 | }, 3000) 20 | 21 | -------------------------------------------------------------------------------- /example/gm_value/gm_value_1_bg.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm value storage 设置方 - 定时脚本 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 多个脚本之间共享数据 设置方 - 定时脚本 6 | // @author You 7 | // @run-at document-start 8 | // @grant GM_setValue 9 | // @grant GM_deleteValue 10 | // @storageName example 11 | // @crontab */5 * * * * * 12 | // ==/UserScript== 13 | 14 | return new Promise((resolve) => { 15 | GM_setValue("test_set", Date.now()); 16 | resolve(); 17 | }); 18 | -------------------------------------------------------------------------------- /src/app/const.ts: -------------------------------------------------------------------------------- 1 | import { version } from "../../package.json"; 2 | 3 | export const ExtVersion = version; 4 | export const Discord = "https://discord.gg/JF76nHCCM7"; 5 | export const DocumentationSite = "https://docs.scriptcat.org"; 6 | 7 | export const ExtServer = "https://ext.scriptcat.org/"; 8 | export const ExtServerApi = ExtServer + "api/v1/"; 9 | 10 | export const ExternalWhitelist = ["greasyfork.org", "scriptcat.org", "tampermonkey.net.cn", "openuserjs.org"]; 11 | 12 | export const ExternalMessage = "externalMessage"; 13 | -------------------------------------------------------------------------------- /example/gm_bg_menu.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name bg gm menu 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 在后台脚本中使用菜单 6 | // @author You 7 | // @background 8 | // @grant GM_registerMenuCommand 9 | // @grant GM_unregisterMenuCommand 10 | // ==/UserScript== 11 | 12 | return new Promise((resolve) => { 13 | const id = GM_registerMenuCommand("测试菜单", () => { 14 | console.log(id); 15 | GM_unregisterMenuCommand(id); 16 | resolve(); 17 | }, "z"); 18 | }); -------------------------------------------------------------------------------- /example/gm_value/gm_value_1.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm value storage 设置方 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 多个脚本之间共享数据 设置方 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @run-at document-start 9 | // @grant GM_setValue 10 | // @grant GM_deleteValue 11 | // @storageName example 12 | // ==/UserScript== 13 | 14 | setTimeout(() => { 15 | GM_deleteValue("test_set"); 16 | }, 3000); 17 | 18 | GM_setValue("test_set", Date.now()); 19 | -------------------------------------------------------------------------------- /example/gm_add_element.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm add element 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 在页面中插入元素,可以绕过CSP限制 6 | // @author You 7 | // @match https://github.com/scriptscat/scriptcat 8 | // @grant GM_addElement 9 | // ==/UserScript== 10 | 11 | const el = GM_addElement(document.querySelector('.BorderGrid-cell'), "img", { 12 | src: "https://bbs.tampermonkey.net.cn/uc_server/avatar.php?uid=4&size=small&ts=1" 13 | }); 14 | 15 | console.log(el); -------------------------------------------------------------------------------- /packages/message/README.md: -------------------------------------------------------------------------------- 1 | # 消息 2 | 3 | 对扩展内消息交互的抽象 4 | 5 | 主要会有以下几种类型的消息: 6 | 7 | - 从脚本发起的GM请求,需要层层传递到service_worker/offscreen进行处理,有的GM只需要进行一次调用获取一次结果,有的需要进行 8 | 多次调用获取多次结果,使用connect的方式实现 9 | - 从service_worker/offscreen发起的请求,类似消息队列,其它页面进行监听,触发后广播给所有页面,使用sendMessage方式实现 10 | - 从扩展页面发起的请求,需要传递到service_worker/offscreen进行处理,如果只是单次调用,获取一次结果,使用sendMessage方式实现,如果需要 11 | 多次调用获取多次结果,使用connect方式实现 12 | 13 | ## 注意点 14 | 15 | - service_worker和offscreen之间可以使用postMessage的方式进行通信,避免同时监听message与connect导致冲突的问题 16 | - service_worker会变为不活动的状态,尽量避免与service_worker建立长连接 17 | -------------------------------------------------------------------------------- /example/gm_save_tab.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm get/save tab 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 用于保存当前标签页的数据, 关闭后会自动删除, 可以获取其它标签页的数据 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @grant GM_saveTab 9 | // @grant GM_getTab 10 | // @grant GM_getTabs 11 | // ==/UserScript== 12 | 13 | GM_saveTab({ test: "save" }); 14 | 15 | GM_getTab(data => { 16 | console.log(data); 17 | }); 18 | 19 | GM_getTabs(data => { 20 | console.log(data); 21 | }); 22 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @unocss preflights; 2 | @unocss default; 3 | 4 | body { 5 | scrollbar-color: var(--color-scrollbar-thumb) var(--color-scrollbar-track); 6 | /* 对于webkit浏览器的滚动条样式 */ 7 | scrollbar-width: thin; 8 | } 9 | 10 | body[arco-theme='dark'] { 11 | --color-scrollbar-thumb: #6b6b6b; 12 | --color-scrollbar-track: #2d2d2d; 13 | --color-scrollbar-thumb-hover: #8c8c8c; 14 | } 15 | 16 | body[arco-theme='light'] { 17 | --color-scrollbar-thumb: #6b6b6b; 18 | --color-scrollbar-track: #f0f0f0; 19 | --color-scrollbar-thumb-hover: #8c8c8c; 20 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request_en.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature Request" 3 | about: Features you would like to see added 4 | title: "[Feature] " 5 | labels: "enhancement" 6 | assignees: "" 7 | --- 8 | 9 | ### Feature Description 10 | 11 | Please clearly describe the feature you want: 12 | 13 | ### Use Case 14 | 15 | In what situations is this feature needed? (e.g., when processing specific websites, improving operational efficiency, etc.) 16 | 17 | ### Additional Information 18 | 19 | (Optional) Supplementary screenshots, sample code, or other reference information 20 | -------------------------------------------------------------------------------- /packages/filesystem/error.ts: -------------------------------------------------------------------------------- 1 | export class WarpTokenError { 2 | error: Error; 3 | 4 | constructor(error: Error) { 5 | this.error = error; 6 | } 7 | } 8 | 9 | export function isWarpTokenError(error: any): error is WarpTokenError { 10 | return error instanceof WarpTokenError; 11 | } 12 | 13 | export class WarpNetworkError { 14 | error: Error; 15 | 16 | constructor(error: Error) { 17 | this.error = error; 18 | } 19 | } 20 | 21 | export function isNetworkError(error: any): error is WarpNetworkError { 22 | return error instanceof WarpNetworkError; 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/components/layout/index.css: -------------------------------------------------------------------------------- 1 | .arco-dropdown-menu-selected { 2 | background-color: var(--color-fill-2) !important; 3 | } 4 | 5 | .action-tools .arco-dropdown-popup-visible .arco-icon-down { 6 | transform: rotate(180deg); 7 | } 8 | 9 | .action-tools>.arco-btn { 10 | padding: 0 8px; 11 | } 12 | 13 | .arco-dropdown-menu-item, 14 | .arco-dropdown-menu-item a { 15 | display: flex; 16 | align-items: center; 17 | } 18 | 19 | :is(.arco-dropdown-menu-pop-header, .arco-dropdown-menu-item, .arco-dropdown-menu-item a) > svg { 20 | margin-right: .5em; 21 | } -------------------------------------------------------------------------------- /src/pkg/utils/monaco-editor/config.ts: -------------------------------------------------------------------------------- 1 | import type { languages } from "monaco-editor"; 2 | 3 | const config = { 4 | noSemanticValidation: true, 5 | noSyntaxValidation: false, 6 | onlyVisible: false, 7 | // 该配置用于修复 Error: Could not find source file: 'inmemory://model/1'. 8 | // https://github.com/microsoft/monaco-editor/issues/1842 9 | // https://github.com/suren-atoyan/monaco-react/issues/75#issuecomment-1890761086 10 | allowNonTsExtensions: true, 11 | } as languages.typescript.CompilerOptions; 12 | 13 | export const defaultConfig = JSON.stringify(config, null, 2); 14 | -------------------------------------------------------------------------------- /src/pkg/utils/queue.ts: -------------------------------------------------------------------------------- 1 | // 一个简单的队列,可以使用pop阻塞等待消息 2 | export default class Queue { 3 | list: T[] = []; 4 | 5 | resolve?: (data: T) => void; 6 | 7 | push(data: T) { 8 | if (this.resolve) { 9 | this.resolve(data); 10 | this.resolve = undefined; 11 | } else { 12 | this.list.push(data); 13 | } 14 | } 15 | 16 | pop(): Promise { 17 | return new Promise((resolve) => { 18 | if (this.list.length > 0) { 19 | resolve(this.list.shift()); 20 | } else { 21 | this.resolve = resolve; 22 | } 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/template/cloudcat-package/utils.tpl: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { ScriptCat } = require("scriptcat-nodejs/dist/src/scriptcat"); 3 | const { ModelValues } = require("scriptcat-nodejs/dist/src/storage/values"); 4 | const { cookies } = require('./cookies'); 5 | const { values } = require('./values'); 6 | 7 | exports.run = function () { 8 | const code = fs.readFileSync('userScript.js', 'utf8'); 9 | 10 | const run = new ScriptCat(); 11 | run.RunOnce(code, { 12 | cookies: cookies, 13 | values: new ModelValues(values), 14 | }).then((res) => { 15 | console.log(res); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /example/inject-into.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Inject into 3 | // @namespace https://docs.scriptcat.org/ 4 | // @version 0.1.0 5 | // @description 将脚本注入到content环境,以绕过CSP检测,请注意此环境无法访问页面的window,哪怕使用unsafeWindow,只与页面共享document 6 | // @author You 7 | // @match https://benjamin-philipp.com/test-trusted-types.php 8 | // @icon https://www.google.com/s2/favicons?sz=64&domain=benjamin-philipp.com 9 | // @inject-into content 10 | // ==/UserScript== 11 | 12 | // 插入元素 13 | const div = document.createElement("div"); 14 | div.innerHTML = "hello scriptcat"; 15 | document.body.append(div); 16 | -------------------------------------------------------------------------------- /example/gm_get_resource.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm get resource 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 通过@resource引用资源,这个资源会被管理器进行缓存,不可修改 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @resource bbs https://bbs.tampermonkey.net.cn/ 9 | // @grant GM_getResourceURL 10 | // @grant GM_getResourceText 11 | // ==/UserScript== 12 | 13 | 14 | console.log(GM_getResourceURL("bbs")); 15 | console.log(GM_getResourceURL("bbs", false)); 16 | console.log(GM_getResourceURL("bbs", true)); 17 | console.log(GM_getResourceText("bbs")); -------------------------------------------------------------------------------- /src/app/service/sandbox/index.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "@Packages/message/server"; 2 | import { type WindowMessage } from "@Packages/message/window_message"; 3 | import { preparationSandbox } from "../offscreen/client"; 4 | import { Runtime } from "./runtime"; 5 | 6 | // sandbox环境的管理器 7 | export class SandboxManager { 8 | api: Server = new Server("sandbox", this.windowMessage); 9 | 10 | constructor(private windowMessage: WindowMessage) {} 11 | 12 | initManager() { 13 | const runtime = new Runtime(this.windowMessage, this.api); 14 | runtime.init(); 15 | // 通知初始化好环境了 16 | preparationSandbox(this.windowMessage); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example/gm_add_style.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm add style 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 在页面中插入style元素,可以绕过CSP限制 6 | // @author You 7 | // @match https://github.com/scriptscat/scriptcat 8 | // @grant GM_addStyle 9 | // ==/UserScript== 10 | 11 | const el = GM_addStyle(` 12 | body { 13 | background: #000; 14 | color: #fff; 15 | } 16 | a { text-decoration: none } 17 | a:link { color: #00f } 18 | a:visited { color: #003399 } 19 | a:hover { color: #ff0000; text-decoration: underline } 20 | a:active { color: #ff0000 } 21 | `); 22 | 23 | console.log(el); -------------------------------------------------------------------------------- /packages/eslint/compat-headers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | /* eslint-disable no-undef */ 3 | const compat_headers = require("eslint-plugin-userscripts/dist/data/compat-headers.js"); 4 | 5 | const compatMap = { 6 | ...compat_headers.compatMap, 7 | nonFunctional: { 8 | ...compat_headers.compatMap.nonFunctional, 9 | // 覆盖或新增新的属性 10 | background: [], 11 | crontab: [], 12 | cloudCat: [], 13 | cloudServer: [], 14 | exportValue: [], 15 | exportCookie: [], 16 | scriptUrl: [], 17 | storageName: [], 18 | "early-start": [], 19 | }, 20 | }; 21 | 22 | module.exports = { compatMap }; 23 | -------------------------------------------------------------------------------- /src/pages/store/global.ts: -------------------------------------------------------------------------------- 1 | import { MessageQueue } from "@Packages/message/message_queue"; 2 | import { SystemConfig } from "@App/pkg/config/config"; 3 | import { ExtensionMessage } from "@Packages/message/extension_message"; 4 | import { initLocales } from "@App/locales/locales"; 5 | import { SystemClient } from "@App/app/service/service_worker/client"; 6 | 7 | export const messageQueue = new MessageQueue(); 8 | export const systemConfig = new SystemConfig(messageQueue); 9 | export const globalCache = new Map(); 10 | export const message = new ExtensionMessage(); 11 | export const systemClient = new SystemClient(message); 12 | 13 | initLocales(systemConfig); 14 | -------------------------------------------------------------------------------- /src/app/logger/db_writer.ts: -------------------------------------------------------------------------------- 1 | import type { LoggerDAO } from "../repo/logger"; 2 | import type { LogLabel, LogLevel, Writer } from "./core"; 3 | 4 | // 使用indexdb作为日志存储 5 | export default class DBWriter implements Writer { 6 | dao: LoggerDAO; 7 | 8 | constructor(dao: LoggerDAO) { 9 | this.dao = dao; 10 | } 11 | 12 | async write(level: LogLevel, message: string, label: LogLabel): Promise { 13 | try { 14 | await this.dao.save({ 15 | id: 0, 16 | level, 17 | message, 18 | label, 19 | createtime: Date.now(), 20 | }); 21 | } catch (e) { 22 | console.error("DBWriter error", e); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/sandbox.ts: -------------------------------------------------------------------------------- 1 | import { WindowMessage } from "@Packages/message/window_message"; 2 | import LoggerCore from "./app/logger/core"; 3 | import MessageWriter from "./app/logger/message_writer"; 4 | import { SandboxManager } from "./app/service/sandbox"; 5 | 6 | function main() { 7 | // 建立与offscreen页面的连接 8 | const msg = new WindowMessage(window, parent); 9 | 10 | // 初始化日志组件 11 | const loggerCore = new LoggerCore({ 12 | writer: new MessageWriter(msg, "offscreen/logger"), 13 | labels: { env: "sandbox" }, 14 | }); 15 | loggerCore.logger().debug("offscreen start"); 16 | 17 | // 初始化管理器 18 | const manager = new SandboxManager(msg); 19 | manager.initManager(); 20 | } 21 | 22 | main(); 23 | -------------------------------------------------------------------------------- /src/pkg/utils/timer.ts: -------------------------------------------------------------------------------- 1 | const timerMap: { [key: string | number]: NodeJS.Timeout | number | undefined } = {}; 2 | export const timeoutExecution = (key: string, fn: () => void, delayMs: number) => { 3 | if (timerMap[key]) { 4 | clearTimeout(timerMap[key]); 5 | timerMap[key] = 0; 6 | } 7 | timerMap[key] = setTimeout(fn, delayMs); 8 | }; 9 | export const intervalExecution = ( 10 | key: string, 11 | fn: (firstExecute?: boolean) => void, 12 | delayMs: number, 13 | executeNow: boolean = false 14 | ) => { 15 | if (timerMap[key]) { 16 | clearInterval(timerMap[key]); 17 | timerMap[key] = 0; 18 | } 19 | timerMap[key] = setInterval(fn, delayMs); 20 | if (executeNow) fn(true); 21 | }; 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /src/app/logger/message_writer.ts: -------------------------------------------------------------------------------- 1 | import type { LogLabel, LogLevel, Writer } from "./core"; 2 | import type { MessageSend } from "@Packages/message/types"; 3 | 4 | // 通过通讯机制写入日志 5 | export default class MessageWriter implements Writer { 6 | send: MessageSend; 7 | 8 | constructor( 9 | send: MessageSend, 10 | private action: string = "logger" 11 | ) { 12 | this.send = send; 13 | } 14 | 15 | write(level: LogLevel, message: string, label: LogLabel): void { 16 | this.send.sendMessage({ 17 | action: this.action, 18 | data: { 19 | id: 0, 20 | level, 21 | message, 22 | label, 23 | createtime: Date.now(), 24 | }, 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/repo/export.ts: -------------------------------------------------------------------------------- 1 | import type { ExportParams } from "@Packages/cloudscript/cloudscript"; 2 | import { Repo } from "./repo"; 3 | 4 | export type ExportTarget = "local" | "tencentCloud"; 5 | 6 | // 导出与本地脚本关联记录 7 | export interface Export { 8 | uuid: string; 9 | params: { 10 | [key: string]: ExportParams; 11 | }; 12 | // 导出目标 13 | target: ExportTarget; 14 | } 15 | 16 | export class ExportDAO extends Repo { 17 | public tableName = "export"; 18 | 19 | constructor() { 20 | super("export"); 21 | } 22 | 23 | findByScriptID(uuid: string) { 24 | return this.get(uuid); 25 | } 26 | 27 | save(model: Export): Promise { 28 | return this._save(model.uuid, model); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/pages/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= htmlRspackPlugin.options.title %> 7 | 15 | 16 | 17 |
18 | 19 | <% if rspackConfig.mode=="script" { %> 20 | 21 | 22 | <% } %> 23 | 24 | -------------------------------------------------------------------------------- /src/pkg/utils/scriptInstall.ts: -------------------------------------------------------------------------------- 1 | import type { InstallSource } from "@App/app/service/service_worker/types"; 2 | import type { SCMetadata } from "@App/app/repo/metadata"; 3 | 4 | export { InstallSource }; 5 | 6 | export type ScriptInfo = { 7 | url: string; 8 | code: string; 9 | uuid: string; 10 | userSubscribe: boolean; 11 | metadata: SCMetadata; 12 | source: InstallSource; 13 | }; 14 | 15 | // 供 getInstallInfo 使用 16 | export function createScriptInfo( 17 | uuid: string, 18 | code: string, 19 | url: string, 20 | source: InstallSource, 21 | metadata: SCMetadata 22 | ): ScriptInfo { 23 | const userSubscribe = metadata.usersubscribe !== undefined; 24 | return { uuid, code, url, source, metadata, userSubscribe } as ScriptInfo; 25 | } 26 | -------------------------------------------------------------------------------- /src/pkg/utils/semver.ts: -------------------------------------------------------------------------------- 1 | import Logger from "@App/app/logger/logger"; 2 | import semver from "semver"; 3 | 4 | // 对比版本大小 5 | export function ltever(newVersion: string, oldVersion: string, logger?: Logger) { 6 | // 先验证符不符合语义化版本规范 7 | try { 8 | return semver.lte(newVersion, oldVersion); 9 | } catch (e) { 10 | logger?.warn("does not conform to the Semantic Versioning specification", Logger.E(e)); 11 | } 12 | const newVer = newVersion.split("."); 13 | const oldVer = oldVersion.split("."); 14 | for (let i = 0; i < newVer.length; i++) { 15 | if (Number(newVer[i]) > Number(oldVer[i])) { 16 | return false; 17 | } 18 | if (Number(newVer[i]) < Number(oldVer[i])) { 19 | return true; 20 | } 21 | } 22 | return true; 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/components/LogLabel/index.css: -------------------------------------------------------------------------------- 1 | .log-query-label .arco-select-view { 2 | border-radius: 0; 3 | } 4 | 5 | .log-query-label .arco-select:first-child { 6 | border-left: 1px solid var(--color-neutral-3); 7 | } 8 | 9 | .log-query-label .arco-select { 10 | width: auto; 11 | border-top: 1px solid var(--color-neutral-3); 12 | border-bottom: 1px solid var(--color-neutral-3); 13 | } 14 | 15 | .log-query-label .arco-btn { 16 | height: 34px; 17 | border-left: 0; 18 | border-radius: 0; 19 | border-top: 1px solid var(--color-neutral-3); 20 | border-bottom: 1px solid var(--color-neutral-3); 21 | border-right: 1px solid var(--color-neutral-3); 22 | } 23 | 24 | .log-query-label .arco-select { 25 | border-right: 1px solid var(--color-neutral-3); 26 | } 27 | -------------------------------------------------------------------------------- /example/gm_download.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm download 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description try to take over the world! 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @grant GM_download 9 | // ==/UserScript== 10 | 11 | GM_download({ 12 | url: "https://scriptcat.org/api/v2/open/crx-download/ndcooeababalnlpkfedmmbbbgkljhpjf", 13 | name: "scriptcat.crx", 14 | headers: { 15 | "referer": "http://www.example.com/", 16 | "origin": "www.example.com" 17 | }, onprogress(data) { 18 | console.log(data); 19 | }, onload(data) { 20 | console.log("load", data); 21 | }, 22 | downloadMethod: "xhr" 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/repo/permission.ts: -------------------------------------------------------------------------------- 1 | import { Repo } from "./repo"; 2 | 3 | export interface Permission { 4 | uuid: string; 5 | permission: string; 6 | permissionValue: string; 7 | allow: boolean; 8 | createtime: number; 9 | updatetime: number; 10 | } 11 | 12 | export class PermissionDAO extends Repo { 13 | constructor() { 14 | super("permission"); 15 | } 16 | 17 | key(model: Permission) { 18 | return model.uuid + ":" + model.permission + ":" + model.permissionValue; 19 | } 20 | 21 | findByKey(uuid: string, permission: string, permissionValue: string) { 22 | return this.get(uuid + ":" + permission + ":" + permissionValue); 23 | } 24 | 25 | save(value: Permission) { 26 | return super._save(this.key(value), value); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/cloudscript/factory.ts: -------------------------------------------------------------------------------- 1 | import type { ExportTarget } from "@App/app/repo/export"; 2 | import type { ExportParams } from "./cloudscript"; 3 | import LocalCloudScript from "./local"; 4 | 5 | export interface CloudScriptParams { 6 | [key: string]: { 7 | title: string; 8 | type?: "select"; 9 | options?: string[]; 10 | }; 11 | } 12 | 13 | export default class CloudScriptFactory { 14 | static create(type: ExportTarget, params: ExportParams) { 15 | switch (type) { 16 | case "local": 17 | return new LocalCloudScript(params); 18 | default: 19 | throw new Error(`unknown type ${type}`); 20 | } 21 | } 22 | 23 | static params(): { [key: string]: CloudScriptParams } { 24 | return { 25 | local: {}, 26 | }; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/offscreen.ts: -------------------------------------------------------------------------------- 1 | import type { MessageSend } from "@Packages/message/types"; 2 | import LoggerCore from "./app/logger/core"; 3 | import MessageWriter from "./app/logger/message_writer"; 4 | import { OffscreenManager } from "./app/service/offscreen"; 5 | import { ExtensionMessageSend } from "@Packages/message/extension_message"; 6 | 7 | function main() { 8 | // 初始化日志组件 9 | const extensionMessage: MessageSend = new ExtensionMessageSend(); 10 | const loggerCore = new LoggerCore({ 11 | writer: new MessageWriter(extensionMessage, "serviceWorker/logger"), 12 | labels: { env: "offscreen" }, 13 | }); 14 | loggerCore.logger().debug("offscreen start"); 15 | // 初始化管理器 16 | const manager = new OffscreenManager(extensionMessage); 17 | manager.initManager(); 18 | } 19 | 20 | main(); 21 | -------------------------------------------------------------------------------- /src/pages/store/subscribe.ts: -------------------------------------------------------------------------------- 1 | import type { TInstallScript, TDeleteScript, TScriptRunStatus } from "@App/app/service/queue"; 2 | import { messageQueue } from "./global"; 3 | import { store } from "./store"; 4 | import { batchDeleteScript, scriptSlice, upsertScript } from "./features/script"; 5 | 6 | export default function storeSubscribe() { 7 | messageQueue.subscribe("scriptRunStatus", (data) => { 8 | store.dispatch(scriptSlice.actions.updateRunStatus(data)); 9 | }); 10 | 11 | messageQueue.subscribe("installScript", (message) => { 12 | store.dispatch(upsertScript(message.script)); 13 | }); 14 | 15 | messageQueue.subscribe("deleteScript", (message) => { 16 | store.dispatch(batchDeleteScript([message.uuid])); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /example/gm_value/gm_value_2.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm value storage 读取与监听方 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 多个脚本之间共享数据 读取与监听方 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @run-at document-start 9 | // @grant GM_getValue 10 | // @grant GM_addValueChangeListener 11 | // @grant GM_listValues 12 | // @grant GM_cookie 13 | // @storageName example 14 | // ==/UserScript== 15 | 16 | GM_addValueChangeListener("test_set", function (name, oldval, newval, remote) { 17 | console.log("test_set change", name, oldval, newval, remote); 18 | }); 19 | 20 | setInterval(() => { 21 | console.log("test_set: ", GM_getValue("test_set")); 22 | console.log("value list:", GM_listValues()); 23 | }, 2000); 24 | -------------------------------------------------------------------------------- /example/gm_async.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name 异步GM函数 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description try to take over the world! 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @grant GM.getValue 9 | // @grant GM.setValue 10 | // @resource test.html https://bbs.tampermonkey.net.cn/ 11 | // @grant GM.getResourceUrl 12 | // ==/UserScript== 13 | 14 | (async function () { 15 | 'use strict'; 16 | GM.setValue("test-key", 1).then(() => { 17 | GM.getValue("test-key").then(value => { 18 | console.log("get test-key value: ", value); 19 | }) 20 | }); 21 | const resourceUrl = await GM.getResourceUrl("test.html"); 22 | console.log(resourceUrl); 23 | })(); -------------------------------------------------------------------------------- /src/app/service/sandbox/client.ts: -------------------------------------------------------------------------------- 1 | import { type ScriptRunResource } from "@App/app/repo/scripts"; 2 | import { sendMessage } from "@Packages/message/client"; 3 | import { type WindowMessage } from "@Packages/message/window_message"; 4 | 5 | export function enableScript(msg: WindowMessage, data: ScriptRunResource) { 6 | return sendMessage(msg, "sandbox/enableScript", data); 7 | } 8 | 9 | export function disableScript(msg: WindowMessage, uuid: string) { 10 | return sendMessage(msg, "sandbox/disableScript", uuid); 11 | } 12 | 13 | export function runScript(msg: WindowMessage, data: ScriptRunResource) { 14 | return sendMessage(msg, "sandbox/runScript", data); 15 | } 16 | 17 | export function stopScript(msg: WindowMessage, uuid: string) { 18 | return sendMessage(msg, "sandbox/stopScript", uuid); 19 | } 20 | -------------------------------------------------------------------------------- /packages/filesystem/zip/rw.ts: -------------------------------------------------------------------------------- 1 | import type { JSZipObject } from "jszip"; 2 | import type JSZip from "jszip"; 3 | import type { FileReader, FileWriter } from "../filesystem"; 4 | 5 | export class ZipFileReader implements FileReader { 6 | zipObject: JSZipObject; 7 | 8 | constructor(zipObject: JSZipObject) { 9 | this.zipObject = zipObject; 10 | } 11 | 12 | read(type?: "string" | "blob"): Promise { 13 | return this.zipObject.async(type || "string"); 14 | } 15 | } 16 | 17 | export class ZipFileWriter implements FileWriter { 18 | zip: JSZip; 19 | 20 | path: string; 21 | 22 | constructor(zip: JSZip, path: string) { 23 | this.zip = zip; 24 | this.path = path; 25 | } 26 | 27 | async write(content: string): Promise { 28 | this.zip.file(this.path, content); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/repo/logger.ts: -------------------------------------------------------------------------------- 1 | import type { LogLabel, LogLevel } from "../logger/core"; 2 | import { DAO, db } from "./dao"; 3 | 4 | export interface Logger { 5 | id: number; 6 | level: LogLevel; 7 | message: string; 8 | label: LogLabel; 9 | createtime: number; 10 | } 11 | 12 | export class LoggerDAO extends DAO { 13 | public tableName = "logger"; 14 | 15 | constructor() { 16 | super(); 17 | this.table = db.table(this.tableName); 18 | } 19 | 20 | async queryLogs(startTime: number, endTime: number) { 21 | const ret = await this.table.where("createtime").between(startTime, endTime).toArray(); 22 | 23 | return ret.sort((a, b) => b.createtime - a.createtime); 24 | } 25 | 26 | deleteBefore(time: number) { 27 | return this.table.where("createtime").below(time).delete(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/pages/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= htmlRspackPlugin.options.title %> 7 | 20 | 21 | 22 | 23 |
24 | 25 | <% if rspackConfig.mode=="script" { %> 26 | 27 | 28 | <% } %> 29 | 30 | -------------------------------------------------------------------------------- /packages/eslint/compat-grant.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | /* eslint-disable no-undef */ 3 | const compat_grant = require("eslint-plugin-userscripts/dist/data/compat-grant.js"); 4 | const compatMap = { 5 | CAT_userConfig: [{ type: "scriptcat", versionConstraint: ">=0.11.0-beta" }], 6 | CAT_fileStorage: [{ type: "scriptcat", versionConstraint: ">=0.11.0" }], 7 | CAT_registerMenuInput: [{ type: "scriptcat", versionConstraint: ">=0.17.0-beta.2" }], 8 | CAT_unregisterMenuInput: [{ type: "scriptcat", versionConstraint: ">=0.17.0-beta.2" }], 9 | CAT_scriptLoaded: [{ type: "scriptcat", versionConstraint: ">=1.1.0-beta" }], 10 | ...compat_grant.compatMap, 11 | }; 12 | 13 | const gmPolyfillOverride = { 14 | ...compat_grant.gmPolyfillOverride, 15 | }; 16 | 17 | module.exports = { compatMap, gmPolyfillOverride }; 18 | -------------------------------------------------------------------------------- /example/gm_value/listener_change.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm value listener change 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 监听脚本数据变更 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @run-at document-start 9 | // @grant GM_getValue 10 | // @grant GM_addValueChangeListener 11 | // @grant GM_listValues 12 | // @grant GM_setValue 13 | // ==/UserScript== 14 | 15 | GM_addValueChangeListener("test_set", function (name, oldval, newval, remote) { 16 | console.log("test_set change", name, oldval, newval, remote); 17 | }); 18 | 19 | setInterval(() => { 20 | console.log("test_set: ", GM_getValue("test_set")); 21 | console.log("value list:", GM_listValues()); 22 | GM_setValue("test_set", Date.now()); 23 | }, 2000); 24 | -------------------------------------------------------------------------------- /packages/chrome-extension-mock/storage.ts: -------------------------------------------------------------------------------- 1 | export default class Storage { 2 | sync = new ChromeStorage(); 3 | local = new ChromeStorage(); 4 | session = new ChromeStorage(); 5 | } 6 | 7 | export class ChromeStorage { 8 | data: any = {}; 9 | 10 | get(key: string, callback: (data: any) => void) { 11 | if (key === null) { 12 | callback(this.data); 13 | return; 14 | } 15 | callback({ [key]: this.data[key] }); 16 | } 17 | 18 | set(data: any, callback: () => void) { 19 | this.data = Object.assign(this.data, data); 20 | callback(); 21 | } 22 | 23 | remove(keys: string | string[], callback: () => void) { 24 | if (typeof keys === "string") { 25 | delete this.data[keys]; 26 | } else { 27 | keys.forEach((key) => { 28 | delete this.data[key]; 29 | }); 30 | } 31 | callback(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docs/AI prompt.md: -------------------------------------------------------------------------------- 1 | # AI Prompt 2 | 3 | 我将在这里记录下开发过程中的AI提示词,让AI更好的助力项目发展(使用VSCode Github Copilot Agent模式) 4 | 5 | ## 单元测试 6 | 7 | ```md 8 | ### 角色 9 | 你是一名专业的 TypeScript 测试工程师,精通 Vitest 测试框架和单元测试最佳实践。 10 | 11 | ### 任务 12 | 请为我提供的 TypeScript 文件编写完整的单元测试套件,遵循以下规范: 13 | 1. **测试框架**:使用 Vitest 14 | 2. **文件命名**:`<原文件名>.test.ts` 格式,与原文件同级目录 15 | 3. **测试覆盖**: 16 | - 覆盖所有导出函数/类 17 | - 包含正向、负向和边界测试用例 18 | - 验证异步逻辑和错误处理 19 | 4. **最佳实践**: 20 | - 使用 `describe`/`it` 组织测试结构 21 | - 包含必要的 setup/teardown 逻辑 22 | - 使用 `vi.fn()`/`vi.mock()` 模拟外部依赖 23 | - 添加清晰的测试描述 24 | 25 | ### 输入格式 26 | 请严格按此格式提供被测试代码,下面请为此文件编写单元测试 27 | 28 | ``` 29 | 30 | ## 提取翻译 31 | 32 | ```md 33 | 34 | 你是一个翻译专家,使用react-i18next做为翻译框架,我需要你帮助我翻译这个React文件中的中文,首先你需要提取文件中的中文部分,生成一个合适的key,使用蛇形命名,添加到 src/locales/zh-CN/translations.json 文件中,然后使用`useTranslation`替换原有中文,如果有参数你可以使用i18next的格式,不需要处理其他语言,不要做多余的事情 35 | 36 | ``` -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - release/* 8 | - dev 9 | - develop/* 10 | pull_request: 11 | 12 | jobs: 13 | tests: 14 | runs-on: ubuntu-latest 15 | name: Run tests 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4 19 | 20 | - name: Use Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 22 24 | cache: 'pnpm' 25 | 26 | - name: Install dependencies 27 | run: pnpm i --frozen-lockfile 28 | 29 | - name: Lint 30 | run: pnpm run lint 31 | 32 | - name: Unit Test 33 | run: | 34 | pnpm test 35 | pnpm run coverage 36 | 37 | - name: Upload coverage reports to Codecov with GitHub Action 38 | uses: codecov/codecov-action@v5 39 | -------------------------------------------------------------------------------- /example/gm_value/gm_value_2_bg.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm value storage 读取与监听方 - 后台脚本 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 多个脚本之间共享数据 读取与监听方 - 后台脚本 6 | // @author You 7 | // @run-at document-start 8 | // @grant GM_getValue 9 | // @grant GM_addValueChangeListener 10 | // @grant GM_listValues 11 | // @grant GM_cookie 12 | // @storageName example 13 | // @background 14 | // ==/UserScript== 15 | 16 | return new Promise((resolve) => { 17 | GM_addValueChangeListener("test_set", function (name, oldval, newval, remote) { 18 | console.log("value change", name, oldval, newval, remote); 19 | }); 20 | 21 | setInterval(() => { 22 | console.log("test_set: ", GM_getValue("test_set")); 23 | console.log("value list:", GM_listValues()); 24 | }, 2000); 25 | // 永不返回resolve表示永不结束 26 | // resolve() 27 | }); 28 | -------------------------------------------------------------------------------- /.github/workflows/packageRelease.yml: -------------------------------------------------------------------------------- 1 | name: Auto_Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build-deploy: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: pnpm/action-setup@v4 16 | 17 | - name: Use Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 22 21 | cache: 'pnpm' 22 | 23 | - name: Package with Node 24 | env: 25 | CHROME_PEM: ${{ secrets.CHROME_PEM }} 26 | run: | 27 | mkdir dist 28 | echo "$CHROME_PEM" > ./dist/scriptcat.pem 29 | chmod 600 ./dist/scriptcat.pem 30 | pnpm i --frozen-lockfile 31 | pnpm test 32 | pnpm run pack 33 | 34 | - uses: ncipollo/release-action@v1 35 | with: 36 | artifacts: "./dist/*.zip,./dist/*.crx" 37 | -------------------------------------------------------------------------------- /packages/chrome-extension-mock/cookies.ts: -------------------------------------------------------------------------------- 1 | export default class Cookies { 2 | getAllCookieStores(callback: (cookieStores: chrome.cookies.CookieStore[]) => void) { 3 | callback([ 4 | { 5 | id: "0", 6 | tabIds: [1], 7 | }, 8 | ]); 9 | } 10 | 11 | mockGetAll?: ( 12 | details: chrome.cookies.GetAllDetails, 13 | callback: (cookies: chrome.cookies.Cookie[]) => void 14 | ) => void | undefined; 15 | 16 | async getAll( 17 | details: chrome.cookies.GetAllDetails, 18 | callback: (cookies: chrome.cookies.Cookie[]) => void 19 | ): Promise { 20 | this.mockGetAll?.(details, callback); 21 | return []; 22 | } 23 | 24 | set(details: chrome.cookies.SetDetails, callback?: () => void): void { 25 | callback?.(); 26 | } 27 | 28 | remove(details: chrome.cookies.CookieDetails, callback?: () => void): void { 29 | callback?.(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/pages/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= htmlRspackPlugin.options.title %> 8 | 21 | 22 | 23 | 24 |
25 | 26 | <% if rspackConfig.mode=="script" { %> 27 | 28 | 29 | <% } %> 30 | 31 | -------------------------------------------------------------------------------- /src/pages/components/CustomLink/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import React from "react"; 3 | import { useNavigate } from "react-router-dom"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | const CustomLink: React.FC<{ 7 | children: ReactNode; 8 | to: string; 9 | className?: string; 10 | search?: string; 11 | }> = ({ children, to, search, className }) => { 12 | const nav = useNavigate(); 13 | const { t } = useTranslation(); 14 | 15 | const click = () => { 16 | if (window.onbeforeunload) { 17 | if (confirm(t("confirm_leave_page"))) { 18 | nav({ 19 | pathname: to, 20 | search, 21 | }); 22 | } 23 | } else { 24 | nav({ 25 | pathname: to, 26 | search, 27 | }); 28 | } 29 | }; 30 | 31 | return ( 32 |
33 | {children} 34 |
35 | ); 36 | }; 37 | 38 | export default CustomLink; 39 | -------------------------------------------------------------------------------- /src/pages/store/hooks.ts: -------------------------------------------------------------------------------- 1 | // This file serves as a central hub for re-exporting pre-typed Redux hooks. 2 | // These imports are restricted elsewhere to ensure consistent 3 | // usage of typed hooks throughout the application. 4 | // We disable the ESLint rule here because this is the designated place 5 | // for importing and re-exporting the typed versions of hooks. 6 | import { useDispatch, useSelector } from "react-redux"; 7 | import type { AppDispatch, RootState } from "./store"; 8 | import { asyncThunkCreator, buildCreateSlice } from "@reduxjs/toolkit"; 9 | 10 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 11 | export const useAppDispatch = useDispatch.withTypes(); 12 | export const useAppSelector = useSelector.withTypes(); 13 | 14 | // `buildCreateSlice` allows us to create a slice with async thunks. 15 | export const createAppSlice = buildCreateSlice({ 16 | creators: { asyncThunk: asyncThunkCreator }, 17 | }); 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": [ 5 | "DOM", 6 | "ES2020", 7 | "WebWorker" 8 | ], 9 | "module": "nodenext", 10 | "jsx": "react-jsx", 11 | "strict": true, 12 | "noEmit": true, 13 | "skipLibCheck": true, 14 | "isolatedModules": true, 15 | "resolveJsonModule": true, 16 | "moduleResolution": "nodenext", 17 | "esModuleInterop": true, 18 | "experimentalDecorators": true, 19 | "useDefineForClassFields": true, 20 | "allowImportingTsExtensions": true, 21 | "baseUrl": ".", 22 | "paths": { 23 | "@App/*": [ 24 | "src/*" 25 | ], 26 | "@Packages/*": [ 27 | "packages/*" 28 | ], 29 | "@Tests/*": [ 30 | "tests/*" 31 | ] 32 | } 33 | }, 34 | "include": [ 35 | "src", 36 | "packages", 37 | "tests" 38 | ], 39 | "ts-node": { 40 | "compilerOptions": { 41 | "module": "CommonJS" 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /packages/chrome-extension-mock/declarativ_net_request.ts: -------------------------------------------------------------------------------- 1 | export default class DeclarativeNetRequest { 2 | HeaderOperation = { 3 | APPEND: "append", 4 | SET: "set", 5 | REMOVE: "remove", 6 | }; 7 | 8 | RuleActionType = { 9 | BLOCK: "block", 10 | REDIRECT: "redirect", 11 | ALLOW: "allow", 12 | UPGRADE_SCHEME: "upgradeScheme", 13 | MODIFY_HEADERS: "modifyHeaders", 14 | ALLOW_ALL_REQUESTS: "allowAllRequests", 15 | }; 16 | 17 | ResourceType = { 18 | MAIN_FRAME: "main_frame", 19 | SUB_FRAME: "sub_frame", 20 | STYLESHEET: "stylesheet", 21 | SCRIPT: "script", 22 | IMAGE: "image", 23 | FONT: "font", 24 | OBJECT: "object", 25 | XMLHTTPREQUEST: "xmlhttprequest", 26 | PING: "ping", 27 | CSP_REPORT: "csp_report", 28 | MEDIA: "media", 29 | WEBSOCKET: "websocket", 30 | OTHER: "other", 31 | }; 32 | 33 | updateSessionRules() { 34 | return new Promise((resolve) => { 35 | resolve(); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | import { defineConfig } from "vitest/config"; 4 | 5 | export default defineConfig({ 6 | resolve: { 7 | alias: { 8 | "@App": path.resolve(__dirname, "./src"), 9 | "@Packages": path.resolve(__dirname, "./packages"), 10 | "@Tests": path.resolve(__dirname, "./tests"), 11 | "monaco-editor": path.resolve(__dirname, "./tests/mocks/monaco-editor.ts"), 12 | }, 13 | }, 14 | plugins: [ 15 | { 16 | name: "handle-tpl-files", 17 | load(id) { 18 | if (id.endsWith(".tpl")) { 19 | // Return the content as a string asset 20 | const content = fs.readFileSync(id, "utf-8"); 21 | return `export default ${JSON.stringify(content)};`; 22 | } 23 | }, 24 | }, 25 | ], 26 | test: { 27 | environment: "jsdom", 28 | // List setup file 29 | setupFiles: ["./tests/vitest.setup.ts"], 30 | env: { 31 | VI_TESTING: "true", 32 | }, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /packages/chrome-extension-mock/index.ts: -------------------------------------------------------------------------------- 1 | import Cookies from "./cookies"; 2 | import Downloads from "./downloads"; 3 | import Notifications from "./notifications"; 4 | import Runtime from "./runtime"; 5 | import MockTab from "./tab"; 6 | import WebRequest from "./web_reqeuest"; 7 | import Storage from "./storage"; 8 | import I18n from "./i18n"; 9 | import DeclarativeNetRequest from "./declarativ_net_request"; 10 | import Permissions from "./permissions"; 11 | import Extension from "./extension"; 12 | 13 | const chromeMock = { 14 | tabs: new MockTab(), 15 | runtime: new Runtime(), 16 | webRequest: new WebRequest(), 17 | notifications: new Notifications(), 18 | downloads: new Downloads(), 19 | cookies: new Cookies(), 20 | storage: new Storage(), 21 | i18n: new I18n(), 22 | declarativeNetRequest: new DeclarativeNetRequest(), 23 | permissions: new Permissions(), 24 | extension: new Extension(), 25 | init() {}, 26 | }; 27 | // @ts-ignore 28 | global.chrome = chromeMock; 29 | 30 | export default chromeMock; 31 | -------------------------------------------------------------------------------- /src/app/repo/subscribe.ts: -------------------------------------------------------------------------------- 1 | import { Repo } from "./repo"; 2 | import type { SCMetadata } from "./metadata"; 3 | 4 | export { SCMetadata }; 5 | 6 | export type SUBSCRIBE_STATUS = 1 | 2 | 3 | 4; 7 | export const SUBSCRIBE_STATUS_ENABLE: SUBSCRIBE_STATUS = 1; 8 | export const SUBSCRIBE_STATUS_DISABLE: SUBSCRIBE_STATUS = 2; 9 | 10 | export interface SubscribeScript { 11 | uuid: string; 12 | url: string; 13 | } 14 | 15 | export interface Subscribe { 16 | url: string; 17 | name: string; 18 | code: string; 19 | author: string; 20 | scripts: { [key: string]: SubscribeScript }; 21 | metadata: SCMetadata; 22 | status: SUBSCRIBE_STATUS; 23 | createtime: number; 24 | updatetime?: number; 25 | checktime: number; 26 | } 27 | 28 | export class SubscribeDAO extends Repo { 29 | constructor() { 30 | super("subscribe"); 31 | } 32 | 33 | public findByUrl(url: string) { 34 | return this.get(url); 35 | } 36 | 37 | public save(val: Subscribe) { 38 | return super._save(val.url, val); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/repo/sync.ts: -------------------------------------------------------------------------------- 1 | export type SyncType = "script" | "subscribe"; 2 | 3 | export type SyncAction = "update" | "delete"; 4 | 5 | export interface SyncScript { 6 | name: string; 7 | uuid: string; 8 | code: string; 9 | meta_json: string; 10 | self_meta: string; 11 | origin: string; 12 | sort: number; 13 | subscribe_url?: string; 14 | type: number; 15 | createtime: number; 16 | updatetime?: number; 17 | } 18 | 19 | export interface SycnSubscribe { 20 | name: string; 21 | url: string; 22 | code: string; 23 | meta_json: string; 24 | scripts: string; 25 | createtime: number; 26 | updatetime?: number; 27 | } 28 | 29 | export interface SyncData { 30 | action: SyncAction; 31 | actiontime: number; 32 | uuid?: string; 33 | url?: string; 34 | msg?: string; 35 | script?: SyncScript; 36 | subscribe?: SycnSubscribe; 37 | } 38 | 39 | export interface Sync { 40 | id: number; 41 | key: string; 42 | user: number; 43 | device: number; 44 | type: SyncType; 45 | data: SyncData; 46 | createtime: number; 47 | } 48 | -------------------------------------------------------------------------------- /src/app/service/content/types.ts: -------------------------------------------------------------------------------- 1 | import type { ScriptLoadInfo } from "../service_worker/types"; 2 | 3 | export type ScriptFunc = (named: { [key: string]: any } | undefined, scriptName: string) => any; 4 | 5 | export type PreScriptFunc = { scriptInfo: ScriptLoadInfo; func: ScriptFunc }; 6 | 7 | // exec_script.ts 8 | 9 | export type ValueUpdateSender = { 10 | runFlag: string; 11 | tabId?: number; 12 | }; 13 | 14 | export type ValueUpdateData = { 15 | oldValue: any; 16 | value: any; 17 | key: string; // 值key 18 | uuid: string; 19 | storageName: string; // 储存name 20 | sender: ValueUpdateSender; 21 | }; 22 | 23 | // gm_api.ts 24 | 25 | export interface ApiParam { 26 | follow?: string; 27 | depend?: string[]; 28 | alias?: string; 29 | } 30 | 31 | export interface ApiValue { 32 | fnKey: string; 33 | api: any; 34 | param: ApiParam; 35 | } 36 | 37 | export interface GMInfoEnv { 38 | userAgentData: typeof GM_info.userAgentData; 39 | sandboxMode: typeof GM_info.sandboxMode; 40 | isIncognito: typeof GM_info.isIncognito; 41 | } 42 | -------------------------------------------------------------------------------- /src/locales/arco.ts: -------------------------------------------------------------------------------- 1 | import enUS from "@arco-design/web-react/es/locale/en-US"; 2 | import zhCN from "@arco-design/web-react/es/locale/zh-CN"; 3 | import zhTW from "@arco-design/web-react/es/locale/zh-TW"; 4 | import jaJP from "@arco-design/web-react/es/locale/ja-JP"; 5 | import deDE from "@arco-design/web-react/es/locale/de-DE"; 6 | import viVN from "@arco-design/web-react/es/locale/vi-VN"; 7 | import ruRU from "@arco-design/web-react/es/locale/ru-RU"; 8 | import type { Locale } from "@arco-design/web-react/es/locale/interface"; 9 | 10 | export function arcoLocale(lang: string): Locale { 11 | switch (lang) { 12 | case "en-US": 13 | return enUS; 14 | case "zh-CN": 15 | return zhCN; 16 | case "zh-TW": 17 | return zhTW; 18 | case "ja-JP": 19 | return jaJP; 20 | case "de-DE": 21 | // @ts-ignore 22 | return deDE; 23 | case "vi-VN": 24 | // @ts-ignore 25 | return viVN; 26 | case "ru-RU": 27 | // @ts-ignore 28 | return ruRU; 29 | default: 30 | return enUS; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/pages/install/index.css: -------------------------------------------------------------------------------- 1 | .monaco-diff-editor .diffOverview { 2 | background: var(--vscode-editorGutter-background); 3 | } 4 | 5 | :root { 6 | --show-code-height: 100vh; 7 | --show-code-height: min(calc(100vh), calc(40vh + 360px)); /* 可設為 400px */ /* 可設為 100vh */ 8 | } 9 | 10 | .install-main-layout { 11 | min-height: max-content; 12 | } 13 | 14 | #install-app-container { 15 | display: flex; 16 | flex-direction: column; 17 | min-height: calc(100vh - 50px); 18 | box-sizing: border-box; 19 | } 20 | 21 | #show-code-container { 22 | display: block; 23 | height: var(--show-code-height); 24 | padding: 16px 0px; 25 | position: relative; 26 | box-sizing: border-box; 27 | margin: 0; 28 | border: 0; 29 | flex-grow: 1; 30 | flex-shrink: 0; 31 | contain: strict; 32 | } 33 | 34 | #show-code { 35 | margin: 0px; 36 | padding: 0px; 37 | border: 0px; 38 | width: 100%; 39 | height: 100%; 40 | overflow: hidden; 41 | border: 1px solid var(--color-neutral-5); 42 | box-sizing: border-box; 43 | position: relative; 44 | background: #071119; 45 | } 46 | -------------------------------------------------------------------------------- /example/gm_menu.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm menu 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 创建菜单, 可以显示在右上角的插件弹出页和浏览器右键菜单中 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @grant GM_registerMenuCommand 9 | // @grant GM_unregisterMenuCommand 10 | // ==/UserScript== 11 | 12 | const id = GM_registerMenuCommand( 13 | "测试菜单", 14 | () => { 15 | console.log(id); 16 | GM_unregisterMenuCommand(id); 17 | }, 18 | { accessKey: "k", title: "测试菜单标题", autoClose: false } 19 | ); 20 | 21 | const id2 = GM_registerMenuCommand( 22 | "测试菜单2", 23 | () => { 24 | console.log(id2); 25 | GM_unregisterMenuCommand(id2); 26 | }, 27 | "j" 28 | ); 29 | 30 | setTimeout(() => { 31 | // 修改名字 32 | GM_registerMenuCommand( 33 | "修改后的测试菜单", 34 | () => { 35 | console.log("修改后的", id); 36 | GM_unregisterMenuCommand(id); 37 | }, 38 | { id: id, accessKey: "k", title: "修改后的测试菜单标题", autoClose: false } 39 | ); 40 | }, 5000); 41 | -------------------------------------------------------------------------------- /src/app/service/queue.ts: -------------------------------------------------------------------------------- 1 | import type { Script, SCRIPT_RUN_STATUS } from "../repo/scripts"; 2 | import type { InstallSource, ScriptMenuItem } from "./service_worker/types"; 3 | import type { Subscribe } from "../repo/subscribe"; 4 | 5 | export type TInstallScript = { script: Script; update: boolean; upsertBy?: InstallSource }; 6 | 7 | export type TDeleteScript = { uuid: string; script: Script }; 8 | 9 | export type TSortScript = Script[]; 10 | 11 | export type TInstallSubscribe = { subscribe: Subscribe }; 12 | 13 | export type TEnableScript = { uuid: string; enable: boolean }; 14 | 15 | export type TScriptRunStatus = { uuid: string; runStatus: SCRIPT_RUN_STATUS }; 16 | 17 | export type TScriptValueUpdate = { script: Script }; 18 | 19 | export type TScriptMenuRegister = { 20 | uuid: string; 21 | id: number; 22 | name: string; 23 | options?: ScriptMenuItem["options"]; 24 | tabId: number; 25 | frameId?: number; 26 | documentId?: string; 27 | }; 28 | 29 | export type TScriptMenuUnregister = { 30 | id: number; 31 | uuid: string; 32 | tabId: number; 33 | frameId?: number; 34 | }; 35 | -------------------------------------------------------------------------------- /src/pages/popup/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import { Provider } from "react-redux"; 5 | import { store } from "../store/store.ts"; 6 | import LoggerCore from "@App/app/logger/core.ts"; 7 | import { message } from "../store/global.ts"; 8 | import MessageWriter from "@App/app/logger/message_writer.ts"; 9 | import "@arco-design/web-react/dist/css/arco.css"; 10 | import "@App/locales/locales"; 11 | import "@App/index.css"; 12 | import "./index.css"; 13 | 14 | // 初始化日志组件 15 | const loggerCore = new LoggerCore({ 16 | writer: new MessageWriter(message), 17 | labels: { env: "install" }, 18 | }); 19 | 20 | loggerCore.logger().debug("popup page start"); 21 | 22 | const Root = ( 23 | 24 |
25 | 26 |
27 |
28 | ); 29 | 30 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 31 | process.env.NODE_ENV === "development" ? {Root} : Root 32 | ); 33 | -------------------------------------------------------------------------------- /packages/filesystem/filesystem.ts: -------------------------------------------------------------------------------- 1 | export interface File { 2 | fsid?: number; 3 | // 文件名 4 | name: string; 5 | // 文件路径 6 | path: string; 7 | // 文件大小 8 | size: number; 9 | // 文件摘要 10 | digest: string; 11 | // 文件创建时间 12 | createtime: number; 13 | // 文件修改时间 14 | updatetime: number; 15 | } 16 | 17 | type ReadType = "string" | "blob"; 18 | export interface FileReader { 19 | // 读取文件内容 20 | read(type?: ReadType): Promise; 21 | } 22 | 23 | export interface FileWriter { 24 | // 写入文件内容 25 | write(content: string | Blob): Promise; 26 | } 27 | 28 | export type FileReadWriter = FileReader & FileWriter; 29 | 30 | // 文件读取 31 | export default interface FileSystem { 32 | // 授权验证 33 | verify(): Promise; 34 | // 打开文件 35 | open(file: File): Promise; 36 | // 打开目录 37 | openDir(path: string): Promise; 38 | // 创建文件 39 | create(path: string): Promise; 40 | // 创建目录 41 | createDir(dir: string): Promise; 42 | // 删除文件 43 | delete(path: string): Promise; 44 | // 文件列表 45 | list(): Promise; 46 | // getDirUrl 获取目录的url 47 | getDirUrl(): Promise; 48 | } 49 | -------------------------------------------------------------------------------- /src/pages/confirm/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import MainLayout from "../components/layout/MainLayout.tsx"; 5 | import { Provider } from "react-redux"; 6 | import { store } from "@App/pages/store/store.ts"; 7 | import LoggerCore from "@App/app/logger/core.ts"; 8 | import { message } from "../store/global.ts"; 9 | import MessageWriter from "@App/app/logger/message_writer.ts"; 10 | import "@arco-design/web-react/dist/css/arco.css"; 11 | import "@App/locales/locales"; 12 | import "@App/index.css"; 13 | 14 | // 初始化日志组件 15 | const loggerCore = new LoggerCore({ 16 | writer: new MessageWriter(message), 17 | labels: { env: "confirm" }, 18 | }); 19 | 20 | loggerCore.logger().debug("confirm page start"); 21 | 22 | const Root = ( 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | 30 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 31 | process.env.NODE_ENV === "development" ? {Root} : Root 32 | ); 33 | -------------------------------------------------------------------------------- /src/pages/import/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import { Provider } from "react-redux"; 5 | import { store } from "@App/pages/store/store.ts"; 6 | import MainLayout from "../components/layout/MainLayout.tsx"; 7 | import LoggerCore from "@App/app/logger/core.ts"; 8 | import { message } from "../store/global.ts"; 9 | import MessageWriter from "@App/app/logger/message_writer.ts"; 10 | import "@arco-design/web-react/dist/css/arco.css"; 11 | import "@App/locales/locales"; 12 | import "@App/index.css"; 13 | 14 | // 初始化日志组件 15 | const loggerCore = new LoggerCore({ 16 | writer: new MessageWriter(message), 17 | labels: { env: "import" }, 18 | }); 19 | 20 | loggerCore.logger().debug("import page start"); 21 | 22 | const Root = ( 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | 30 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 31 | process.env.NODE_ENV === "development" ? {Root} : Root 32 | ); 33 | -------------------------------------------------------------------------------- /src/app/repo/resource.ts: -------------------------------------------------------------------------------- 1 | import { Repo } from "./repo"; 2 | import { v5 as uuidv5 } from "uuid"; 3 | 4 | export type ResourceType = "require" | "require-css" | "resource"; 5 | 6 | export interface Resource { 7 | url: string; // key 8 | content: string; 9 | base64: string; 10 | hash: ResourceHash; 11 | type: ResourceType; 12 | link: { [key: string]: boolean }; // 关联的脚本 13 | contentType: string; 14 | createtime: number; 15 | updatetime?: number; 16 | } 17 | 18 | export interface ResourceHash { 19 | md5: string; 20 | sha1: string; 21 | sha256: string; 22 | sha384: string; 23 | sha512: string; 24 | integrity?: { 25 | md5: string; 26 | sha1: string; 27 | sha256: string; 28 | sha384: string; 29 | sha512: string; 30 | }; 31 | } 32 | 33 | const ResourceNamespace = "76f45084-91b1-42c1-8be8-cbcc54b171f0"; 34 | 35 | export class ResourceDAO extends Repo { 36 | constructor() { 37 | super("resource"); 38 | } 39 | 40 | protected joinKey(key: string) { 41 | return this.prefix + uuidv5(key, ResourceNamespace); 42 | } 43 | 44 | save(resource: Resource) { 45 | return super._save(resource.url, resource); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /example/gm_xhr.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm xhr 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 无视浏览器的cors的跨域请求,可以设置各种unsafeHeader与cookie,需要使用@connect获取权限,或者由用户确认 6 | // @author You 7 | // @grant GM_xmlhttpRequest 8 | // @match https://bbs.tampermonkey.net.cn/ 9 | // @connect tampermonkey.net.cn 10 | // ==/UserScript== 11 | 12 | const data = new FormData(); 13 | 14 | data.append("username", "admin"); 15 | 16 | data.append( 17 | "file", 18 | new File(["foo"], "foo.txt", { 19 | type: "text/plain", 20 | }) 21 | ); 22 | 23 | GM_xmlhttpRequest({ 24 | url: "https://bbs.tampermonkey.net.cn/", 25 | method: "POST", 26 | responseType: "blob", 27 | data: data, 28 | cookie: "ceshi=123", 29 | anonymous: true, 30 | headers: { 31 | referer: "http://www.example.com/", 32 | origin: "www.example.com", 33 | // 为空将不会发送此header 34 | "sec-ch-ua-mobile": "", 35 | }, 36 | onload(resp) { 37 | console.log("onload", resp); 38 | }, 39 | onreadystatechange(resp) { 40 | console.log("onreadystatechange", resp); 41 | }, 42 | onloadend(resp) { 43 | console.log("onloadend", resp); 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /example/early-start.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name early start script 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 使用 early-start 可以比网页更快的加载脚本进行执行,但是会存在一些性能问题与GM API使用限制 6 | // @author You 7 | // @run-at document-start 8 | // @grant GM_getValue 9 | // @grant GM_setValue 10 | // @grant CAT_scriptLoaded 11 | // @early-start 12 | // @match http://test-case.ggnb.top/is_trusted/is_trusted.html 13 | // ==/UserScript== 14 | 15 | console.log("early-start 获取值", GM_getValue("test")); 16 | 17 | console.log("early-start 设置值", GM_setValue("test", Math.random())); 18 | 19 | const realAdd = document.addEventListener; 20 | document.addEventListener = function (type, fuc) { 21 | if (type == "click") { 22 | const realFuc = fuc; 23 | fuc = function (e) { 24 | const obj = { isTrusted: true, target: e.target }; 25 | Object.setPrototypeOf(obj, MouseEvent.prototype); 26 | realFuc.call(this, obj); 27 | }; 28 | } 29 | realAdd.call(this, type, fuc); 30 | }; 31 | 32 | unsafeWindow.onload = () => { 33 | document.querySelector("#btn").click(); 34 | }; 35 | 36 | CAT_scriptLoaded().then(() => { 37 | console.log("脚本完全加载完成"); 38 | }); -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "__MSG_scriptcat__", 4 | "version": "1.1.1", 5 | "author": "CodFrm", 6 | "description": "__MSG_scriptcat_description__", 7 | "options_ui": { 8 | "page": "src/options.html", 9 | "open_in_tab": true 10 | }, 11 | "background": { 12 | "service_worker": "src/service_worker.js", 13 | "scripts": [ 14 | "src/service_worker.js" 15 | ] 16 | }, 17 | "incognito": "split", 18 | "action": { 19 | "default_popup": "src/popup.html", 20 | "default_icon": { 21 | "128": "assets/logo.png" 22 | } 23 | }, 24 | "icons": { 25 | "128": "assets/logo.png" 26 | }, 27 | "default_locale": "en", 28 | "permissions": [ 29 | "tabs", 30 | "alarms", 31 | "storage", 32 | "cookies", 33 | "offscreen", 34 | "scripting", 35 | "downloads", 36 | "activeTab", 37 | "webRequest", 38 | "userScripts", 39 | "contextMenus", 40 | "notifications", 41 | "clipboardWrite", 42 | "unlimitedStorage", 43 | "declarativeNetRequest" 44 | ], 45 | "optional_permissions": [ 46 | "userScripts" 47 | ], 48 | "host_permissions": [ 49 | "" 50 | ], 51 | "sandbox": { 52 | "pages": ["src/sandbox.html"] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/pages/install/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import { Provider } from "react-redux"; 5 | import { store } from "@App/pages/store/store.ts"; 6 | import MainLayout from "../components/layout/MainLayout.tsx"; 7 | import LoggerCore from "@App/app/logger/core.ts"; 8 | import { message } from "../store/global.ts"; 9 | import MessageWriter from "@App/app/logger/message_writer.ts"; 10 | import "@arco-design/web-react/dist/css/arco.css"; 11 | import "@App/locales/locales"; 12 | import "@App/index.css"; 13 | import "./index.css"; 14 | import registerEditor from "@App/pkg/utils/monaco-editor"; 15 | 16 | registerEditor(); 17 | 18 | // 初始化日志组件 19 | const loggerCore = new LoggerCore({ 20 | writer: new MessageWriter(message), 21 | labels: { env: "install" }, 22 | }); 23 | 24 | loggerCore.logger().debug("install page start"); 25 | 26 | const Root = ( 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | 34 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 35 | process.env.NODE_ENV === "development" ? {Root} : Root 36 | ); 37 | -------------------------------------------------------------------------------- /src/inject.ts: -------------------------------------------------------------------------------- 1 | import LoggerCore from "./app/logger/core"; 2 | import MessageWriter from "./app/logger/message_writer"; 3 | import { CustomEventMessage } from "@Packages/message/custom_event_message"; 4 | import { Server } from "@Packages/message/server"; 5 | import type { ScriptLoadInfo } from "./app/service/service_worker/types"; 6 | import type { GMInfoEnv } from "./app/service/content/types"; 7 | import { InjectRuntime } from "./app/service/content/inject"; 8 | import { ScriptExecutor } from "./app/service/content/script_executor"; 9 | 10 | /* global MessageFlag, EarlyScriptFlag */ 11 | 12 | const msg = new CustomEventMessage(MessageFlag, false); 13 | 14 | // 加载logger组件 15 | const logger = new LoggerCore({ 16 | writer: new MessageWriter(msg), 17 | labels: { env: "inject", href: window.location.href }, 18 | }); 19 | 20 | const server = new Server("inject", msg); 21 | const scriptExecutor = new ScriptExecutor(msg, EarlyScriptFlag); 22 | const runtime = new InjectRuntime(server, msg, scriptExecutor); 23 | // 检查early-start的脚本 24 | scriptExecutor.checkEarlyStartScript(); 25 | 26 | server.on("pageLoad", (data: { scripts: ScriptLoadInfo[]; envInfo: GMInfoEnv }) => { 27 | logger.logger().debug("inject start"); 28 | // 监听事件 29 | runtime.init(data.envInfo); 30 | runtime.start(data.scripts); 31 | }); 32 | -------------------------------------------------------------------------------- /example/cat_file_storage.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name cat file storage 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 脚本同步储存空间操作 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @grant CAT_fileStorage 9 | // @run-at document-start 10 | // ==/UserScript== 11 | 12 | CAT_fileStorage("upload", { 13 | path: "test.txt", 14 | baseDir: "test-dir", 15 | data: new Blob(["Hello World"]), 16 | onload() { 17 | CAT_fileStorage("list", { 18 | baseDir: "test-dir", 19 | onload(list) { 20 | console.log(list); 21 | list.forEach(value => { 22 | if (value.name === "test.txt") { 23 | CAT_fileStorage("download", { 24 | file: value, 25 | baseDir: "test-dir", 26 | async onload(data) { 27 | console.log(await data.text()); 28 | CAT_fileStorage("delete", { 29 | path: value.name, 30 | baseDir: "test-dir", 31 | onload() { 32 | console.log('ok'); 33 | } 34 | }); 35 | } 36 | }); 37 | } 38 | }); 39 | } 40 | }) 41 | }, onerror(err) { 42 | console.log(err); 43 | switch (err.code) { 44 | case 1: 45 | case 2: 46 | CAT_fileStorage("config"); 47 | break; 48 | } 49 | } 50 | }) -------------------------------------------------------------------------------- /src/pkg/utils/cron.ts: -------------------------------------------------------------------------------- 1 | import { CronTime } from "cron"; 2 | 3 | export function nextTime(crontab: string, date?: Date): string { 4 | let oncePos = 0; 5 | if (crontab.includes("once")) { 6 | const vals = crontab.split(" "); 7 | vals.forEach((val, index) => { 8 | if (val === "once") { 9 | oncePos = index; 10 | } 11 | }); 12 | if (vals.length === 5) { 13 | oncePos++; 14 | } 15 | } 16 | let cron: CronTime; 17 | try { 18 | cron = new CronTime(crontab.replace(/once/g, "*")); 19 | } catch { 20 | throw new Error("错误的定时表达式"); 21 | } 22 | const datetime = cron.getNextDateFrom(date || new Date()); 23 | if (oncePos) { 24 | switch (oncePos) { 25 | case 1: // 每分钟 26 | return datetime.toFormat("yyyy-MM-dd HH:mm 每分钟运行一次"); 27 | case 2: // 每小时 28 | return datetime.plus({ hour: 1 }).toFormat("yyyy-MM-dd HH 每小时运行一次"); 29 | case 3: // 每天 30 | return datetime.plus({ day: 1 }).toFormat("yyyy-MM-dd 每天运行一次"); 31 | case 4: // 每月 32 | return datetime.plus({ month: 1 }).toFormat("yyyy-MM 每月运行一次"); 33 | case 5: // 每星期 34 | return datetime.plus({ week: 1 }).toFormat("yyyy-MM-dd 每星期运行一次"); 35 | } 36 | throw new Error("错误表达式"); 37 | } 38 | return datetime.toFormat("yyyy-MM-dd HH:mm:ss"); 39 | } 40 | -------------------------------------------------------------------------------- /packages/chrome-extension-mock/permissions.ts: -------------------------------------------------------------------------------- 1 | export default class Permissions { 2 | request(permissions: chrome.permissions.Permissions, callback?: (granted: boolean) => void) { 3 | // Mock implementation - always grant permissions for testing 4 | if (callback) { 5 | callback(true); 6 | } 7 | return Promise.resolve(true); 8 | } 9 | 10 | contains(permissions: chrome.permissions.Permissions, callback?: (result: boolean) => void) { 11 | // Mock implementation - always return true for testing 12 | if (callback) { 13 | callback(true); 14 | } 15 | return Promise.resolve(true); 16 | } 17 | 18 | getAll(callback?: (permissions: chrome.permissions.Permissions) => void) { 19 | const mockPermissions = { 20 | permissions: ["activeTab" as chrome.runtime.ManifestPermissions, "storage" as chrome.runtime.ManifestPermissions], 21 | origins: [""], 22 | }; 23 | if (callback) { 24 | callback(mockPermissions); 25 | } 26 | return Promise.resolve(mockPermissions); 27 | } 28 | 29 | remove(permissions: chrome.permissions.Permissions, callback?: (removed: boolean) => void) { 30 | // Mock implementation - always succeed for testing 31 | if (callback) { 32 | callback(true); 33 | } 34 | return Promise.resolve(true); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/pkg/utils/yaml.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "yaml"; 2 | import type { UserConfig } from "@App/app/repo/scripts"; 3 | 4 | export function parseUserConfig(code: string): UserConfig | undefined { 5 | const regex = /\/\*\s*==UserConfig==([\s\S]+?)\s*==\/UserConfig==\s*\*\//m; 6 | const config = regex.exec(code); 7 | if (!config) { 8 | return undefined; 9 | } 10 | 11 | const configs = config[1].trim().split(/[-]{3,}/); 12 | const ret: UserConfig = {}; 13 | 14 | for (const val of configs) { 15 | const obj: UserConfig = parse(val); 16 | if (!obj || typeof obj !== "object") { 17 | continue; 18 | } 19 | 20 | // 验证是否符合分组规范:group -> config -> properties 21 | for (const [groupKey, groupValue] of Object.entries(obj)) { 22 | if (!groupValue || typeof groupValue !== "object") { 23 | // 如果分组值不是对象,说明不符合规范 24 | throw new Error(`UserConfig group "${groupKey}" is not a valid object.`); 25 | } 26 | 27 | ret[groupKey] = groupValue; 28 | Object.keys(ret[groupKey] || {}).forEach((subKey, subIndex) => { 29 | if (ret[groupKey][subKey] && typeof ret[groupKey][subKey] === "object") { 30 | ret[groupKey][subKey].index = ret[groupKey][subKey].index || subIndex; // 确保index存在 31 | } 32 | }); 33 | } 34 | } 35 | 36 | return ret; 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - release/* 8 | - dev 9 | paths-ignore: 10 | - ".github/**" 11 | - ".gitignore" 12 | - "**.md" 13 | - "LICENSE" 14 | workflow_dispatch: 15 | 16 | jobs: 17 | build-deploy: 18 | runs-on: ubuntu-latest 19 | name: Build 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: pnpm/action-setup@v4 23 | 24 | - name: Use Node.js 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: 22 28 | cache: 'pnpm' 29 | 30 | - name: Package with Node 31 | env: 32 | CHROME_PEM: ${{ secrets.CHROME_PEM }} 33 | run: | 34 | mkdir dist 35 | echo "$CHROME_PEM" > ./dist/scriptcat.pem 36 | chmod 600 ./dist/scriptcat.pem 37 | pnpm i --frozen-lockfile 38 | pnpm run pack 39 | 40 | - name: Archive production artifacts 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: production-artifacts 44 | path: | 45 | dist/*.crx 46 | dist/*.zip 47 | 48 | - name: Archive extension 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: scriptcat-chrome-extension 52 | path: | 53 | dist/ext/* 54 | 55 | -------------------------------------------------------------------------------- /packages/filesystem/webdav/rw.ts: -------------------------------------------------------------------------------- 1 | import type { WebDAVClient } from "webdav"; 2 | import type { FileReader, FileWriter } from "../filesystem"; 3 | 4 | export class WebDAVFileReader implements FileReader { 5 | client: WebDAVClient; 6 | 7 | path: string; 8 | 9 | constructor(client: WebDAVClient, path: string) { 10 | this.client = client; 11 | this.path = path; 12 | } 13 | 14 | async read(type?: "string" | "blob"): Promise { 15 | switch (type) { 16 | case "string": 17 | return await (this.client.getFileContents(this.path, { 18 | format: "text", 19 | }) as Promise); 20 | default: { 21 | const resp = (await this.client.getFileContents(this.path, { 22 | format: "binary", 23 | })) as ArrayBuffer; 24 | return new Blob([resp]); 25 | } 26 | } 27 | } 28 | } 29 | 30 | export class WebDAVFileWriter implements FileWriter { 31 | client: WebDAVClient; 32 | 33 | path: string; 34 | 35 | constructor(client: WebDAVClient, path: string) { 36 | this.client = client; 37 | this.path = path; 38 | } 39 | 40 | async write(content: string | Blob): Promise { 41 | const data = content instanceof Blob ? await content.arrayBuffer() : content; 42 | const resp = await this.client.putFileContents(this.path, data); 43 | if (!resp) { 44 | throw new Error("write error"); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/types.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace App { 2 | // window.external 3 | 4 | export type ExternalTampermonkey = { 5 | isInstalled(name: string, namespace: string, callback: (res: IsInstalledResponse | undefined) => unknown): void; 6 | getVersion?: (callback: (res: GetVersionResponse | undefined) => unknown) => unknown; 7 | openOptions?: (p1?: unknown, p2?: unknown) => unknown; 8 | }; 9 | 10 | export type ExternalViolentmonkey = { 11 | isInstalled(name: string, namespace: string): Promise; 12 | }; 13 | 14 | // GreasyFork项目中FireMonkey扩展的脚本与样式管理机制解析 15 | // https://blog.csdn.net/gitblog_07112/article/details/148466939 16 | export type ExternalFireMonkey = { 17 | version: string; 18 | } & ( 19 | | { 20 | installedScriptVersion: string; 21 | } 22 | | { 23 | installedStyleVersion: string; 24 | } 25 | | { 26 | installedCSSVersion: string; 27 | } 28 | )?; 29 | 30 | export type ExternalScriptCat = { 31 | isInstalled(name: string, namespace: string, callback: (res: IsInstalledResponse | undefined) => unknown): void; 32 | }; 33 | 34 | export type IsInstalledResponse = 35 | | { 36 | installed: true; 37 | version: string | undefined; 38 | } 39 | | { 40 | installed: false; 41 | }; 42 | 43 | export type GetVersionResponse = { 44 | version?: string; 45 | id?: string | undefined; 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /src/pages/options/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import MainLayout from "../components/layout/MainLayout.tsx"; 4 | import Sider from "../components/layout/Sider.tsx"; 5 | import { Provider } from "react-redux"; 6 | import { store } from "@App/pages/store/store.ts"; 7 | import "@arco-design/web-react/dist/css/arco.css"; 8 | import "@App/locales/locales"; 9 | import "@App/index.css"; 10 | import "./index.css"; 11 | import LoggerCore from "@App/app/logger/core.ts"; 12 | import { LoggerDAO } from "@App/app/repo/logger.ts"; 13 | import DBWriter from "@App/app/logger/db_writer.ts"; 14 | import registerEditor from "@App/pkg/utils/monaco-editor"; 15 | import storeSubscribe from "../store/subscribe.ts"; 16 | import migrate from "@App/app/migrate.ts"; 17 | 18 | migrate(); 19 | 20 | registerEditor(); 21 | 22 | // 初始化日志组件 23 | const loggerCore = new LoggerCore({ 24 | writer: new DBWriter(new LoggerDAO()), 25 | labels: { env: "options" }, 26 | }); 27 | 28 | loggerCore.logger().debug("options page start"); 29 | 30 | storeSubscribe(); 31 | 32 | const Root = ( 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | 40 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 41 | process.env.NODE_ENV === "development" ? {Root} : Root 42 | ); 43 | -------------------------------------------------------------------------------- /src/pkg/utils/crypto.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { calculateHashFromArrayBuffer } from "./crypto"; 3 | 4 | describe("crypto utils", () => { 5 | describe("calculateHashFromArrayBuffer", () => { 6 | it("计算hash", () => { 7 | // 将字符串 "123456" 转换为 ArrayBuffer 8 | const str = "123456"; 9 | const uint8Array = Uint8Array.from(str, (c) => c.charCodeAt(0)); 10 | const buffer = uint8Array.buffer; 11 | const result = calculateHashFromArrayBuffer(buffer); 12 | expect(result).toEqual({ 13 | md5: "e10adc3949ba59abbe56e057f20f883e", 14 | sha1: "7c4a8d09ca3762af61e59520943dc26494f8941b", 15 | sha256: "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", 16 | sha384: "0a989ebc4a77b56a6e2bb7b19d995d185ce44090c13e2984b7ecc6d446d4b61ea9991b76a4c2f04b1b4d244841449454", 17 | sha512: 18 | "ba3253876aed6bc22d4a6ff53d8406c6ad864195ed144ab5c87621b6c233b548baeae6956df346ec8c17f5ea10f35ee3cbc514797ed7ddd3145464e2a0bab413", 19 | integrity: { 20 | md5: "4QrcOUm6Wau+VuBX8g+IPg==", 21 | sha1: "fEqNCco3Yq9h5ZUglD3CZJT4lBs=", 22 | sha256: "jZae727K08KaOmKSgOaGzww/XVqGr/PKEgIMkjrcbJI=", 23 | sha384: "CpievEp3tWpuK7exnZldGFzkQJDBPimEt+zG1EbUth6pmRt2pMLwSxtNJEhBRJRU", 24 | sha512: "ujJTh2rta8ItSm/1PYQGxq2GQZXtFEq1yHYhtsIztUi66uaVbfNG7IwX9eoQ817jy8UUeX7X3dMUVGTioLq0Ew==", 25 | }, 26 | }); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /scripts/crowdin-download.js: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | import { readdirSync, statSync, readFileSync, writeFileSync } from "fs"; 3 | import { join } from "path"; 4 | 5 | console.log("Downloading translations from Crowdin..."); 6 | // 执行 crowdin download --skip-untranslated-strings 7 | execSync("crowdin download --skip-untranslated-strings", { stdio: "inherit" }); 8 | 9 | // 将所有语言中的""删除 10 | // 语言文件在 src/locales/*/*.json 排除zh-CN 11 | const localesPath = "./src/locales"; 12 | console.log("Removing empty strings from locale files..."); 13 | function removeEmptyStrings(obj) { 14 | for (const key in obj) { 15 | if (typeof obj[key] === "object" && obj[key] !== null) { 16 | removeEmptyStrings(obj[key]); 17 | if (Object.keys(obj[key]).length === 0) { 18 | delete obj[key]; 19 | } 20 | } else if (obj[key] === "") { 21 | delete obj[key]; 22 | } 23 | } 24 | } 25 | function removeEmptyStringsFromLocaleFiles(dir) { 26 | const files = readdirSync(dir); 27 | for (const file of files) { 28 | const filePath = join(dir, file); 29 | if (statSync(filePath).isDirectory() && !filePath.includes("zh-CN")) { 30 | removeEmptyStringsFromLocaleFiles(filePath); 31 | } else if (file.endsWith(".json")) { 32 | const content = JSON.parse(readFileSync(filePath, "utf-8")); 33 | removeEmptyStrings(content); 34 | writeFileSync(filePath, JSON.stringify(content, null, 2)); 35 | } 36 | } 37 | } 38 | removeEmptyStringsFromLocaleFiles(localesPath); 39 | -------------------------------------------------------------------------------- /example/gm_cookie.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name GM cookie操作 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 可以控制浏览器的cookie, 必须指定@connect, 并且每次一个新的域调用都需要用户确定 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @grant GM_cookie 9 | // @grant GM.cookie 10 | // @connect example.com 11 | // ==/UserScript== 12 | 13 | GM_cookie("set", { 14 | url: "http://example.com/cookie", 15 | name: "cookie1", value: "value" 16 | }, () => { 17 | GM_cookie("set", { 18 | url: "http://www.example.com/", 19 | domain: ".example.com", path: "/path", 20 | name: "cookie2", value: "path" 21 | }, () => { 22 | GM_cookie("list", { 23 | domain: "example.com" 24 | }, (cookies) => { 25 | console.log("domain", cookies); 26 | }); 27 | GM_cookie("list", { 28 | url: "http://example.com/cookie", 29 | }, (cookies) => { 30 | console.log("domain", cookies); 31 | }); 32 | GM_cookie("delete", { 33 | url: "http://www.example.com/path", 34 | name: "cookie2" 35 | }, () => { 36 | GM_cookie("list", { 37 | domain: "example.com" 38 | }, (cookies) => { 39 | console.log("delete", cookies); 40 | }); 41 | }) 42 | }); 43 | }); 44 | 45 | console.log("async GM.cookie.list", await GM.cookie.list({ 46 | domain: "example.com" 47 | })); 48 | -------------------------------------------------------------------------------- /src/pkg/utils/filehandle-db.ts: -------------------------------------------------------------------------------- 1 | import Dexie from "dexie"; 2 | const dbName = "filehandle-temp-dexie"; 3 | 4 | // Define the Dexie database class 5 | class FileHandleDB extends Dexie { 6 | handles: Dexie.Table<{ handle: FileSystemFileHandle; timestamp: number }, string>; 7 | 8 | constructor() { 9 | super(dbName); 10 | this.version(1).stores({ 11 | handles: "", // No key path, keys are provided explicitly as strings 12 | }); 13 | this.handles = this.table("handles"); 14 | } 15 | } 16 | 17 | // Instantiate the database 18 | const db = new FileHandleDB(); 19 | 20 | // Save a file handle with a timestamp 21 | export async function saveHandle(key: string, handle: FileSystemFileHandle): Promise { 22 | await db.handles.put({ handle, timestamp: Date.now() }, key); 23 | } 24 | 25 | // Load a file handle by key 26 | export async function loadHandle(key: string): Promise { 27 | const result = await db.handles.get(key); 28 | if (result?.handle instanceof FileSystemFileHandle) { 29 | return result.handle; 30 | } else { 31 | throw new Error("Handle not found or invalid"); 32 | } 33 | } 34 | 35 | // Delete a file handle by key 36 | export async function deleteHandle(key: string): Promise { 37 | await db.handles.delete(key); 38 | } 39 | 40 | // 清除超过 15 分钟未使用的FileHandle 41 | export async function cleanupOldHandles(maxAgeMs = 15 * 60 * 1000): Promise { 42 | const now = Date.now(); 43 | await db.handles.filter((entry) => now - entry.timestamp > maxAgeMs).delete(); 44 | } 45 | -------------------------------------------------------------------------------- /src/app/service/content/gm_context.ts: -------------------------------------------------------------------------------- 1 | import type { ApiParam, ApiValue } from "./types"; 2 | 3 | const apis: Map = new Map(); 4 | 5 | export function GMContextApiGet(name: string): ApiValue[] | undefined { 6 | // 回传 Api 列表 7 | return apis.get(name); 8 | } 9 | 10 | function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam): void { 11 | // 一个 @grant 可以扩充多个 API 函数 12 | let m: ApiValue[] | undefined = apis.get(grant); 13 | if (!m) apis.set(grant, (m = [])); 14 | m.push({ fnKey, api, param }); 15 | } 16 | 17 | export const protect: { [key: string]: any } = {}; 18 | 19 | export default class GMContext { 20 | public static protected(value: any = undefined) { 21 | return (target: any, propertyName: string) => { 22 | // keyword是与createContext时同步的,避免访问到context的内部变量 23 | // 暂时只用於禁止存取(value = undefined)。日后有需要可扩展成假值 24 | protect[propertyName] = value; 25 | }; 26 | } 27 | 28 | public static API(param: ApiParam = {}) { 29 | return (target: any, propertyName: string, descriptor: PropertyDescriptor) => { 30 | const key = propertyName; 31 | let { follow } = param; 32 | const { alias } = param; 33 | if (!follow) { 34 | follow = key; // follow 是实际 @grant 的权限;使用follow时,不要使用alias以避免混乱 35 | } 36 | GMContextApiSet(follow, key, descriptor.value, param); 37 | if (alias) { 38 | // 追加别名呼叫(参数和回传完全一致,为 GM_xxx 与 GM.xxx 等问题设计) 39 | GMContextApiSet(alias, alias, descriptor.value, param); 40 | } 41 | }; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/message/types.ts: -------------------------------------------------------------------------------- 1 | export type TMessageQueue = { 2 | msgQueue: string; 3 | data: { 4 | action: string; 5 | message: NonNullable; 6 | }; 7 | action?: never; 8 | code?: never; 9 | }; 10 | 11 | export type TMessageCommAction = { 12 | action: string; 13 | data?: NonNullable; 14 | msgQueue?: never; 15 | code?: never; 16 | }; 17 | 18 | export type TMessageCommCode = { 19 | code: number; 20 | msgQueue?: never; 21 | action?: never; 22 | data?: NonNullable; 23 | message?: NonNullable; 24 | }; 25 | 26 | export type TMessage = TMessageQueue | TMessageCommAction | TMessageCommCode; 27 | 28 | export type MessageSender = chrome.runtime.MessageSender; 29 | 30 | export interface Message extends MessageSend { 31 | onConnect(callback: (data: TMessage, con: MessageConnect) => void): void; 32 | onMessage( 33 | callback: (data: TMessage, sendResponse: (data: any) => void, sender?: MessageSender) => boolean | void 34 | ): void; 35 | } 36 | 37 | export interface MessageSend { 38 | connect(data: TMessage): Promise; 39 | sendMessage(data: TMessage): Promise; 40 | } 41 | 42 | export interface MessageConnect { 43 | onMessage(callback: (data: TMessage) => void): void; 44 | sendMessage(data: TMessage): void; 45 | disconnect(): void; 46 | onDisconnect(callback: () => void): void; 47 | } 48 | 49 | export type ExtMessageSender = { 50 | tabId: number; 51 | frameId?: number; 52 | documentId?: string; 53 | windowId?: number; 54 | }; 55 | -------------------------------------------------------------------------------- /packages/chrome-extension-mock/web_reqeuest.ts: -------------------------------------------------------------------------------- 1 | export default class WebRequest { 2 | sendHeader?: (details: chrome.webRequest.OnSendHeadersDetails) => chrome.webRequest.BlockingResponse | void; 3 | 4 | mockXhr(xhr: any): any { 5 | return () => { 6 | const ret = new xhr(); 7 | const header: chrome.webRequest.HttpHeader[] = []; 8 | ret.setRequestHeader = (k: string, v: string) => { 9 | header.push({ 10 | name: k, 11 | value: v, 12 | }); 13 | }; 14 | const oldSend = ret.send.bind(ret); 15 | ret.send = (data: any) => { 16 | header.push({ 17 | name: "cookie", 18 | value: "website=example.com", 19 | }); 20 | const resp = this.sendHeader?.({ 21 | method: ret.method, 22 | url: ret.url, 23 | requestHeaders: header, 24 | initiator: chrome.runtime.getURL(""), 25 | } as chrome.webRequest.OnSendHeadersDetails) as chrome.webRequest.BlockingResponse; 26 | resp.requestHeaders?.forEach((h) => { 27 | ret._authorRequestHeaders!.addHeader(h.name, h.value); 28 | }); 29 | oldSend(data); 30 | }; 31 | return ret; 32 | }; 33 | } 34 | 35 | onBeforeSendHeaders = { 36 | addListener: (callback: any) => { 37 | this.sendHeader = callback; 38 | }, 39 | }; 40 | 41 | onHeadersReceived = { 42 | addListener: () => { 43 | // TODO 44 | }, 45 | }; 46 | 47 | onCompleted = { 48 | addListener: () => { 49 | // TODO 50 | }, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/pages/components/CustomTrans/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@arco-design/web-react"; 2 | import React from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | // 因为i18n的Trans组件打包后出现问题,所以自己实现一个 6 | export const CustomTrans: React.FC<{ 7 | className?: string; 8 | i18nKey: string; 9 | }> = ({ className, i18nKey }) => { 10 | const { t } = useTranslation(); 11 | const children: (JSX.Element | string)[] = []; 12 | let content = t(i18nKey); 13 | for (;;) { 14 | const i = content.indexOf("<"); 15 | if (i !== -1) { 16 | children.push(content.substring(0, i)); 17 | const end = content.indexOf(">", i); 18 | const key = content.substring(i + 1, end).split(" ")[0]; 19 | const tag = content.substring(i, end + 1); 20 | const tagEnd = content.indexOf(``, end); 21 | const element = content.substring(end + 1, content.indexOf(``, end)); 22 | switch (key) { 23 | case "Link": 24 | // eslint-disable-next-line no-case-declarations 25 | const href = tag.match(/href="(.*)"/)![1]; 26 | children.push( 27 | 28 | {element} 29 | 30 | ); 31 | break; 32 | default: 33 | children.push(element); 34 | break; 35 | } 36 | content = content.substring(tagEnd + key.length + 3); 37 | } else { 38 | children.push(content); 39 | break; 40 | } 41 | } 42 | 43 | return
{children}
; 44 | }; 45 | 46 | export default CustomTrans; 47 | -------------------------------------------------------------------------------- /src/pkg/utils/day_format.ts: -------------------------------------------------------------------------------- 1 | // 把簡單的時間格式function以pure javascript做出來 2 | 3 | // export function dayFormatCurrent() { 4 | // // return dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss") 5 | // return dayFormat(new Date(), "YYYY-MM-DD HH:mm:ss"); 6 | // } 7 | 8 | // export function formatTime(time: Date) { 9 | // return dayFormat(time, "YYYY-MM-DD HH:mm:ss"); 10 | // } 11 | 12 | export function formatUnixTime(time: number) { 13 | // return dayjs.unix(time).format("YYYY-MM-DD HH:mm:ss"); 14 | const date = new Date(time * 1000); 15 | return dayFormat(date, "YYYY-MM-DD HH:mm:ss"); 16 | } 17 | 18 | const formatRe = /YYYY|MM|DD|HH|mm|ss/g; 19 | 20 | export function dayFormat(date = new Date(), fmt = "YYYY-MM-DD HH:mm:ss"): string { 21 | return fmt.replace(formatRe, (token) => { 22 | switch (token) { 23 | case "YYYY": { 24 | const y = date.getFullYear(); 25 | return y.toString(); 26 | } 27 | case "MM": { 28 | const m = date.getMonth() + 1; 29 | return m < 10 ? "0" + m : m.toString(); 30 | } 31 | case "DD": { 32 | const d = date.getDate(); 33 | return d < 10 ? "0" + d : d.toString(); 34 | } 35 | case "HH": { 36 | const h = date.getHours(); 37 | return h < 10 ? "0" + h : h.toString(); 38 | } 39 | case "mm": { 40 | const min = date.getMinutes(); 41 | return min < 10 ? "0" + min : min.toString(); 42 | } 43 | case "ss": { 44 | const s = date.getSeconds(); 45 | return s < 10 ? "0" + s : s.toString(); 46 | } 47 | } 48 | return token; 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /packages/chrome-extension-mock/tab.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "eventemitter3"; 2 | 3 | export default class MockTab { 4 | hook = new EventEmitter(); 5 | 6 | query(queryInfo?: chrome.tabs.QueryInfo, callback?: (tabs: chrome.tabs.Tab[]) => void) { 7 | const mockTab = { 8 | id: 1, 9 | url: "https://example.com", 10 | title: "Test Page", 11 | active: true, 12 | windowId: 1, 13 | index: 0, 14 | highlighted: false, 15 | incognito: false, 16 | pinned: false, 17 | status: "complete" as const, 18 | favIconUrl: "https://example.com/favicon.ico", 19 | } as chrome.tabs.Tab; 20 | 21 | if (callback) { 22 | callback([mockTab]); 23 | } 24 | return Promise.resolve([mockTab]); 25 | } 26 | 27 | create(createProperties: chrome.tabs.CreateProperties, callback?: (tab: chrome.tabs.Tab) => void) { 28 | this.hook.emit("create", createProperties); 29 | callback?.({ 30 | id: 1, 31 | } as chrome.tabs.Tab); 32 | } 33 | 34 | remove(tabId: number) { 35 | this.hook.emit("remove", tabId); 36 | } 37 | 38 | sendMessage( 39 | tabId: number, 40 | message: any, 41 | options?: chrome.tabs.MessageSendOptions, 42 | callback?: (response: any) => void 43 | ) { 44 | this.hook.emit("sendMessage", tabId, message, options); 45 | if (callback) { 46 | callback({ success: true }); 47 | } 48 | return Promise.resolve({ success: true }); 49 | } 50 | 51 | onRemoved = { 52 | addListener: (callback: any) => { 53 | this.hook.addListener("remove", callback); 54 | }, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /packages/chrome-extension-mock/notifications.ts: -------------------------------------------------------------------------------- 1 | export default class Notifications { 2 | notification: Map = new Map(); 3 | 4 | onClosedHandler?: (id: string, byUser: boolean) => void; 5 | 6 | onClosed = { 7 | addListener: (callback: (notificationId: string, byUser: boolean) => void) => { 8 | this.onClosedHandler = callback; 9 | }, 10 | }; 11 | 12 | onButtonClickedHandler?: (id: string, index: number) => void; 13 | 14 | onButtonClicked = { 15 | addListener: (callback: (notificationId: string, buttonIndex: number) => void) => { 16 | this.onButtonClickedHandler = callback; 17 | }, 18 | }; 19 | 20 | mockClickButton(id: string, index: number) { 21 | this.onButtonClickedHandler?.(id, index); 22 | } 23 | 24 | onClickedHandler?: (id: string) => void; 25 | 26 | onClicked = { 27 | addListener: (callback: (notificationId: string) => void) => { 28 | this.onClickedHandler = callback; 29 | }, 30 | }; 31 | 32 | create(options: chrome.notifications.NotificationOptions, callback?: (id: string) => void) { 33 | const id = Math.random().toString(); 34 | this.notification.set(id, true); 35 | if (callback) { 36 | callback(id); 37 | } 38 | } 39 | 40 | clear(id: string) { 41 | if (!this.notification.has(id)) { 42 | throw new Error("notification not found"); 43 | } 44 | this.notification.delete(id); 45 | } 46 | 47 | update(id: string) { 48 | if (!this.notification.has(id)) { 49 | throw new Error("notification not found"); 50 | } 51 | return true; 52 | } 53 | 54 | mockClick(id: string) { 55 | this.onClickedHandler?.(id); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/cloudscript/local.ts: -------------------------------------------------------------------------------- 1 | import { ExtVersion } from "@App/app/const"; 2 | import type { Script } from "@App/app/repo/scripts"; 3 | import type { Value } from "@App/app/repo/value"; 4 | import type JSZip from "jszip"; 5 | import packageTpl from "@App/template/cloudcat-package/package.tpl"; 6 | import utilsTpl from "@App/template/cloudcat-package/utils.tpl"; 7 | import indexTpl from "@App/template/cloudcat-package/index.tpl"; 8 | import type { ExportCookies, ExportParams } from "./cloudscript"; 9 | import type CloudScript from "./cloudscript"; 10 | 11 | // 导出到本地,一个可执行到npm包 12 | export default class LocalCloudScript implements CloudScript { 13 | zip: JSZip; 14 | 15 | params: ExportParams; 16 | 17 | constructor(params: ExportParams) { 18 | this.zip = params.zip! as JSZip; 19 | this.params = params; 20 | } 21 | 22 | exportCloud(script: Script, code: string, values: Value[], cookies: ExportCookies[]): Promise { 23 | this.zip.file("userScript.js", code); 24 | this.zip.file("cookies.js", `exports.cookies = ${JSON.stringify(cookies)}`); 25 | this.zip.file("values.js", `exports.values = ${JSON.stringify(values)}`); 26 | this.zip.file( 27 | "config.js", 28 | `export default ${JSON.stringify({ 29 | version: ExtVersion, 30 | uuid: script.uuid, 31 | overwrite: { 32 | value: this.params.overwriteValue, 33 | cookie: this.params.overwriteCookie, 34 | }, 35 | })}` 36 | ); 37 | this.zip.file("package.json", packageTpl); 38 | this.zip.file("utils.js", utilsTpl); 39 | this.zip.file("index.js", indexTpl); 40 | return Promise.resolve(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vitest } from "vitest"; 2 | import { initTestGMApi } from "./utils"; 3 | import { randomUUID } from "crypto"; 4 | import { newMockXhr } from "mock-xmlhttprequest"; 5 | import type { Script, ScriptRunResource } from "@App/app/repo/scripts"; 6 | import { ScriptDAO } from "@App/app/repo/scripts"; 7 | import GMApi from "@App/app/service/content/gm_api"; 8 | 9 | describe("测试GMApi环境", async () => { 10 | const msg = initTestGMApi(); 11 | const script: Script = { 12 | uuid: randomUUID(), 13 | name: "test", 14 | metadata: { 15 | grant: [ 16 | // gm xhr 17 | "GM_xmlhttpRequest", 18 | ], 19 | connect: ["example.com"], 20 | }, 21 | namespace: "", 22 | type: 1, 23 | status: 1, 24 | sort: 0, 25 | runStatus: "running", 26 | createtime: 0, 27 | checktime: 0, 28 | }; 29 | await new ScriptDAO().save(script); 30 | const gmApi = new GMApi("serviceWorker", msg, { 31 | uuid: script.uuid, 32 | }); 33 | const mockXhr = newMockXhr(); 34 | mockXhr.onSend = async (request) => { 35 | return request.respond(200, {}, "example"); 36 | }; 37 | global.XMLHttpRequest = mockXhr; 38 | it("test GM xhr", async () => { 39 | const onload = vitest.fn(); 40 | await new Promise((resolve) => { 41 | gmApi.GM_xmlhttpRequest({ 42 | url: "https://example.com/", 43 | onload: (res) => { 44 | console.log(res); 45 | resolve(res); 46 | onload(res.responseText); 47 | }, 48 | }); 49 | }); 50 | expect(onload).toBeCalled(); 51 | expect(onload.mock.calls[0][0]).toBe("example"); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/app/logger/core.ts: -------------------------------------------------------------------------------- 1 | import Logger from "./logger"; 2 | 3 | export type LogLevel = "none" | "trace" | "debug" | "info" | "warn" | "error"; 4 | 5 | export interface LogLabel { 6 | [key: string]: string | string[] | boolean | number | object | undefined; 7 | component?: string; 8 | } 9 | 10 | // 储存 11 | export interface Writer { 12 | write(level: LogLevel, message: string, label: LogLabel): void; 13 | } 14 | 15 | export class EmptyWriter implements Writer { 16 | write(): void {} 17 | } 18 | 19 | export default class LoggerCore { 20 | static instance: LoggerCore; 21 | 22 | static getInstance() { 23 | return LoggerCore.instance; 24 | } 25 | 26 | static logger(...label: LogLabel[]) { 27 | return LoggerCore.getInstance().logger(...label); 28 | } 29 | 30 | writer: Writer; 31 | 32 | // 日志级别, 会记录在日志文件中 33 | level: LogLevel = "info"; 34 | 35 | // 打印在console的等级, 会在控制台输出 36 | consoleLevel: LogLevel = "warn"; 37 | 38 | labels: LogLabel; 39 | 40 | constructor(config: { level?: LogLevel; consoleLevel?: LogLevel; writer: Writer; labels: LogLabel }) { 41 | this.writer = config.writer; 42 | this.level = config.level || this.level; 43 | this.labels = config.labels || {}; 44 | // 获取日志debug等级, 如果是开发环境, 则默认为debug 45 | if (config.consoleLevel !== undefined) { 46 | this.consoleLevel = config.consoleLevel; 47 | } else { 48 | if (process.env.NODE_ENV === "development") { 49 | this.consoleLevel = "debug"; 50 | } 51 | } 52 | if (!LoggerCore.instance) { 53 | LoggerCore.instance = this; 54 | } 55 | } 56 | 57 | logger(...label: LogLabel[]) { 58 | return new Logger(this, this.labels, ...label); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/message/client.ts: -------------------------------------------------------------------------------- 1 | import type { MessageConnect, MessageSend, TMessageCommAction, TMessageCommCode } from "./types"; 2 | import LoggerCore from "@App/app/logger/core"; 3 | import Logger from "@App/app/logger/logger"; 4 | 5 | export async function sendMessage(msg: MessageSend, action: string, data?: any): Promise { 6 | const res = await msg.sendMessage | TMessageCommAction>({ action, data }); 7 | const logger = LoggerCore.getInstance().logger().with({ action, data, response: res }); 8 | logger.trace("sendMessage"); 9 | if (res?.code) { 10 | console.error(res); 11 | throw res.message; 12 | } else { 13 | try { 14 | return res.data; 15 | } catch (e) { 16 | logger.trace("Invalid response data", Logger.E(e)); 17 | return undefined; 18 | } 19 | } 20 | } 21 | 22 | export function connect(msg: MessageSend, action: string, data?: any): Promise { 23 | return msg.connect({ action, data }); 24 | } 25 | 26 | export class Client { 27 | constructor( 28 | protected msg: MessageSend, 29 | protected prefix?: string 30 | ) { 31 | if (this.prefix && !this.prefix.endsWith("/")) { 32 | this.prefix += "/"; 33 | } else { 34 | this.prefix = ""; 35 | } 36 | } 37 | 38 | do(action: string, params?: any): Promise { 39 | return sendMessage(this.msg, `${this.prefix}${action}`, params); 40 | } 41 | 42 | async doThrow(action: string, params?: any): Promise { 43 | const ret = await sendMessage(this.msg, `${this.prefix}${action}`, params); 44 | if (!ret) { 45 | throw new Error(`doThrow: ${this.prefix}${action}`); 46 | } 47 | return ret; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/pkg/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto-js"; 2 | import { MD5 } from "crypto-js"; 3 | 4 | export function calculateMd5(blob: Blob) { 5 | return new Promise((resolve, reject) => { 6 | const reader = new FileReader(); 7 | reader.readAsArrayBuffer(blob); 8 | reader.onloadend = () => { 9 | if (!reader.result) { 10 | reject(new Error("result is null")); 11 | } else { 12 | const result = calculateMD5FromArrayBuffer(reader.result); 13 | resolve(result); 14 | } 15 | }; 16 | }); 17 | } 18 | 19 | export function md5OfText(text: string) { 20 | return MD5(text).toString(); 21 | } 22 | 23 | function calculateMD5FromArrayBuffer(a: ArrayBuffer) { 24 | const wordArray = crypto.lib.WordArray.create(a); 25 | return MD5(wordArray).toString(); 26 | } 27 | 28 | export function calculateHashFromArrayBuffer(a: ArrayBuffer) { 29 | const wordArray = crypto.lib.WordArray.create(a); 30 | // 计算各种哈希值 31 | const ret = { 32 | md5: crypto.MD5(wordArray), 33 | sha1: crypto.SHA1(wordArray), 34 | sha256: crypto.SHA256(wordArray), 35 | sha384: crypto.SHA384(wordArray), 36 | sha512: crypto.SHA512(wordArray), 37 | }; 38 | return { 39 | md5: ret.md5.toString(), 40 | sha1: ret.sha1.toString(), 41 | sha256: ret.sha256.toString(), 42 | sha384: ret.sha384.toString(), 43 | sha512: ret.sha512.toString(), 44 | integrity: { 45 | md5: ret.md5.toString(crypto.enc.Base64), 46 | sha1: ret.sha1.toString(crypto.enc.Base64), 47 | sha256: ret.sha256.toString(crypto.enc.Base64), 48 | sha384: ret.sha384.toString(crypto.enc.Base64), 49 | sha512: ret.sha512.toString(crypto.enc.Base64), 50 | }, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/pages/store/store.ts: -------------------------------------------------------------------------------- 1 | import type { Action, ThunkAction } from "@reduxjs/toolkit"; 2 | import { combineSlices, configureStore } from "@reduxjs/toolkit"; 3 | import { setupListeners } from "@reduxjs/toolkit/query"; 4 | import { scriptSlice } from "./features/script"; 5 | import { configSlice } from "./features/config"; 6 | 7 | // `combineSlices` automatically combines the reducers using 8 | // their `reducerPath`s, therefore we no longer need to call `combineReducers`. 9 | const rootReducer = combineSlices(configSlice, scriptSlice); 10 | // Infer the `RootState` type from the root reducer 11 | export type RootState = ReturnType; 12 | 13 | // The store setup is wrapped in `makeStore` to allow reuse 14 | // when setting up tests that need the same store config 15 | export const makeStore = (preloadedState?: Partial) => { 16 | const store = configureStore({ 17 | reducer: rootReducer, 18 | // Adding the api middleware enables caching, invalidation, polling, 19 | // and other useful features of `rtk-query`. 20 | // middleware: (getDefaultMiddleware) => { 21 | // return getDefaultMiddleware().concat(quotesApiSlice.middleware); 22 | // }, 23 | preloadedState, 24 | }); 25 | // configure listeners using the provided defaults 26 | // optional, but required for `refetchOnFocus`/`refetchOnReconnect` behaviors 27 | setupListeners(store.dispatch); 28 | return store; 29 | }; 30 | 31 | export const store = makeStore(); 32 | 33 | // Infer the type of `store` 34 | export type AppStore = typeof store; 35 | // Infer the `AppDispatch` type from the store itself 36 | export type AppDispatch = AppStore["dispatch"]; 37 | export type AppThunk = ThunkAction; 38 | -------------------------------------------------------------------------------- /example/cat_bg_input_menu.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name bg cat input menu 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 在后台脚本中带交互输入的快捷菜单 6 | // @author You 7 | // @background 8 | // @grant CAT_registerMenuInput 9 | // @grant CAT_unregisterMenuInput 10 | // @grant GM_notification 11 | // ==/UserScript== 12 | 13 | return new Promise((resolve) => { 14 | const id = CAT_registerMenuInput( 15 | "测试菜单", 16 | () => { 17 | console.log(id); 18 | CAT_unregisterMenuInput(id); 19 | }, 20 | "z" 21 | ); 22 | 23 | CAT_registerMenuInput( 24 | "测试菜单boolean", 25 | (inputValue) => { 26 | GM_notification({ 27 | title: "测试菜单boolean", 28 | text: "" + inputValue, 29 | }); 30 | }, 31 | { 32 | inputType: "boolean", 33 | inputLabel: "是否通知", 34 | inputDefaultValue: true, 35 | autoClose: false, 36 | } 37 | ); 38 | 39 | CAT_registerMenuInput( 40 | "测试菜单text", 41 | (inputValue) => { 42 | GM_notification({ 43 | title: "测试菜单text", 44 | text: "" + inputValue, 45 | }); 46 | }, 47 | { 48 | inputType: "text", 49 | inputLabel: "通知内容", 50 | inputValue: "text", 51 | autoClose: false, 52 | } 53 | ); 54 | 55 | CAT_registerMenuInput( 56 | "测试菜单number", 57 | (inputValue) => { 58 | setTimeout(() => { 59 | GM_notification({ 60 | title: "测试菜单number", 61 | text: "" + (1000 + inputValue), 62 | }); 63 | }, 1000 + inputValue); 64 | }, 65 | { 66 | inputType: "number", 67 | inputLabel: "延迟ms", 68 | inputPlaceholder: "最低1000ms", 69 | } 70 | ); 71 | 72 | resolve(); 73 | }); 74 | -------------------------------------------------------------------------------- /src/app/service/content/gm_info.ts: -------------------------------------------------------------------------------- 1 | import { ExtVersion } from "@App/app/const"; 2 | import type { GMInfoEnv } from "./types"; 3 | import type { ScriptLoadInfo } from "../service_worker/types"; 4 | 5 | // 获取脚本信息和管理器信息 6 | export function evaluateGMInfo(envInfo: GMInfoEnv, script: ScriptLoadInfo) { 7 | const options = { 8 | description: script.metadata.description?.[0] || null, 9 | matches: script.metadata.match || [], 10 | includes: script.metadata.include || [], 11 | "run-at": script.metadata["run-at"]?.[0] || "document-idle", 12 | "run-in": script.metadata["run-in"] || [], 13 | icon: script.metadata.icon?.[0] || null, 14 | icon64: script.metadata.icon64?.[0] || null, 15 | header: script.metadataStr, 16 | grant: script.metadata.grant || [], 17 | connects: script.metadata.connect || [], 18 | }; 19 | return { 20 | downloadMode: "native", 21 | isIncognito: envInfo.isIncognito, 22 | // relaxedCsp 23 | sandboxMode: envInfo.sandboxMode, 24 | scriptWillUpdate: true, 25 | scriptHandler: "ScriptCat", 26 | userAgentData: envInfo.userAgentData, 27 | // "" => null 28 | scriptUpdateURL: script.downloadUrl || null, 29 | scriptMetaStr: script.metadataStr, 30 | userConfig: script.userConfig, 31 | userConfigStr: script.userConfigStr, 32 | // scriptSource: script.sourceCode, 33 | version: ExtVersion, 34 | script: { 35 | // TODO: 更多完整的信息(为了兼容Tampermonkey,后续待定) 36 | name: script.name, 37 | namespace: script.namespace, 38 | version: script.metadata.version?.[0], 39 | author: script.author, 40 | lastModified: script.updatetime, 41 | downloadURL: script.downloadUrl || null, 42 | updateURL: script.checkUpdateUrl || null, 43 | ...options, 44 | }, 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /tests/test-utils.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from "react"; 2 | import { render, type RenderOptions, cleanup } from "@testing-library/react"; 3 | import { Provider } from "react-redux"; 4 | import { configureStore } from "@reduxjs/toolkit"; 5 | import { vi, afterEach } from "vitest"; 6 | 7 | // Mock monaco-editor 8 | vi.mock("monaco-editor", () => ({ 9 | editor: { 10 | setTheme: vi.fn(), 11 | create: vi.fn(), 12 | createModel: vi.fn(), 13 | setModelLanguage: vi.fn(), 14 | }, 15 | })); 16 | 17 | // Cleanup after each test 18 | afterEach(() => { 19 | cleanup(); 20 | }); 21 | 22 | // 创建一个基础的mock store 23 | const createMockStore = (initialState = {}) => { 24 | return configureStore({ 25 | reducer: { 26 | script: (state = { scripts: [], backScripts: [] }) => state, 27 | config: (state = { theme: "light" }) => state, 28 | }, 29 | preloadedState: initialState, 30 | }); 31 | }; 32 | 33 | // 自定义render函数,包装Redux Provider 34 | const customRender = ( 35 | ui: ReactElement, 36 | { 37 | initialState = {}, 38 | store = createMockStore(initialState), 39 | ...renderOptions 40 | }: { initialState?: any; store?: any } & Omit = {} 41 | ) => { 42 | const Wrapper = ({ children }: { children: React.ReactNode }) => { 43 | return {children}; 44 | }; 45 | 46 | return render(ui, { wrapper: Wrapper, ...renderOptions }); 47 | }; 48 | 49 | // Setup global mocks 50 | export const setupGlobalMocks = () => { 51 | // Chrome mock已经在vitest.setup.ts中通过chromeMock.init()设置了 52 | 53 | // Mock window.open 54 | Object.assign(global, { 55 | open: vi.fn(), 56 | location: { href: "https://example.com" }, 57 | }); 58 | }; 59 | 60 | export * from "@testing-library/react"; 61 | export { customRender as render, createMockStore }; 62 | -------------------------------------------------------------------------------- /packages/filesystem/zip/zip.ts: -------------------------------------------------------------------------------- 1 | import JSZip from "jszip"; 2 | import type { File, FileReader, FileWriter } from "@Packages/filesystem/filesystem"; 3 | import type FileSystem from "@Packages/filesystem/filesystem"; 4 | import { ZipFileReader, ZipFileWriter } from "./rw"; 5 | 6 | export default class ZipFileSystem implements FileSystem { 7 | zip: JSZip; 8 | 9 | basePath: string; 10 | 11 | // zip为空时,创建一个空的zip 12 | constructor(zip?: JSZip, basePath?: string) { 13 | this.zip = zip || new JSZip(); 14 | this.basePath = basePath || ""; 15 | } 16 | 17 | async verify(): Promise { 18 | // do nothing 19 | } 20 | 21 | async open(info: File): Promise { 22 | const path = info.name; 23 | const file = this.zip.file(path); 24 | if (file) { 25 | return new ZipFileReader(file); 26 | } 27 | throw new Error("File not found"); 28 | } 29 | 30 | async openDir(path: string): Promise { 31 | return new ZipFileSystem(this.zip, path); 32 | } 33 | 34 | async create(path: string): Promise { 35 | return new ZipFileWriter(this.zip, path); 36 | } 37 | 38 | async createDir(): Promise { 39 | // do nothing 40 | } 41 | 42 | async delete(path: string): Promise { 43 | this.zip.remove(path); 44 | } 45 | 46 | async list(): Promise { 47 | const files: File[] = []; 48 | Object.keys(this.zip.files).forEach((key) => { 49 | files.push({ 50 | name: key, 51 | path: key, 52 | size: 0, 53 | digest: "", 54 | createtime: this.zip.files[key].date.getTime(), 55 | updatetime: this.zip.files[key].date.getTime(), 56 | }); 57 | }); 58 | return files; 59 | } 60 | 61 | async getDirUrl(): Promise { 62 | throw new Error("Method not implemented."); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /example/gm_notification.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name gm notification 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 用来发送一个浏览器通知, 支持图标/文字/进度条(进度条只在 Chrome 有效) 6 | // @author You 7 | // @match https://bbs.tampermonkey.net.cn/ 8 | // @grant GM_notification 9 | // ==/UserScript== 10 | 11 | /** 12 | * @typedef {import('../src/types/scriptcat')} ScriptCat 13 | */ 14 | 15 | let i; 16 | GM_notification({ 17 | title: "倒计时", 18 | text: "准备进入倒计时,创建和获取通知id", 19 | ondone: (byUser) => { 20 | console.log("done user:", byUser); 21 | clearInterval(i); 22 | }, 23 | onclick: () => { 24 | console.log("click"); 25 | }, 26 | oncreate: (id) => { 27 | let t = 1; 28 | i = setInterval(() => { 29 | GM_updateNotification(id, { 30 | title: "倒计时", 31 | text: 60 - t + "s倒计时", 32 | progress: (100 / 60) * t, 33 | }); 34 | if (t == 60) { 35 | clearInterval(i); 36 | GM_updateNotification(id, { 37 | title: "倒计时", 38 | text: "倒计时结束", 39 | progress: 100, 40 | }); 41 | } 42 | t++; 43 | }, 1000); 44 | }, 45 | // 开启进度条模式 46 | progress: 0, 47 | }); 48 | 49 | // 示例2: 综合功能通知 - 使用更多特性 50 | GM_notification({ 51 | title: "综合功能通知", 52 | text: "这是一个展示多种特性的通知示例", 53 | tag: "feature-demo", // 使用相同的tag可以覆盖之前的通知,否则会创建新的通知 54 | image: "https://bbs.tampermonkey.net.cn/favicon.ico", // 自定义图标 55 | timeout: 10000, // 10秒后自动关闭 56 | url: "https://bbs.tampermonkey.net.cn/", // 关联URL 57 | onclick: (event) => { 58 | console.log("通知被点击:", event); 59 | // event.preventDefault(); // 阻止打开url 60 | }, 61 | oncreate: (event) => { 62 | console.log("综合功能通知已创建,ID:", event.id); 63 | }, 64 | ondone: (user) => { 65 | console.log("综合功能通知完成,用户操作:", user); 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /src/pkg/config/chrome_storage.ts: -------------------------------------------------------------------------------- 1 | export default class ChromeStorage { 2 | private prefix: string; 3 | 4 | private storage: chrome.storage.StorageArea; 5 | 6 | constructor(prefix: string, sync: boolean) { 7 | this.prefix = `${prefix}_`; 8 | this.storage = sync ? chrome.storage.sync : chrome.storage.local; 9 | } 10 | 11 | public buildKey(key: string): string { 12 | return this.prefix + key; 13 | } 14 | 15 | public get(key: string): Promise { 16 | return new Promise((resolve) => { 17 | key = this.buildKey(key); 18 | this.storage.get(key, (items) => { 19 | resolve(items && items[key]); 20 | }); 21 | }); 22 | } 23 | 24 | public set(key: string, value: any): Promise { 25 | return new Promise((resolve) => { 26 | const kvp: { [key: string]: any } = {}; 27 | kvp[this.buildKey(key)] = value; 28 | this.storage.set(kvp, () => resolve()); 29 | }); 30 | } 31 | 32 | public remove(key: string): Promise { 33 | return new Promise((resolve) => { 34 | this.storage.remove(this.buildKey(key), () => resolve()); 35 | }); 36 | } 37 | 38 | public removeAll(): Promise { 39 | return new Promise((resolve) => { 40 | this.storage.clear(() => resolve()); 41 | }); 42 | } 43 | 44 | public keys(): Promise<{ [key: string]: any }> { 45 | return new Promise((resolve) => { 46 | const ret: { [key: string]: any } = {}; 47 | const prefix = this.buildKey(""); 48 | this.storage.get((items: { [key: string]: any }) => { 49 | if (!items) { 50 | return resolve(ret); 51 | } 52 | for (const key of Object.keys(items)) { 53 | if (key.startsWith(prefix)) { 54 | ret[key.substring(prefix.length)] = items[key]; 55 | } 56 | } 57 | return resolve(ret); 58 | }); 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app/service/offscreen/client.ts: -------------------------------------------------------------------------------- 1 | import { type WindowMessage } from "@Packages/message/window_message"; 2 | import type { SCRIPT_RUN_STATUS, ScriptRunResource } from "@App/app/repo/scripts"; 3 | import { Client, sendMessage } from "@Packages/message/client"; 4 | import type { MessageSend } from "@Packages/message/types"; 5 | import { type VSCodeConnect } from "./vscode-connect"; 6 | 7 | export function preparationSandbox(msg: WindowMessage) { 8 | return sendMessage(msg, "offscreen/preparationSandbox"); 9 | } 10 | 11 | // 代理发送消息到ServiceWorker 12 | export function sendMessageToServiceWorker(msg: WindowMessage, action: string, data?: any) { 13 | return sendMessage(msg, "offscreen/sendMessageToServiceWorker", { action, data }); 14 | } 15 | 16 | // 代理连接ServiceWorker 17 | export function connectServiceWorker(msg: WindowMessage) { 18 | return sendMessage(msg, "offscreen/connectServiceWorker"); 19 | } 20 | 21 | export function proxyUpdateRunStatus( 22 | msg: WindowMessage, 23 | data: { uuid: string; runStatus: SCRIPT_RUN_STATUS; error?: any; nextruntime?: number } 24 | ) { 25 | return sendMessageToServiceWorker(msg, "script/updateRunStatus", data); 26 | } 27 | 28 | export function runScript(msg: MessageSend, data: ScriptRunResource) { 29 | return sendMessage(msg, "offscreen/script/runScript", data); 30 | } 31 | 32 | export function stopScript(msg: MessageSend, uuid: string) { 33 | return sendMessage(msg, "offscreen/script/stopScript", uuid); 34 | } 35 | 36 | export function createObjectURL(msg: MessageSend, data: Blob, persistence: boolean = false) { 37 | return sendMessage(msg, "offscreen/createObjectURL", { data, persistence }); 38 | } 39 | 40 | export class VscodeConnectClient extends Client { 41 | constructor(msg: MessageSend) { 42 | super(msg, "offscreen/vscodeConnect"); 43 | } 44 | 45 | connect(params: Parameters[0]): ReturnType { 46 | return this.do("connect", params); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /scripts/changlog.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable @typescript-eslint/no-require-imports */ 3 | /* eslint-disable no-undef */ 4 | 5 | const { execSync } = require("child_process"); 6 | const fs = require("fs"); 7 | const path = require("path"); 8 | 9 | /** 10 | * 生成changelog并处理文件内容 11 | */ 12 | function generateChangelog() { 13 | try { 14 | console.log("🚀 开始生成 CHANGELOG.md..."); 15 | 16 | // 执行 npm run changlog 命令 17 | console.log("📝 执行 gitmoji-changelog 生成changelog..."); 18 | execSync("gitmoji-changelog init --author=true --group-similar-commits=true", { 19 | stdio: "inherit", 20 | cwd: process.cwd(), 21 | }); 22 | 23 | console.log("✅ changelog 生成完成"); 24 | 25 | // 读取生成的 CHANGELOG.md 文件 26 | const changelogPath = path.join(process.cwd(), "CHANGELOG.md"); 27 | 28 | if (!fs.existsSync(changelogPath)) { 29 | console.error("❌ CHANGELOG.md 文件不存在"); 30 | process.exit(1); 31 | } 32 | 33 | console.log("📖 读取 CHANGELOG.md 文件..."); 34 | let content = fs.readFileSync(changelogPath, "utf8"); 35 | 36 | // 使用正则表达式替换 (by (\w) -> (by @$1 37 | // 删除owner 38 | console.log("🔄 处理文件内容,添加 @ 符号..."); 39 | let updatedContent = content.replaceAll(" (by 王一之)", ""); 40 | updatedContent = updatedContent.replaceAll(" (by CodFrm)", ""); 41 | updatedContent = updatedContent.replace(/\(by (\w)/g, "(by @$1"); 42 | 43 | // 检查是否有内容被替换 44 | if (content !== updatedContent) { 45 | // 写回文件 46 | fs.writeFileSync(changelogPath, updatedContent, "utf8"); 47 | console.log("✅ 文件内容已更新,作者名前已添加 @ 符号"); 48 | } else { 49 | console.log("ℹ️ 没有找到需要替换的内容"); 50 | } 51 | 52 | console.log("🎉 CHANGELOG.md 处理完成!"); 53 | } catch (error) { 54 | console.error("❌ 生成changelog时出错:", error.message); 55 | process.exit(1); 56 | } 57 | } 58 | 59 | // 如果直接运行此脚本 60 | if (require.main === module) { 61 | generateChangelog(); 62 | } 63 | 64 | module.exports = { generateChangelog }; 65 | -------------------------------------------------------------------------------- /packages/message/mock_message.ts: -------------------------------------------------------------------------------- 1 | import type { Message, MessageConnect, MessageSend, TMessage } from "./types"; 2 | import EventEmitter from "eventemitter3"; 3 | import { sleep } from "@App/pkg/utils/utils"; 4 | 5 | export class MockMessageConnect implements MessageConnect { 6 | constructor(protected EE: EventEmitter) {} 7 | 8 | onMessage(callback: (data: TMessage) => void): void { 9 | this.EE.on("message", (data: any) => { 10 | callback(data); 11 | }); 12 | } 13 | 14 | sendMessage(data: TMessage): void { 15 | this.EE.emit("message", data); 16 | } 17 | 18 | disconnect(): void { 19 | this.EE.emit("disconnect"); 20 | } 21 | 22 | onDisconnect(callback: () => void): void { 23 | this.EE.on("disconnect", callback); 24 | } 25 | } 26 | 27 | export class MockMessageSend implements MessageSend { 28 | constructor(protected EE: EventEmitter) {} 29 | 30 | connect(data: TMessage): Promise { 31 | return new Promise((resolve) => { 32 | const EE = new EventEmitter(); 33 | const con = new MockMessageConnect(EE); 34 | resolve(con); 35 | sleep(1).then(() => { 36 | this.EE.emit("connect", data, con); 37 | }); 38 | }); 39 | } 40 | 41 | sendMessage(data: TMessage): Promise { 42 | return new Promise((resolve) => { 43 | this.EE.emit("message", data, (resp: T) => { 44 | resolve(resp); 45 | }); 46 | }); 47 | } 48 | } 49 | 50 | export class MockMessage extends MockMessageSend implements Message { 51 | onConnect(callback: (data: any, con: MessageConnect) => void): void { 52 | this.EE.on("connect", (data: any, con: MessageConnect) => { 53 | callback(data, con); 54 | }); 55 | } 56 | 57 | onMessage(callback: (data: any, sendResponse: (data: any) => void) => void): void { 58 | this.EE.on("message", (data: any, sendResponse: (data: any) => void) => { 59 | callback(data, sendResponse); 60 | }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/app/service/service_worker/system.ts: -------------------------------------------------------------------------------- 1 | import { type SystemConfig } from "@App/pkg/config/config"; 2 | import { type Group } from "@Packages/message/server"; 3 | import type { MessageSend } from "@Packages/message/types"; 4 | import { createObjectURL, VscodeConnectClient } from "../offscreen/client"; 5 | import { cacheInstance } from "@App/app/cache"; 6 | import { CACHE_KEY_FAVICON } from "@App/app/cache_key"; 7 | import { fetchIconByDomain } from "./fetch"; 8 | 9 | // 一些系统服务 10 | export class SystemService { 11 | constructor( 12 | private systemConfig: SystemConfig, 13 | private group: Group, 14 | private sender: MessageSend 15 | ) {} 16 | 17 | getFaviconFromDomain(domain: string) { 18 | return fetchIconByDomain(domain); 19 | } 20 | 21 | async init() { 22 | const vscodeConnect = new VscodeConnectClient(this.sender); 23 | // 如果开启了自动连接vscode,则自动连接 24 | // 使用tx来确保service_worker恢复时不会再执行 25 | cacheInstance.tx("vscodeReconnect", async (init) => { 26 | if (!init) { 27 | if (await this.systemConfig.getVscodeReconnect()) { 28 | // 调用连接 29 | vscodeConnect.connect({ 30 | url: await this.systemConfig.getVscodeUrl(), 31 | reconnect: true, 32 | }); 33 | } 34 | } 35 | return true; 36 | }); 37 | this.group.on("connectVSCode", (params) => { 38 | return vscodeConnect.connect(params); 39 | }); 40 | this.group.on("loadFavicon", async (url) => { 41 | // 加载favicon图标 42 | // 对url做一个缓存 43 | const cacheKey = `${CACHE_KEY_FAVICON}${url}`; 44 | return cacheInstance.getOrSet(cacheKey, async () => { 45 | return fetch(url) 46 | .then((response) => response.blob()) 47 | .then((blob) => createObjectURL(this.sender, blob, true)) 48 | .catch(() => { 49 | return ""; 50 | }); 51 | }); 52 | }); 53 | 54 | this.group.on("getFaviconFromDomain", this.getFaviconFromDomain.bind(this)); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/pkg/utils/file-tracker.ts: -------------------------------------------------------------------------------- 1 | const handleRecords = new Set<[FileSystemFileHandle, FTInfo, FileSystemObserverInstance]>(); 2 | 3 | export type FTInfo = { 4 | uuid: string; 5 | fileName: string; 6 | setCode(code: string, hideInfo?: boolean): void; 7 | lastModified?: number; 8 | }; 9 | 10 | const callback = async (records: FileSystemChangeRecord[], observer: FileSystemObserverInstance) => { 11 | for (const record of records) { 12 | const { root, type } = record; 13 | if (!(root instanceof FileSystemFileHandle) || type !== "modified") continue; 14 | for (const [fileHandle, ftInfo, fileObserver] of handleRecords) { 15 | if (fileObserver !== observer) continue; 16 | try { 17 | const isSame = await root.isSameEntry(fileHandle); 18 | if (!isSame) continue; 19 | // 调用安装 20 | const file = await root.getFile(); 21 | // 避免重覆更新 22 | if (ftInfo.lastModified === file.lastModified) continue; 23 | ftInfo.lastModified = file.lastModified; 24 | const code = await file.text(); 25 | if (code && typeof code === "string") { 26 | ftInfo.setCode(code, false); 27 | } 28 | } catch (e) { 29 | console.warn(e); 30 | } 31 | } 32 | } 33 | }; 34 | 35 | export const startFileTrack = (fileHandle: FileSystemFileHandle, ftInfo: FTInfo) => { 36 | const fileObserver = new FileSystemObserver(callback); 37 | handleRecords.add([fileHandle, ftInfo, fileObserver]); 38 | fileObserver.observe(fileHandle); 39 | }; 40 | 41 | export const unmountFileTrack = async (fileHandle: FileSystemFileHandle) => { 42 | try { 43 | for (const entry of handleRecords) { 44 | const [fileHandleEntry, _ftInfo, fileObserver] = entry; 45 | if (await fileHandle.isSameEntry(fileHandleEntry)) { 46 | handleRecords.delete(entry); 47 | fileObserver.disconnect(); 48 | return true; 49 | } 50 | } 51 | } catch (e) { 52 | console.warn(e); 53 | } 54 | return false; 55 | }; 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: "Bug反馈" 2 | description: 提交脚本猫使用过程中遇到的 BUG 3 | title: "[BUG] " 4 | labels: ["bug"] 5 | # assignees: "" 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | **请花2分钟填写以下信息,这能极大帮助我们快速定位问题** 11 | 12 | - type: textarea 13 | id: problem-description 14 | attributes: 15 | label: "问题描述" 16 | description: "发生了什么问题?你预期的正常行为是什么?" 17 | placeholder: "例:在YouTube页面点击下载按钮时,脚本报错404,预期应弹出下载窗口" 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | id: reproduction-steps 23 | attributes: 24 | label: "重现步骤" 25 | description: "请按顺序列出可稳定重现问题的操作步骤" 26 | placeholder: | 27 | 1. 打开 https://example.com 28 | 2. 点击页面右上角红色按钮 29 | 3. 等待5秒后观察控制台 30 | validations: 31 | required: true 32 | 33 | - type: markdown 34 | attributes: 35 | value: "### 环境信息" 36 | - type: input 37 | id: scriptcat-version 38 | attributes: 39 | label: 脚本猫版本 40 | description: 你可以点击脚本猫的弹出窗口进行查看,可以的话最好使用最新版本,也许你的问题已经解决了 41 | placeholder: 例如 v0.17.0 42 | validations: 43 | required: true 44 | - type: input 45 | id: browser-version 46 | attributes: 47 | label: 操作系统以及浏览器信息 48 | description: 你可以在浏览器-关于下进行查看,或者输入 chrome://settings/help 49 | placeholder: 例如 Windows 11 Chrome 137.0.3296.93 (正式版本) (arm64) 50 | validations: 51 | required: true 52 | 53 | - type: markdown 54 | attributes: 55 | value: "### 附加说明" 56 | 57 | - type: textarea 58 | id: additional-info 59 | attributes: 60 | label: 补充信息 (选填) 61 | description: 错误日志/截图/脚本代码片段/相关配置... 62 | placeholder: | 63 | 如果涉及某个具体的脚本,最好在扩展的首页中,选中将其导出后的zip压缩包上传上来,或者给出脚本的安装地址 64 | 65 | 扩展本身的错误日志查看流程为: 66 | 1. 打开扩展管理页(chrome://extensions/) 67 | 2. 打开开发者模式,点击脚本猫的 服务工作进程 或 offscreen.html 68 | 3. 在弹出窗口中的控制台页面查看 69 | 70 | 普通脚本的错误日志在脚本运行页面的开发者工具(快捷键F12)-控制台中查看 71 | 后台脚本的错误日志在上述的 offscreen.html 中查看 72 | -------------------------------------------------------------------------------- /src/pages/store/features/config.ts: -------------------------------------------------------------------------------- 1 | import { createAppSlice } from "../hooks"; 2 | import type { PayloadAction } from "@reduxjs/toolkit"; 3 | import { editor } from "monaco-editor"; 4 | 5 | function setAutoMode() { 6 | const darkTheme = window.matchMedia("(prefers-color-scheme: dark)"); 7 | const isMatch = (match: boolean) => { 8 | if (match) { 9 | document.body.setAttribute("arco-theme", "dark"); 10 | editor.setTheme("vs-dark"); 11 | } else { 12 | document.body.removeAttribute("arco-theme"); 13 | editor.setTheme("vs"); 14 | } 15 | }; 16 | darkTheme.addEventListener("change", (e) => { 17 | isMatch(e.matches); 18 | }); 19 | isMatch(darkTheme.matches); 20 | } 21 | 22 | export const configSlice = createAppSlice({ 23 | name: "setting", 24 | initialState: { 25 | lightMode: localStorage.lightMode || "auto", 26 | }, 27 | reducers: (create) => { 28 | // 判断模式 29 | switch (localStorage.lightMode) { 30 | case "dark": 31 | document.body.setAttribute("arco-theme", "dark"); 32 | editor.setTheme("vs-dark"); 33 | break; 34 | case "light": 35 | document.body.removeAttribute("arco-theme"); 36 | editor.setTheme("vs"); 37 | break; 38 | default: 39 | setAutoMode(); 40 | break; 41 | } 42 | return { 43 | setDarkMode: create.reducer((state, action: PayloadAction<"light" | "dark" | "auto">) => { 44 | localStorage.lightMode = action.payload; 45 | state.lightMode = action.payload; 46 | if (action.payload === "auto") { 47 | setAutoMode(); 48 | } else { 49 | document.body.setAttribute("arco-theme", action.payload); 50 | editor.setTheme(action.payload === "dark" ? "vs-dark" : "vs"); 51 | } 52 | }), 53 | }; 54 | }, 55 | selectors: { 56 | selectThemeMode: (state) => state.lightMode, 57 | }, 58 | }); 59 | 60 | export const { setDarkMode } = configSlice.actions; 61 | 62 | export const { selectThemeMode } = configSlice.selectors; 63 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { fixupConfigRules } from "@eslint/compat"; 2 | import js from "@eslint/js"; 3 | import prettier from "eslint-config-prettier"; 4 | import prettierPlugin from "eslint-plugin-prettier"; 5 | import reactHooks from "eslint-plugin-react-hooks"; 6 | import reactJsx from "eslint-plugin-react/configs/jsx-runtime.js"; 7 | import react from "eslint-plugin-react/configs/recommended.js"; 8 | import ts from "typescript-eslint"; 9 | import globals from "globals"; 10 | import requireLastErrorCheck from "./eslint-rules/require-last-error-check.js"; 11 | 12 | export default [ 13 | { 14 | languageOptions: { 15 | ecmaVersion: 2020, 16 | sourceType: "module", 17 | globals: { 18 | ...globals.browser, 19 | ...globals.webextensions, 20 | }, 21 | }, 22 | }, 23 | js.configs.recommended, 24 | ...ts.configs.recommended, 25 | ...fixupConfigRules([ 26 | { 27 | ...react, 28 | settings: { 29 | react: { version: "detect" }, 30 | }, 31 | }, 32 | reactJsx, 33 | ]), 34 | { 35 | plugins: { 36 | "react-hooks": reactHooks, 37 | prettier: prettierPlugin, 38 | "chrome-error": { 39 | rules: { 40 | "require-last-error-check": requireLastErrorCheck, 41 | }, 42 | }, 43 | }, 44 | rules: { 45 | "@typescript-eslint/no-explicit-any": "off", 46 | "@typescript-eslint/ban-ts-comment": "off", 47 | "@typescript-eslint/no-unused-expressions": "off", 48 | "@typescript-eslint/consistent-type-imports": "error", 49 | "@typescript-eslint/no-unused-vars": [ 50 | "error", 51 | { 52 | argsIgnorePattern: "^_", 53 | varsIgnorePattern: "^_", 54 | caughtErrorsIgnorePattern: "^_", 55 | }, 56 | ], 57 | ...reactHooks.configs.recommended.rules, 58 | "react-hooks/exhaustive-deps": "off", 59 | "prettier/prettier": "error", 60 | "react/prop-types": "off", 61 | "chrome-error/require-last-error-check": "error", 62 | "react/jsx-no-literals": "warn", 63 | }, 64 | }, 65 | prettier, 66 | { ignores: ["dist/", "example/"] }, 67 | ]; 68 | -------------------------------------------------------------------------------- /src/pages/options/index.css: -------------------------------------------------------------------------------- 1 | .show-log-card .arco-list-item { 2 | border-bottom: 0 !important; 3 | } 4 | 5 | h1.arco-typography, 6 | h2.arco-typography, 7 | h3.arco-typography, 8 | h4.arco-typography, 9 | h5.arco-typography, 10 | h6.arco-typography { 11 | margin-top: 0 !important; 12 | } 13 | 14 | .script-list .arco-card-body { 15 | padding: 0 !important; 16 | } 17 | 18 | .max-table-cell .arco-table-cell { 19 | display: block; 20 | max-height: 100px; 21 | overflow: auto; 22 | } 23 | 24 | /* error、wran图标直接用的油猴CodeMirror编辑器图标 待优化*/ 25 | .icon-error { 26 | background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAHlBMVEW7AAC7AACxAAC7AAC7AAAAAAC4AAC5AAD///+7AAAUdclpAAAABnRSTlMXnORSiwCK0ZKSAAAATUlEQVR42mWPOQ7AQAgDuQLx/z8csYRmPRIFIwRGnosRrpamvkKi0FTIiMASR3hhKW+hAN6/tIWhu9PDWiTGNEkTtIOucA5Oyr9ckPgAWm0GPBog6v4AAAAASUVORK5CYII=); 27 | background-repeat: no-repeat; 28 | background-position: center; 29 | left: 10px !important; 30 | } 31 | 32 | .icon-warn { 33 | background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAANlBMVEX/uwDvrwD/uwD/uwD/uwD/uwD/uwD/uwD/uwD6twD/uwAAAADurwD2tQD7uAD+ugAAAAD/uwDhmeTRAAAADHRSTlMJ8mN1EYcbmiixgACm7WbuAAAAVklEQVR42n3PUQqAIBBFUU1LLc3u/jdbOJoW1P08DA9Gba8+YWJ6gNJoNYIBzAA2chBth5kLmG9YUoG0NHAUwFXwO9LuBQL1giCQb8gC9Oro2vp5rncCIY8L8uEx5ZkAAAAASUVORK5CYII=); 34 | background-repeat: no-repeat; 35 | background-position: center; 36 | left: 10px !important; 37 | } 38 | 39 | .actionList { 40 | height: auto !important; 41 | } 42 | 43 | .arco-table-custom-filter { 44 | padding: 10px; 45 | background-color: var(--color-bg-5); 46 | box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.15); 47 | } 48 | 49 | .arco-table-custom-filter span.arco-input-inner-wrapper, 50 | .arco-table-custom-filter input.arco-input { 51 | transition: none; 52 | } 53 | 54 | #script-list .arco-table-th-item, 55 | #script-list .arco-table-col-has-sorter .arco-table-cell-with-sorter, 56 | #script-list .arco-table-td { 57 | padding: 4px 8px; 58 | } 59 | 60 | #script-list .arco-table-col-has-sorter { 61 | padding: 0; 62 | } 63 | 64 | #script-list col:not([style]):not([class]) { 65 | max-width: 240px; 66 | min-width: 100px; 67 | } 68 | -------------------------------------------------------------------------------- /src/locales/README.md: -------------------------------------------------------------------------------- 1 | # i18n Solution 2 | 3 | The i18n implementation uses [i18next](https://www.i18next.com/). We chose this over `chrome.i18n` because the latter 4 | does not support dynamic language switching. However, to meet the requirements of certain extension markets, we still 5 | add `chrome.i18n` language files in the `src/assets/_locales` directory. 6 | 7 | ## Language Files 8 | 9 | Language files are located in the `src/locales` directory and are divided by pages, with each page having a 10 | corresponding language file. These files are ultimately merged and exported through `locales.ts`. 11 | 12 | ## Keyword Conflicts 13 | 14 | If keywords in a page are the same but their translations differ, you can distinguish them using the `page.key` format, 15 | for example: 16 | 17 | ```json 18 | { 19 | "list": { 20 | "confirm_delete": "Are you sure you want to delete? Please note that this is an irreversible operation.", 21 | "confirm_update": "Are you sure you want to update? Please note that this is an irreversible operation." 22 | } 23 | } 24 | ``` 25 | 26 | ### Help Us Translate 27 | 28 | [Crowdin](https://crowdin.com/project/scriptcat) 29 | is an online localization platform that helps us manage translations. If you're interested in helping us translate ScriptCat, you can find the project on Crowdin and start contributing. 30 | 31 | - `src/locales` is the translation file directory for the [extension](https://github.com/scriptscat/scriptcat) 32 | - `public/locales` is the translation file directory for the [script website](https://github.com/scriptscat/scriptlist-frontend) 33 | 34 | # i18n 方案 35 | 36 | i18n 使用[i8next](https://www.i18next.com/)实现,之所以不是用`chrome.i18n`的原因是该方案不支持动态切换语言。但是为了某些扩展市场的要求,我们还是在`src/assets/_locales`目录下添加了`chrome.i18n`的语言文件。 37 | 38 | ## 语言文件 39 | 40 | 语言文件位于`src/locales`目录下,按照页面划分,每个页面对应一个语言文件,最终由`locales.ts`合并进行导出。 41 | 42 | ## 关键字冲突 43 | 44 | 如果页面中的关键字一样,但是翻译不一样,可以使用`page.key`的方式进行区分,例如: 45 | 46 | ```json 47 | { 48 | "list": { 49 | "confirm_delete": "确定要删除吗?请注意这个操作无法恢复!", 50 | "confirm_update": "确定要更新吗?请注意这个操作无法恢复!" 51 | } 52 | } 53 | ``` 54 | 55 | ### 帮助我们翻译 56 | 57 | [Crowdin](https://crowdin.com/project/scriptcat) 58 | 是一个在线的多语言翻译平台。如果您有兴趣帮助我们翻译 ScriptCat 的相关内容,您可以在 Crowdin 上找到 ScriptCat 项目,并开始进行翻译工作。 59 | 60 | - `src/locales`为[扩展](https://github.com/scriptscat/scriptcat)翻译文件目录 61 | - `public/locales`为[脚本站](https://github.com/scriptscat/scriptlist-frontend)的翻译文件目录 62 | -------------------------------------------------------------------------------- /src/pages/components/LogLabel/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Select } from "@arco-design/web-react"; 2 | import { IconClose } from "@arco-design/web-react/icon"; 3 | import React from "react"; 4 | import "./index.css"; 5 | 6 | export type Query = { 7 | key: string; 8 | condition: "=" | "=~" | "!=" | "!~"; 9 | value: string; 10 | }; 11 | 12 | export type Labels = { 13 | [key: string]: { [key: string | number]: boolean }; 14 | }; 15 | 16 | const LogLabel: React.FC<{ 17 | value: Query; 18 | labels: Labels; 19 | onChange: (value: Query) => void; 20 | onClose: () => void; 21 | }> = ({ value, labels, onChange, onClose }) => { 22 | const values = labels[value.key] || {}; 23 | return ( 24 |
25 | 44 | 56 | 75 |
77 | ); 78 | }; 79 | 80 | export default LogLabel; 81 | -------------------------------------------------------------------------------- /src/types/main.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@App/types/scriptcat.d.ts"; 2 | declare module "*.tpl"; 3 | declare module "*.json"; 4 | declare module "*.yaml"; 5 | declare module "@App/app/types.d.ts"; 6 | 7 | declare const sandbox: Window; 8 | 9 | declare const self: ServiceWorkerGlobalScope; 10 | 11 | type FileSystemEventCallback = (records: any[], observer: FileSystemObserverInstance) => void; 12 | 13 | declare const FileSystemObserver: { 14 | new (callback: FileSystemEventCallback): FileSystemObserverInstance; 15 | }; 16 | 17 | interface FileSystemChangeRecord { 18 | root: FileSystemFileHandle | FileSystemDirectoryHandle | FileSystemSyncAccessHandle; 19 | type: string; 20 | changedHandle: FileSystemFileHandle; 21 | } 22 | 23 | interface FileSystemObserverInstance { 24 | disconnect(): void; 25 | observe(handle: FileSystemFileHandle | FileSystemDirectoryHandle | FileSystemSyncAccessHandle): Promise; 26 | } 27 | 28 | declare const MessageFlag: string; 29 | declare const EarlyScriptFlag: string[]; 30 | 31 | // 可以让content与inject环境交换携带dom的对象 32 | declare let cloneInto: ((detail: any, view: any) => any) | undefined; 33 | 34 | declare namespace GMSend { 35 | interface XHRDetails { 36 | method?: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS"; 37 | url: string; 38 | headers?: { [key: string]: string }; 39 | data?: string | Array; 40 | cookie?: string; 41 | /** 42 | * 43 | * @link https://developer.mozilla.org/zh-CN/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning 44 | */ 45 | cookiePartition?: { 46 | topLevelSite?: string; 47 | }; 48 | binary?: boolean; 49 | timeout?: number; 50 | context?: CONTEXT_TYPE; 51 | responseType?: "text" | "arraybuffer" | "blob" | "json" | "document" | "stream"; 52 | overrideMimeType?: string; 53 | anonymous?: boolean; 54 | fetch?: boolean; 55 | user?: string; 56 | password?: string; 57 | nocache?: boolean; 58 | dataType?: "FormData" | "Blob"; 59 | redirect?: "follow" | "error" | "manual"; 60 | } 61 | 62 | interface XHRFormData { 63 | type?: "file" | "text"; 64 | key: string; 65 | val: string; 66 | filename?: string; 67 | } 68 | } 69 | 70 | declare namespace globalThis { 71 | interface Window { 72 | external?: External; 73 | } 74 | interface External { 75 | Tampermonkey?: App.ExternalTampermonkey; 76 | Violentmonkey?: App.ExternalViolentmonkey; 77 | FireMonkey?: App.ExternalFireMonkey; 78 | Scriptcat?: App.ExternalScriptCat; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/pages/store/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Script } from "@App/app/repo/scripts"; 2 | import { extractFavicons } from "@App/pkg/utils/favicon"; 3 | import { store } from "./store"; 4 | import { scriptSlice } from "./features/script"; 5 | import { cacheInstance } from "@App/app/cache"; 6 | import { SystemClient } from "@App/app/service/service_worker/client"; 7 | import { message } from "./global"; 8 | import { CACHE_KEY_FAVICON } from "@App/app/cache_key"; 9 | 10 | // 处理单个脚本的favicon 11 | const processScriptFavicon = async (script: Script) => { 12 | const cacheKey = `${CACHE_KEY_FAVICON}${script.uuid}`; 13 | return { 14 | uuid: script.uuid, 15 | fav: await cacheInstance.getOrSet(cacheKey, async () => { 16 | const icons = await extractFavicons(script.metadata!.match || [], script.metadata!.include || []); 17 | if (icons.length === 0) return []; 18 | 19 | // 从缓存中获取favicon图标 20 | const systemClient = new SystemClient(message); 21 | const newIcons = await Promise.all( 22 | icons.map(async (icon) => { 23 | let iconUrl = ""; 24 | // 没有的话缓存到本地使用URL.createObjectURL 25 | if (icon.icon) { 26 | try { 27 | // 因为需要持久化URL.createObjectURL,所以需要通过调用到offscreen来创建 28 | iconUrl = await systemClient.loadFavicon(icon.icon); 29 | } catch (_) { 30 | // ignored 31 | } 32 | } 33 | return { 34 | match: icon.match, 35 | website: icon.website, 36 | icon: iconUrl, 37 | }; 38 | }) 39 | ); 40 | return newIcons; 41 | }), 42 | }; 43 | }; 44 | 45 | type FavIconResult = { 46 | uuid: string; 47 | fav: { 48 | match: string; 49 | website?: string; 50 | icon?: string; 51 | }[]; 52 | }; 53 | 54 | // 在scriptSlice创建后处理favicon加载,以批次方式处理 55 | export const loadScriptFavicons = (scripts: Script[]) => { 56 | const results: FavIconResult[] = []; 57 | let waiting = false; 58 | for (const script of scripts) { 59 | processScriptFavicon(script).then((result: FavIconResult) => { 60 | results.push(result); 61 | // 下一个 MacroTask 执行。 62 | // 使用 requestAnimationFrame 而非setTimeout 是因为前台才要显示。而且网页绘画中时会延后这个 63 | if (!waiting) { 64 | requestAnimationFrame(() => { 65 | waiting = false; 66 | if (!results.length) return; 67 | const chunkResults: FavIconResult[] = results.slice(0); 68 | results.length = 0; 69 | store.dispatch(scriptSlice.actions.setScriptFavicon(chunkResults)); 70 | }); 71 | waiting = true; 72 | } 73 | }); 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /src/content.ts: -------------------------------------------------------------------------------- 1 | import LoggerCore from "./app/logger/core"; 2 | import MessageWriter from "./app/logger/message_writer"; 3 | import { ExtensionMessage, ExtensionMessageSend } from "@Packages/message/extension_message"; 4 | import { CustomEventMessage } from "@Packages/message/custom_event_message"; 5 | import { RuntimeClient } from "./app/service/service_worker/client"; 6 | import { Server } from "@Packages/message/server"; 7 | import ContentRuntime from "./app/service/content/content"; 8 | import { ScriptExecutor } from "./app/service/content/script_executor"; 9 | import { randomMessageFlag } from "./pkg/utils/utils"; 10 | 11 | declare global { 12 | interface Window { 13 | EarlyScriptFlag?: string[]; 14 | } 15 | } 16 | 17 | if (typeof chrome?.runtime?.onMessage?.addListener !== "function") { 18 | // ScriptCat 未支持 Firefox MV3 19 | console.error("Firefox MV3 UserScripts is not yet supported by ScriptCat"); 20 | } else { 21 | // 建立与service_worker页面的连接 22 | const send = new ExtensionMessageSend(); 23 | 24 | // 处理scriptExecutor 25 | const scriptExecutorFlag = randomMessageFlag(); 26 | const scriptExecutorMsg = new CustomEventMessage(scriptExecutorFlag, true); 27 | const scriptExecutor = new ScriptExecutor(new CustomEventMessage(scriptExecutorFlag, false), []); 28 | // 处理EarlyScript 29 | if (window.EarlyScriptFlag) { 30 | scriptExecutor.setEarlyStartScriptFlag(window.EarlyScriptFlag); 31 | scriptExecutor.checkEarlyStartScript(); 32 | } else { 33 | // 监听属性设置 34 | Object.defineProperty(window, "EarlyScriptFlag", { 35 | configurable: true, 36 | set: (val: string[]) => { 37 | scriptExecutor.setEarlyStartScriptFlag(val); 38 | scriptExecutor.checkEarlyStartScript(); 39 | }, 40 | }); 41 | } 42 | 43 | // 初始化日志组件 44 | const loggerCore = new LoggerCore({ 45 | writer: new MessageWriter(send), 46 | labels: { env: "content" }, 47 | }); 48 | 49 | const client = new RuntimeClient(send); 50 | client.pageLoad().then((data) => { 51 | loggerCore.logger().debug("content start"); 52 | const extMsg = new ExtensionMessage(); 53 | const msg = new CustomEventMessage(data.flag, true); 54 | const server = new Server("content", [msg, scriptExecutorMsg]); 55 | // Opera中没有chrome.runtime.onConnect,并且content也不需要chrome.runtime.onConnect 56 | // 所以不需要处理连接,设置为false 57 | const extServer = new Server("content", extMsg, false); 58 | // scriptExecutor的消息接口 59 | // 初始化运行环境 60 | const runtime = new ContentRuntime(extServer, server, send, msg, scriptExecutorMsg, scriptExecutor); 61 | runtime.init(); 62 | runtime.start(data.scripts, data.envInfo); 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/repo/dao.ts: -------------------------------------------------------------------------------- 1 | import Dexie from "dexie"; 2 | 3 | export const db = new Dexie("ScriptCat"); 4 | 5 | export const ErrSaveError = new Error("数据保存失败"); 6 | 7 | export class Page { 8 | protected Page: number; 9 | 10 | protected Count: number; 11 | 12 | protected Order: string; 13 | 14 | protected Sort: "asc" | "desc"; 15 | 16 | constructor(page: number, count: number, sort?: "asc" | "desc", order?: string) { 17 | this.Page = page; 18 | this.Count = count; 19 | this.Order = order || "id"; 20 | this.Sort = sort || "desc"; 21 | } 22 | 23 | public page() { 24 | return this.Page; 25 | } 26 | 27 | public count() { 28 | return this.Count; 29 | } 30 | 31 | public order() { 32 | return this.Order; 33 | } 34 | 35 | public sort() { 36 | return this.Sort; 37 | } 38 | } 39 | 40 | export abstract class DAO { 41 | public table!: Dexie.Table; 42 | 43 | public tableName = ""; 44 | 45 | public list(query: { [key: string]: any }, page?: Page) { 46 | if (!page) { 47 | return this.table.where(query).toArray(); 48 | } 49 | let collect = this.table 50 | .where(query) 51 | .offset((page.page() - 1) * page.count()) 52 | .limit(page.count()); 53 | if (page.order() !== "id") { 54 | collect.sortBy(page.order()); 55 | } 56 | if (page.sort() === "desc") { 57 | collect = collect.reverse(); 58 | } 59 | return collect.toArray(); 60 | } 61 | 62 | public find() { 63 | return this.table; 64 | } 65 | 66 | public findOne(where: { [key: string]: any }) { 67 | return this.table.where(where).first(); 68 | } 69 | 70 | public async save(val: T) { 71 | const id = (val).id; 72 | if (!id) { 73 | delete (val).id; 74 | return this.table.add(val); 75 | } 76 | const resp = await this.table.update(id, val); 77 | if (resp) { 78 | return id; 79 | } 80 | throw ErrSaveError; 81 | } 82 | 83 | public findById(id: number) { 84 | return this.table.get(id); 85 | } 86 | 87 | public clear() { 88 | return this.table.clear(); 89 | } 90 | 91 | public async delete(id: number | { [key: string]: any }) { 92 | if (typeof id === "number") { 93 | return this.table.where({ id }).delete(); 94 | } 95 | return this.table.where(id).delete(); 96 | } 97 | 98 | public update(id: number, changes: { [key: string]: any }) { 99 | return this.table.update(id, changes); 100 | } 101 | 102 | public count() { 103 | return this.table.count(); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/linter.worker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | const { Linter } = require("eslint-linter-browserify"); 3 | const { rules } = require("eslint-plugin-userscripts"); 4 | 5 | // eslint语法检查,使用webworker 6 | 7 | const linter = new Linter({ configType: "eslintrc" }); 8 | 9 | // 额外定义 userscripts 规则 10 | const formatRules = Object.fromEntries(Object.entries(rules).map(([key, metas]) => ["userscripts/" + key, metas])); 11 | linter.defineRules(formatRules as any); 12 | 13 | const getRules = linter.getRules(); 14 | 15 | const severityMap = { 16 | 2: 8, // 2 for ESLint is error 17 | 1: 4, // 1 for ESLint is warning 18 | }; 19 | 20 | function getTextBlock(text: string, startPosition: number, endPosition: number) { 21 | if (startPosition > endPosition || startPosition < 0 || endPosition > text.length) { 22 | throw new Error("Invalid positions provided"); 23 | } 24 | 25 | let startLineNumber = 1; 26 | let startColumn = 1; 27 | let endLineNumber = 1; 28 | let endColumn = 1; 29 | 30 | for (let i = 0, currentLine = 1, currentColumn = 1; i < text.length; i += 1) { 31 | if (i === startPosition) { 32 | startLineNumber = currentLine; 33 | startColumn = currentColumn; 34 | } 35 | 36 | if (i === endPosition) { 37 | endLineNumber = currentLine; 38 | endColumn = currentColumn; 39 | break; 40 | } 41 | 42 | if (text[i] === "\n") { 43 | currentLine += 1; 44 | currentColumn = 0; 45 | } 46 | 47 | currentColumn += 1; 48 | } 49 | 50 | return { 51 | startLineNumber, 52 | startColumn, 53 | endLineNumber, 54 | endColumn, 55 | }; 56 | } 57 | 58 | self.addEventListener("message", (event) => { 59 | const { code, id, config } = event.data; 60 | const errs = linter.verify(code, config); 61 | const markers = errs.map((err: any) => { 62 | const rule = getRules.get(err.ruleId); 63 | const target = rule?.meta?.docs?.url ?? ""; 64 | 65 | let fix: any; 66 | if (err.fix) { 67 | fix = { 68 | range: getTextBlock(code, err.fix.range[0], err.fix.range[1]), 69 | text: err.fix.text, 70 | }; 71 | } 72 | return { 73 | code: { 74 | value: err.ruleId || "", 75 | target, 76 | }, 77 | startLineNumber: err.line, 78 | endLineNumber: err.endLine || err.line, 79 | startColumn: err.column, 80 | endColumn: err.endColumn || err.column, 81 | message: err.message, 82 | // 设置错误的等级,此处ESLint与monaco的存在差异,做一层映射 83 | // @ts-ignore 84 | severity: severityMap[err.severity], 85 | source: "ESLint", 86 | fix, 87 | }; 88 | }); 89 | // 发回主进程 90 | self.postMessage({ markers, id }); 91 | }); 92 | -------------------------------------------------------------------------------- /packages/cloudscript/cloudscript.ts: -------------------------------------------------------------------------------- 1 | import type { Script } from "@App/app/repo/scripts"; 2 | import type { Value } from "@App/app/repo/value"; 3 | import { valueClient } from "@App/pages/store/features/script"; 4 | 5 | export type ExportCookies = { 6 | [key: string]: any; 7 | domain?: string; 8 | url?: string; 9 | cookies?: chrome.cookies.Cookie[]; 10 | }; 11 | 12 | export type ExportParams = { 13 | [key: string]: any; 14 | exportValue: string; 15 | exportCookie: string; 16 | overwriteValue: boolean; 17 | overwriteCookie: boolean; 18 | }; 19 | 20 | export default interface CloudScript { 21 | exportCloud(script: Script, code: string, values: Value[], cookies: ExportCookies[]): Promise; 22 | } 23 | 24 | function getCookies(detail: chrome.cookies.GetAllDetails): Promise { 25 | return new Promise((resolve) => { 26 | chrome.cookies.getAll(detail, (cookies) => { 27 | const lastError = chrome.runtime.lastError; 28 | if (lastError) { 29 | console.error("chrome.runtime.lastError in chrome.cookies.getAll:", lastError); 30 | // 无视错误继续执行 31 | } 32 | resolve(cookies); 33 | }); 34 | }); 35 | } 36 | 37 | // 解析导出cookie表达式生成导出的cookie 38 | export function parseExportCookie(exportCookie: string): Promise { 39 | const lines = exportCookie.split("\n"); 40 | const result = []; 41 | for (let i = 0; i < lines.length; i += 1) { 42 | const line = lines[i]; 43 | const detail: ExportCookies = {}; 44 | if (line.trim()) { 45 | for (const param of line.split(";")) { 46 | const s = param.split("="); 47 | if (s.length !== 2) { 48 | continue; 49 | } 50 | detail[s[0].trim()] = s[1].trim(); 51 | } 52 | if (detail.url || detail.domain) { 53 | result.push( 54 | new Promise((resolve) => { 55 | getCookies(detail).then((cookies) => { 56 | detail.cookies = cookies; 57 | resolve(detail); 58 | }); 59 | }) 60 | ); 61 | } 62 | } 63 | } 64 | return Promise.all(result); 65 | } 66 | 67 | // 解析value表达式生成导出的value 68 | export async function parseExportValue(script: Script, exportValue: string): Promise { 69 | const lines = exportValue.split("\n"); 70 | const result = []; 71 | const values = await valueClient.getScriptValue(script); 72 | for (let i = 0; i < lines.length; i += 1) { 73 | const line = lines[i]; 74 | if (line.trim()) { 75 | const s = line.split(","); 76 | for (let n = 0; n < s.length; n += 1) { 77 | const key = s[n].trim(); 78 | if (key && values[key]) { 79 | result.push(values[key]); 80 | } 81 | } 82 | } 83 | } 84 | return result; 85 | } 86 | -------------------------------------------------------------------------------- /src/pkg/utils/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, it } from "vitest"; 2 | import { checkSilenceUpdate } from "./utils"; 3 | import { ltever } from "@App/pkg/utils/semver"; 4 | import { nextTime } from "./cron"; 5 | import dayjs from "dayjs"; 6 | 7 | describe("nextTime", () => { 8 | const date = new Date(1737275107000); 9 | test("每分钟表达式", () => { 10 | expect(nextTime("* * * * *", date)).toEqual(dayjs(date).add(1, "minute").format("YYYY-MM-DD HH:mm:00")); 11 | }); 12 | test("每分钟一次表达式", () => { 13 | expect(nextTime("once * * * *", date)).toEqual( 14 | dayjs(date).add(1, "minute").format("YYYY-MM-DD HH:mm 每分钟运行一次") 15 | ); 16 | }); 17 | test("每小时一次表达式", () => { 18 | expect(nextTime("* once * * *", date)).toEqual(dayjs(date).add(1, "hour").format("YYYY-MM-DD HH 每小时运行一次")); 19 | }); 20 | test("每天一次表达式", () => { 21 | expect(nextTime("* * once * *", date)).toEqual(dayjs(date).add(1, "day").format("YYYY-MM-DD 每天运行一次")); 22 | }); 23 | test("每月一次表达式", () => { 24 | expect(nextTime("* * * once *", date)).toEqual(dayjs(date).add(1, "month").format("YYYY-MM 每月运行一次")); 25 | }); 26 | test("每星期一次表达式", () => { 27 | expect(nextTime("* * * * once", date)).toEqual(dayjs(date).add(1, "week").format("YYYY-MM-DD 每星期运行一次")); 28 | }); 29 | }); 30 | 31 | describe("ltever", () => { 32 | it("semver", () => { 33 | expect(ltever("1.0.0", "1.0.1")).toBe(true); 34 | expect(ltever("1.0.0", "1.0.0")).toBe(true); 35 | expect(ltever("1.0.1", "1.0.0")).toBe(false); 36 | }); 37 | it("any", () => { 38 | expect(ltever("1.2.3.4", "1.2.3.4")).toBe(true); 39 | expect(ltever("1.2.3.4", "1.2.3.5")).toBe(true); 40 | expect(ltever("1.2.3.4", "1.2.3.3")).toBe(false); 41 | }); 42 | }); 43 | 44 | describe("checkSilenceUpdate", () => { 45 | it("true", () => { 46 | expect( 47 | checkSilenceUpdate( 48 | { 49 | connect: ["www.baidu.com"], 50 | }, 51 | { 52 | connect: ["www.baidu.com"], 53 | } 54 | ) 55 | ).toBe(true); 56 | expect( 57 | checkSilenceUpdate( 58 | { 59 | connect: ["www.baidu.com", "scriptcat.org"], 60 | }, 61 | { 62 | connect: ["scriptcat.org"], 63 | } 64 | ) 65 | ).toBe(true); 66 | }); 67 | it("false", () => { 68 | expect( 69 | checkSilenceUpdate( 70 | { 71 | connect: ["www.baidu.com"], 72 | }, 73 | { 74 | connect: ["www.google.com"], 75 | } 76 | ) 77 | ).toBe(false); 78 | expect( 79 | checkSilenceUpdate( 80 | { 81 | connect: ["www.baidu.com"], 82 | }, 83 | { 84 | connect: ["www.baidu.com", "scriptcat.org"], 85 | } 86 | ) 87 | ).toBe(false); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /packages/filesystem/factory.ts: -------------------------------------------------------------------------------- 1 | import BaiduFileSystem from "./baidu/baidu"; 2 | import type FileSystem from "./filesystem"; 3 | import GoogleDriveFileSystem from "./googledrive/googledrive"; 4 | import OneDriveFileSystem from "./onedrive/onedrive"; 5 | import DropboxFileSystem from "./dropbox/dropbox"; 6 | import WebDAVFileSystem from "./webdav/webdav"; 7 | import ZipFileSystem from "./zip/zip"; 8 | import { t } from "@App/locales/locales"; 9 | 10 | export type FileSystemType = "zip" | "webdav" | "baidu-netdsik" | "onedrive" | "googledrive" | "dropbox"; 11 | 12 | export type FileSystemParams = { 13 | [key: string]: { 14 | title: string; 15 | type?: "select" | "authorize" | "password"; 16 | options?: string[]; 17 | }; 18 | }; 19 | 20 | export default class FileSystemFactory { 21 | static create(type: FileSystemType, params: any): Promise { 22 | let fs: FileSystem; 23 | switch (type) { 24 | case "zip": 25 | fs = new ZipFileSystem(params); 26 | break; 27 | case "webdav": 28 | fs = new WebDAVFileSystem(params.authType, params.url, params.username, params.password); 29 | break; 30 | case "baidu-netdsik": 31 | fs = new BaiduFileSystem(); 32 | break; 33 | case "onedrive": 34 | fs = new OneDriveFileSystem(); 35 | break; 36 | case "googledrive": 37 | fs = new GoogleDriveFileSystem(); 38 | break; 39 | case "dropbox": 40 | fs = new DropboxFileSystem(); 41 | break; 42 | default: 43 | throw new Error("not found filesystem"); 44 | } 45 | return fs.verify().then(() => fs); 46 | } 47 | 48 | static params(): { [key: string]: FileSystemParams } { 49 | return { 50 | webdav: { 51 | authType: { 52 | title: t("auth_type"), 53 | type: "select", 54 | options: ["password", "digest", "none", "token"], 55 | }, 56 | url: { title: t("url") }, 57 | username: { title: t("username") }, 58 | password: { title: t("password"), type: "password" }, 59 | }, 60 | "baidu-netdsik": {}, 61 | onedrive: {}, 62 | googledrive: {}, 63 | dropbox: {}, 64 | }; 65 | } 66 | 67 | static async mkdirAll(fs: FileSystem, path: string) { 68 | return new Promise((resolve, reject) => { 69 | const dirs = path.split("/"); 70 | let i = 0; 71 | const mkdir = () => { 72 | if (i >= dirs.length) { 73 | resolve(); 74 | return; 75 | } 76 | const dir = dirs.slice(0, i + 1).join("/"); 77 | fs.createDir(dir) 78 | .then(() => { 79 | i += 1; 80 | mkdir(); 81 | }) 82 | .catch(() => { 83 | reject(); 84 | }); 85 | }; 86 | mkdir(); 87 | }); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/app/service/content/exec_warp.ts: -------------------------------------------------------------------------------- 1 | import ExecScript from "./exec_script"; 2 | import type { Message } from "@Packages/message/types"; 3 | import type { ScriptLoadInfo } from "../service_worker/types"; 4 | import type { GMInfoEnv } from "./types"; 5 | 6 | export class CATRetryError { 7 | msg: string; 8 | 9 | time: Date; 10 | 11 | constructor(msg: string, time: number | Date) { 12 | this.msg = msg; 13 | if (typeof time === "number") { 14 | this.time = new Date(Date.now() + time * 1000); 15 | } else { 16 | this.time = time; 17 | } 18 | } 19 | } 20 | 21 | export class BgExecScriptWarp extends ExecScript { 22 | setTimeout: Map; 23 | 24 | setInterval: Map; 25 | 26 | constructor(scriptRes: ScriptLoadInfo, message: Message) { 27 | const thisContext: { [key: string]: any } = {}; 28 | const setTimeout = new Map(); 29 | const setInterval = new Map(); 30 | thisContext.setTimeout = function (handler: () => void, timeout: number | undefined, ...args: any) { 31 | const t = global.setTimeout( 32 | function () { 33 | setTimeout.delete(t); 34 | if (typeof handler === "function") { 35 | handler(); 36 | } 37 | }, 38 | timeout, 39 | ...args 40 | ); 41 | setTimeout.set(t, true); 42 | return t; 43 | }; 44 | thisContext.clearTimeout = function (t: number) { 45 | setTimeout.delete(t); 46 | global.clearTimeout(t); 47 | }; 48 | thisContext.setInterval = function (handler: () => void, timeout: number | undefined, ...args: any) { 49 | const t = global.setInterval( 50 | function () { 51 | if (typeof handler === "function") { 52 | handler(); 53 | } 54 | }, 55 | timeout, 56 | ...args 57 | ); 58 | setInterval.set(t, true); 59 | return t; 60 | }; 61 | thisContext.clearInterval = function (t: number) { 62 | setInterval.delete(t); 63 | global.clearInterval(t); 64 | }; 65 | // @ts-ignore 66 | thisContext.CATRetryError = CATRetryError; 67 | const envInfo: GMInfoEnv = { 68 | sandboxMode: "raw", 69 | userAgentData: { 70 | brands: [], 71 | mobile: false, 72 | platform: "", 73 | }, 74 | isIncognito: false, 75 | }; 76 | super(scriptRes, "offscreen", message, scriptRes.code, envInfo, thisContext); 77 | this.setTimeout = setTimeout; 78 | this.setInterval = setInterval; 79 | } 80 | 81 | stop() { 82 | this.setTimeout.forEach((_, t) => { 83 | global.clearTimeout(t); 84 | }); 85 | this.setTimeout.clear(); 86 | this.setInterval.forEach((_, t) => { 87 | global.clearInterval(t); 88 | }); 89 | this.setInterval.clear(); 90 | return super.stop(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /example/userconfig.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name userconfig 3 | // @namespace https://bbs.tampermonkey.net.cn/ 4 | // @version 0.1.0 5 | // @description 会在页面上显示用户配置,可以可视化的进行配置 6 | // @author You 7 | // @background 8 | // @grant GM_getValue 9 | // @grant CAT_userConfig 10 | // ==/UserScript== 11 | 12 | /* ==UserConfig== 13 | group1: 14 | configA: # 键值为group.config,例如本键为:group1.configA 15 | title: 配置A # 配置的标题 16 | description: 这是一个文本类型的配置 # 配置的描述内容 17 | type: text # 选项类型,如果不填写会根据数据自动识别 18 | default: 默认值 # 配置的默认值 19 | min: 2 # 文本最短2个字符 20 | max: 18 # 文本最长18个字符 21 | password: true # 设置为密码 22 | configB: 23 | title: 配置B 24 | description: 这是一个选择框的配置 25 | type: checkbox 26 | default: true 27 | configC: 28 | title: 配置C 29 | description: 这是一个列表选择的配置 30 | type: select 31 | default: 1 32 | values: [1,2,3,4,5] 33 | configD: 34 | title: 配置D 35 | description: 这是一个动态列表选择的配置 36 | type: select 37 | bind: $cookies # 动态显示绑定的values,值是以$开头的key,value需要是一个数组 38 | configE: 39 | title: 配置E 40 | description: 这是一个多选列表的配置 41 | type: mult-select 42 | default: [1] 43 | values: [1,2,3,4,5] 44 | configF: 45 | title: 配置F 46 | description: 这是一个动态多选列表的配置 47 | type: mult-select 48 | bind: $cookies 49 | configG: 50 | title: 配置G 51 | description: 这是一个数字的配置 52 | type: number 53 | default: 11 54 | min: 10 # 最小值 55 | max: 16 # 最大值 56 | unit: 分 # 表示单位 57 | configH: 58 | title: 配置H 59 | description: 这是一个长文本类型的配置 60 | type: textarea 61 | default: 默认值 62 | rows: 6 63 | configI: 64 | title: 开关 65 | description: 这是一个开关类型的配置 66 | type: switch 67 | default: true 68 | --- 69 | group2: 70 | configX: 71 | title: 配置A 72 | description: 这是一个文本类型的配置 73 | default: 默认值 74 | ==/UserConfig== */ 75 | 76 | // 通过GM_info新方法获取UserConfig对象 77 | const rawUserConfig = GM_info.userConfig; 78 | // 定义一个对象暂存读取到的UserConfig值 79 | const userConfig = {}; 80 | // 解构遍历读取UserConfig并赋缺省值 81 | for (const [mainKey, configs] of Object.entries(rawUserConfig)) { 82 | for (const [subKey, { default: defaultValue }] of Object.entries(configs)) { 83 | userConfig[`${mainKey}.${subKey}`] = GM_getValue(`${mainKey}.${subKey}`, defaultValue); 84 | } 85 | } 86 | 87 | setInterval(() => { 88 | // 传统方法读取UserConfig,每个缺省值需要单独静态声明,修改UserConfig缺省值后代码也需要手动修改 89 | console.log(GM_getValue("group1.configA", "默认值")); 90 | console.log(GM_getValue("group1.configG", 11)); 91 | // GM_info新方法读取UserConfig,可直接关联读取缺省值,无需额外修改 92 | console.log(userConfig["group1.configA"]); 93 | console.log(userConfig["group1.configG"]); 94 | }, 5000) 95 | 96 | // 打开用户配置 97 | CAT_userConfig(); -------------------------------------------------------------------------------- /packages/filesystem/webdav/webdav.ts: -------------------------------------------------------------------------------- 1 | import type { AuthType, FileStat, WebDAVClient } from "webdav"; 2 | import { createClient } from "webdav"; 3 | import type FileSystem from "../filesystem"; 4 | import type { File, FileReader, FileWriter } from "../filesystem"; 5 | import { joinPath } from "../utils"; 6 | import { WebDAVFileReader, WebDAVFileWriter } from "./rw"; 7 | import { WarpTokenError } from "../error"; 8 | 9 | export default class WebDAVFileSystem implements FileSystem { 10 | client: WebDAVClient; 11 | 12 | url: string; 13 | 14 | basePath: string = "/"; 15 | 16 | constructor(authType: AuthType | WebDAVClient, url?: string, username?: string, password?: string) { 17 | if (typeof authType === "object") { 18 | this.client = authType; 19 | this.basePath = joinPath(url || ""); 20 | this.url = username!; 21 | } else { 22 | this.url = url!; 23 | this.client = createClient(url!, { 24 | authType, 25 | username, 26 | password, 27 | }); 28 | } 29 | } 30 | 31 | async verify(): Promise { 32 | try { 33 | await this.client.getQuota(); 34 | } catch (e: any) { 35 | if (e.response && e.response.status === 401) { 36 | throw new WarpTokenError(e); 37 | } 38 | throw new Error("verify failed"); 39 | } 40 | } 41 | 42 | async open(file: File): Promise { 43 | return new WebDAVFileReader(this.client, joinPath(file.path, file.name)); 44 | } 45 | 46 | async openDir(path: string): Promise { 47 | return new WebDAVFileSystem(this.client, joinPath(this.basePath, path), this.url); 48 | } 49 | 50 | async create(path: string): Promise { 51 | return new WebDAVFileWriter(this.client, joinPath(this.basePath, path)); 52 | } 53 | 54 | async createDir(path: string): Promise { 55 | try { 56 | await this.client.createDirectory(joinPath(this.basePath, path)); 57 | } catch (e: any) { 58 | // 如果是405错误,则忽略 59 | if (e.message.includes("405")) { 60 | return; 61 | } 62 | throw e; 63 | } 64 | } 65 | 66 | async delete(path: string): Promise { 67 | return this.client.deleteFile(joinPath(this.basePath, path)); 68 | } 69 | 70 | async list(): Promise { 71 | const dir = (await this.client.getDirectoryContents(this.basePath)) as FileStat[]; 72 | const ret: File[] = []; 73 | for (const item of dir) { 74 | if (item.type !== "file") { 75 | continue; 76 | } 77 | const time = new Date(item.lastmod).getTime(); 78 | ret.push({ 79 | name: item.basename, 80 | path: this.basePath, 81 | digest: item.etag || "", 82 | size: item.size, 83 | createtime: time, 84 | updatetime: time, 85 | }); 86 | } 87 | return ret; 88 | } 89 | 90 | async getDirUrl(): Promise { 91 | return this.url + this.basePath; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /packages/filesystem/onedrive/rw.ts: -------------------------------------------------------------------------------- 1 | import { calculateMd5, md5OfText } from "@App/pkg/utils/crypto"; 2 | import type { File, FileReader, FileWriter } from "../filesystem"; 3 | import { joinPath } from "../utils"; 4 | import type OneDriveFileSystem from "./onedrive"; 5 | 6 | export class OneDriveFileReader implements FileReader { 7 | file: File; 8 | 9 | fs: OneDriveFileSystem; 10 | 11 | constructor(fs: OneDriveFileSystem, file: File) { 12 | this.fs = fs; 13 | this.file = file; 14 | } 15 | 16 | async read(type?: "string" | "blob"): Promise { 17 | const data = await this.fs.request( 18 | `https://graph.microsoft.com/v1.0/me/drive/special/approot:${joinPath(this.file.path, this.file.name)}:/content`, 19 | {}, 20 | true 21 | ); 22 | if (data.status !== 200) { 23 | throw new Error(await data.text()); 24 | } 25 | switch (type) { 26 | case "string": 27 | return data.text(); 28 | default: { 29 | return data.blob(); 30 | } 31 | } 32 | } 33 | } 34 | 35 | export class OneDriveFileWriter implements FileWriter { 36 | path: string; 37 | 38 | fs: OneDriveFileSystem; 39 | 40 | constructor(fs: OneDriveFileSystem, path: string) { 41 | this.fs = fs; 42 | this.path = path; 43 | } 44 | 45 | size(content: string | Blob) { 46 | if (content instanceof Blob) { 47 | return content.size; 48 | } 49 | return new Blob([content]).size; 50 | } 51 | 52 | async md5(content: string | Blob) { 53 | if (content instanceof Blob) { 54 | return calculateMd5(content); 55 | } 56 | return md5OfText(content); 57 | } 58 | 59 | async write(content: string | Blob): Promise { 60 | // 预上传获取id 61 | const size = this.size(content).toString(); 62 | let myHeaders = new Headers(); 63 | myHeaders.append("Content-Type", "application/json"); 64 | const uploadUrl = await this.fs 65 | .request(`https://graph.microsoft.com/v1.0/me/drive/special/approot:${this.path}:/createUploadSession`, { 66 | method: "POST", 67 | headers: myHeaders, 68 | body: JSON.stringify({ 69 | item: { 70 | "@microsoft.graph.conflictBehavior": "replace", 71 | // description: "description", 72 | // fileSystemInfo: { 73 | // "@odata.type": "microsoft.graph.fileSystemInfo", 74 | // }, 75 | // name: this.path.substring(this.path.lastIndexOf("/") + 1), 76 | }, 77 | }), 78 | }) 79 | .then((data) => { 80 | if (data.error) { 81 | throw new Error(JSON.stringify(data)); 82 | } 83 | return data.uploadUrl; 84 | }); 85 | myHeaders = new Headers(); 86 | myHeaders.append("Content-Range", `bytes 0-${parseInt(size, 10) - 1}/${size}`); 87 | return this.fs.request(uploadUrl, { 88 | method: "PUT", 89 | body: content, 90 | headers: myHeaders, 91 | }); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/app/service/offscreen/script.ts: -------------------------------------------------------------------------------- 1 | import LoggerCore from "@App/app/logger/core"; 2 | import type Logger from "@App/app/logger/logger"; 3 | import { type MessageQueue } from "@Packages/message/message_queue"; 4 | import { type WindowMessage } from "@Packages/message/window_message"; 5 | import { ResourceClient, ScriptClient, ValueClient } from "../service_worker/client"; 6 | import type { ScriptRunResource } from "@App/app/repo/scripts"; 7 | import { SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; 8 | import { disableScript, enableScript, runScript, stopScript } from "../sandbox/client"; 9 | import { type Group } from "@Packages/message/server"; 10 | import type { MessageSend } from "@Packages/message/types"; 11 | import type { TDeleteScript, TInstallScript, TEnableScript } from "../queue"; 12 | 13 | export class ScriptService { 14 | logger: Logger; 15 | 16 | scriptClient: ScriptClient = new ScriptClient(this.extensionMessage); 17 | resourceClient: ResourceClient = new ResourceClient(this.extensionMessage); 18 | valueClient: ValueClient = new ValueClient(this.extensionMessage); 19 | 20 | constructor( 21 | private group: Group, 22 | private extensionMessage: MessageSend, 23 | private windowMessage: WindowMessage, 24 | private messageQueue: MessageQueue 25 | ) { 26 | this.logger = LoggerCore.logger().with({ service: "script" }); 27 | } 28 | 29 | runScript(script: ScriptRunResource) { 30 | runScript(this.windowMessage, script); 31 | } 32 | 33 | stopScript(uuid: string) { 34 | stopScript(this.windowMessage, uuid); 35 | } 36 | 37 | async init() { 38 | this.messageQueue.subscribe("enableScript", async (data) => { 39 | const script = await this.scriptClient.info(data.uuid); 40 | if (script.type === SCRIPT_TYPE_NORMAL) { 41 | return; 42 | } 43 | if (data.enable) { 44 | // 构造脚本运行资源,发送给沙盒运行 45 | enableScript(this.windowMessage, await this.scriptClient.getScriptRunResource(script)); 46 | } else { 47 | // 发送给沙盒停止 48 | disableScript(this.windowMessage, script.uuid); 49 | } 50 | }); 51 | this.messageQueue.subscribe("installScript", async (data) => { 52 | // 普通脚本不处理 53 | if (data.script.type === SCRIPT_TYPE_NORMAL) { 54 | return; 55 | } 56 | // 判断是开启还是关闭 57 | if (data.script.status === SCRIPT_STATUS_ENABLE) { 58 | // 构造脚本运行资源,发送给沙盒运行 59 | enableScript(this.windowMessage, await this.scriptClient.getScriptRunResource(data.script)); 60 | } else { 61 | // 发送给沙盒停止 62 | disableScript(this.windowMessage, data.script.uuid); 63 | } 64 | }); 65 | this.messageQueue.subscribe("deleteScript", async (data) => { 66 | disableScript(this.windowMessage, data.uuid); 67 | }); 68 | 69 | this.group.on("runScript", this.runScript.bind(this)); 70 | this.group.on("stopScript", this.stopScript.bind(this)); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | import LoggerCore, { EmptyWriter } from "@App/app/logger/core"; 2 | import { MockMessage } from "@Packages/message/mock_message"; 3 | import { Server } from "@Packages/message/server"; 4 | import type { Message } from "@Packages/message/types"; 5 | import { ValueService } from "@App/app/service/service_worker/value"; 6 | import GMApi, { MockGMExternalDependencies } from "@App/app/service/service_worker/gm_api"; 7 | import OffscreenGMApi from "@App/app/service/offscreen/gm_api"; 8 | import EventEmitter from "eventemitter3"; 9 | import "@Packages/chrome-extension-mock"; 10 | import { MessageQueue } from "@Packages/message/message_queue"; 11 | import { SystemConfig } from "@App/pkg/config/config"; 12 | import PermissionVerify from "@App/app/service/service_worker/permission_verify"; 13 | 14 | export function initTestEnv() { 15 | // @ts-ignore 16 | if (global.initTest) { 17 | return; 18 | } 19 | // @ts-ignore 20 | global.initTest = true; 21 | 22 | const OldBlob = Blob; 23 | // @ts-ignore 24 | global.Blob = function Blob(data, options) { 25 | const blob = new OldBlob(data, options); 26 | blob.text = () => Promise.resolve(data[0]); 27 | blob.arrayBuffer = () => { 28 | return new Promise((resolve) => { 29 | const str = data[0]; 30 | const buf = new ArrayBuffer(str.length * 2); // 每个字符占用2个字节 31 | const bufView = new Uint16Array(buf); 32 | for (let i = 0, strLen = str.length; i < strLen; i += 1) { 33 | bufView[i] = str.charCodeAt(i); 34 | } 35 | resolve(buf); 36 | }); 37 | }; 38 | return blob; 39 | }; 40 | 41 | const logger = new LoggerCore({ 42 | level: "trace", 43 | consoleLevel: "trace", 44 | writer: new EmptyWriter(), 45 | labels: { env: "test" }, 46 | }); 47 | logger.logger().debug("test start"); 48 | } 49 | 50 | export function initTestGMApi(): Message { 51 | const wsEE = new EventEmitter(); 52 | const wsMessage = new MockMessage(wsEE); 53 | const osEE = new EventEmitter(); 54 | const osMessage = new MockMessage(osEE); 55 | const messageQueue = new MessageQueue(); 56 | const systemConfig = new SystemConfig(messageQueue); 57 | 58 | const serviceWorkerServer = new Server("serviceWorker", wsMessage); 59 | const valueService = new ValueService(serviceWorkerServer.group("value"), messageQueue); 60 | const permissionVerify = new PermissionVerify(serviceWorkerServer.group("permissionVerify"), messageQueue); 61 | const swGMApi = new GMApi( 62 | systemConfig, 63 | permissionVerify, 64 | serviceWorkerServer.group("runtime"), 65 | osMessage, 66 | messageQueue, 67 | valueService, 68 | new MockGMExternalDependencies() 69 | ); 70 | 71 | swGMApi.start(); 72 | 73 | // offscreen 74 | const offscreenServer = new Server("offscreen", osMessage); 75 | const osGMApi = new OffscreenGMApi(offscreenServer.group("gmApi")); 76 | osGMApi.init(); 77 | 78 | return wsMessage; 79 | } 80 | -------------------------------------------------------------------------------- /src/app/service/service_worker/types.ts: -------------------------------------------------------------------------------- 1 | import type { Script, ScriptRunResource, SCRIPT_RUN_STATUS, SCMetadata, UserConfig } from "@App/app/repo/scripts"; 2 | import { type URLRuleEntry } from "@App/pkg/utils/url_matcher"; 3 | import { type GetSender } from "@Packages/message/server"; 4 | 5 | export type InstallSource = "user" | "system" | "sync" | "subscribe" | "vscode"; 6 | export type SearchType = "auto" | "name" | "script_code"; 7 | 8 | export interface ScriptMatchInfo extends ScriptRunResource { 9 | scriptUrlPatterns: URLRuleEntry[]; // 已被自定义覆盖的 UrlPatterns 10 | originalUrlPatterns: URLRuleEntry[]; // 脚本原本的 UrlPatterns 11 | } 12 | 13 | export interface ScriptLoadInfo extends ScriptRunResource { 14 | metadataStr: string; // 脚本元数据字符串 15 | userConfigStr: string; // 用户配置字符串 16 | userConfig?: UserConfig; 17 | } 18 | 19 | // 为了优化性能,存储到缓存时删除了code、value与resource 20 | export type TScriptMatchInfoEntry = { 21 | code: ""; 22 | value: Record; 23 | resource: Record; 24 | sort?: number; 25 | } & Omit; 26 | 27 | export interface EmitEventRequest { 28 | uuid: string; 29 | event: string; 30 | eventId: string; 31 | data?: any; 32 | } 33 | 34 | // GMApi,处理脚本的GM API调用请求 35 | 36 | export type MessageRequest = { 37 | uuid: string; // 脚本id 38 | api: string; 39 | runFlag: string; 40 | params: any[]; 41 | }; 42 | 43 | export type Request = MessageRequest & { 44 | script: Script; 45 | }; 46 | 47 | export type NotificationMessageOption = { 48 | event: "click" | "buttonClick" | "close"; 49 | params: { 50 | /** 51 | * event为buttonClick时存在该值 52 | * 53 | * buttonClick的index 54 | */ 55 | index?: number; 56 | /** 57 | * 是否是用户点击 58 | */ 59 | byUser?: boolean; 60 | }; 61 | }; 62 | 63 | export type Api = (request: Request, con: GetSender) => Promise; 64 | 65 | // popup 66 | 67 | export type ScriptMenuItem = { 68 | id: number; 69 | name: string; 70 | options?: { 71 | id?: number; 72 | autoClose?: boolean; 73 | title?: string; 74 | accessKey?: string; 75 | // 可选输入框 76 | inputType?: "text" | "number" | "boolean"; 77 | inputLabel?: string; 78 | inputDefaultValue?: string | number | boolean; 79 | inputPlaceholder?: string; 80 | }; 81 | tabId: number; //-1表示后台脚本 82 | frameId?: number; 83 | documentId?: string; 84 | }; 85 | 86 | export type ScriptMenu = { 87 | uuid: string; // 脚本uuid 88 | name: string; // 脚本名称 89 | storageName: string; // 脚本存储名称 90 | enable: boolean; // 脚本是否启用 91 | updatetime: number; // 脚本更新时间 92 | hasUserConfig: boolean; // 是否有用户配置 93 | metadata: SCMetadata; // 脚本元数据 94 | runStatus?: SCRIPT_RUN_STATUS; // 脚本运行状态 95 | runNum: number; // 脚本运行次数 96 | runNumByIframe: number; // iframe运行次数 97 | menus: ScriptMenuItem[]; // 脚本菜单 98 | isEffective: boolean | null; // 是否在当前网址啟动 99 | }; 100 | -------------------------------------------------------------------------------- /src/app/service/offscreen/index.ts: -------------------------------------------------------------------------------- 1 | import { forwardMessage, Server } from "@Packages/message/server"; 2 | import type { MessageSend } from "@Packages/message/types"; 3 | import { ScriptService } from "./script"; 4 | import { type Logger } from "@App/app/repo/logger"; 5 | import { WindowMessage } from "@Packages/message/window_message"; 6 | import { ServiceWorkerClient } from "../service_worker/client"; 7 | import { sendMessage } from "@Packages/message/client"; 8 | import GMApi from "./gm_api"; 9 | import { MessageQueue } from "@Packages/message/message_queue"; 10 | import { VSCodeConnect } from "./vscode-connect"; 11 | 12 | // offscreen环境的管理器 13 | export class OffscreenManager { 14 | private windowMessage = new WindowMessage(window, sandbox, true); 15 | 16 | private windowServer: Server = new Server("offscreen", this.windowMessage); 17 | 18 | private messageQueue: MessageQueue = new MessageQueue(); 19 | 20 | private serviceWorker = new ServiceWorkerClient(this.extensionMessage); 21 | 22 | constructor(private extensionMessage: MessageSend) {} 23 | 24 | logger(data: Logger) { 25 | // 发送日志消息 26 | this.sendMessageToServiceWorker({ 27 | action: "logger", 28 | data, 29 | }); 30 | } 31 | 32 | preparationSandbox() { 33 | // 通知初始化好环境了 34 | this.serviceWorker.preparationOffscreen(); 35 | } 36 | 37 | sendMessageToServiceWorker(data: { action: string; data: any }) { 38 | return sendMessage(this.extensionMessage, `serviceWorker/${data.action}`, data.data); 39 | } 40 | 41 | async initManager() { 42 | // 监听消息 43 | this.windowServer.on("logger", this.logger.bind(this)); 44 | this.windowServer.on("preparationSandbox", this.preparationSandbox.bind(this)); 45 | this.windowServer.on("sendMessageToServiceWorker", this.sendMessageToServiceWorker.bind(this)); 46 | const script = new ScriptService( 47 | this.windowServer.group("script"), 48 | this.extensionMessage, 49 | this.windowMessage, 50 | this.messageQueue 51 | ); 52 | script.init(); 53 | // 转发从sandbox来的gm api请求 54 | forwardMessage("serviceWorker", "runtime/gmApi", this.windowServer, this.extensionMessage); 55 | // 转发valueUpdate与emitEvent 56 | forwardMessage("sandbox", "runtime/valueUpdate", this.windowServer, this.windowMessage); 57 | forwardMessage("sandbox", "runtime/emitEvent", this.windowServer, this.windowMessage); 58 | 59 | const gmApi = new GMApi(this.windowServer.group("gmApi")); 60 | gmApi.init(); 61 | const vscodeConnect = new VSCodeConnect(this.windowServer.group("vscodeConnect"), this.extensionMessage); 62 | vscodeConnect.init(); 63 | 64 | this.windowServer.on("createObjectURL", async (params: { data: Blob; persistence: boolean }) => { 65 | const url = URL.createObjectURL(params.data); 66 | if (!params.persistence) { 67 | // 如果不是持久化的,则在1分钟后释放 68 | setTimeout(() => { 69 | URL.revokeObjectURL(url); 70 | }, 1000 * 60); 71 | } 72 | return url; 73 | }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/pages/components/ScriptMenuList.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, beforeEach, vi } from "vitest"; 2 | import { screen, fireEvent } from "@testing-library/react"; 3 | import { render, setupGlobalMocks } from "@Tests/test-utils"; 4 | 5 | // Mock ScriptMenuList component 6 | const MockScriptMenuList = ({ scripts, onScriptToggle }: any) => ( 7 |
8 | {scripts?.map((script: any, index: number) => ( 9 |
10 | {script.name} 11 | 14 |
15 | ))} 16 |
17 | ); 18 | 19 | // Mock dependencies 20 | vi.mock("react-i18next", () => ({ 21 | useTranslation: () => ({ 22 | t: (key: string) => key, 23 | }), 24 | })); 25 | 26 | describe("ScriptMenuList Component Mock Test", () => { 27 | beforeEach(() => { 28 | setupGlobalMocks(); 29 | vi.clearAllMocks(); 30 | }); 31 | 32 | it("should render script list", async () => { 33 | const mockScripts = [ 34 | { id: "1", name: "Test Script 1", enabled: true }, 35 | { id: "2", name: "Test Script 2", enabled: false }, 36 | ]; 37 | 38 | render(); 39 | 40 | expect(screen.getByText("Test Script 1")).toBeInTheDocument(); 41 | expect(screen.getByText("Test Script 2")).toBeInTheDocument(); 42 | }); 43 | 44 | it("should handle script toggle", async () => { 45 | const mockScripts = [{ id: "1", name: "Test Script", enabled: true }]; 46 | const onToggle = vi.fn(); 47 | 48 | render(); 49 | 50 | const toggleButton = screen.getByTestId("toggle-1"); 51 | fireEvent.click(toggleButton); 52 | 53 | expect(onToggle).toHaveBeenCalledWith(mockScripts[0]); 54 | }); 55 | 56 | it("should display correct toggle button text", async () => { 57 | const enabledScript = { id: "1", name: "Enabled Script", enabled: true }; 58 | const disabledScript = { id: "2", name: "Disabled Script", enabled: false }; 59 | 60 | render(); 61 | 62 | expect(screen.getByText("禁用")).toBeInTheDocument(); // 启用的脚本显示禁用按钮 63 | expect(screen.getByText("启用")).toBeInTheDocument(); // 禁用的脚本显示启用按钮 64 | }); 65 | 66 | it("should handle empty script list", async () => { 67 | render(); 68 | 69 | const container = screen.getByTestId("script-menu-list"); 70 | expect(container).toBeInTheDocument(); 71 | expect(container).toBeEmptyDOMElement(); 72 | }); 73 | 74 | it("should handle undefined scripts", async () => { 75 | render(); 76 | 77 | const container = screen.getByTestId("script-menu-list"); 78 | expect(container).toBeInTheDocument(); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report_en.yaml: -------------------------------------------------------------------------------- 1 | name: "Bug Report" 2 | description: Submit a bug encountered while using ScriptCat 3 | title: "[BUG] " 4 | labels: ["bug"] 5 | # assignees: "" 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | **Please take 2 minutes to fill out the following information, which will greatly help us quickly locate the problem** 11 | 12 | - type: textarea 13 | id: problem-description 14 | attributes: 15 | label: "Problem Description" 16 | description: "What problem occurred? What is the expected normal behavior?" 17 | placeholder: "e.g., When clicking the download button on YouTube page, the script throws a 404 error, expected to show a download window" 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | id: reproduction-steps 23 | attributes: 24 | label: "Reproduction Steps" 25 | description: "Please list the steps to reproduce the problem in order" 26 | placeholder: | 27 | 1. Open https://example.com 28 | 2. Click the red button in the top right corner of the page 29 | 3. Wait 5 seconds and observe the console 30 | validations: 31 | required: true 32 | 33 | - type: markdown 34 | attributes: 35 | value: "### Environment Information" 36 | - type: input 37 | id: scriptcat-version 38 | attributes: 39 | label: ScriptCat Version 40 | description: You can view it by clicking on the ScriptCat popup window. If possible, please use the latest version as your issue may have already been resolved 41 | placeholder: e.g., v0.17.0 42 | validations: 43 | required: true 44 | - type: input 45 | id: browser-version 46 | attributes: 47 | label: Operating System and Browser Information 48 | description: You can view it in Browser - About, or enter chrome://settings/help 49 | placeholder: e.g., Windows 11 Chrome 137.0.3296.93 (Official Build) (arm64) 50 | validations: 51 | required: true 52 | 53 | - type: markdown 54 | attributes: 55 | value: "### Additional Information" 56 | 57 | - type: textarea 58 | id: additional-info 59 | attributes: 60 | label: Additional Information (Optional) 61 | description: Error logs/screenshots/script code snippets/related configurations... 62 | placeholder: | 63 | If it involves a specific script, it's best to export it as a zip file from the extension's homepage and upload it, or provide the script's installation address. 64 | 65 | To view error logs for the extension itself: 66 | 1. Open the extension management page (chrome://extensions/) 67 | 2. Turn on developer mode, click on ScriptCat's "Service Worker" or "offscreen.html" 68 | 3. View the console page in the popup window 69 | 70 | Error logs for regular scripts can be viewed in the developer tools (F12 shortcut) - Console on the script's running page 71 | Error logs for background scripts can be viewed in the above-mentioned offscreen.html 72 | -------------------------------------------------------------------------------- /src/pkg/backup/struct.ts: -------------------------------------------------------------------------------- 1 | import type { Script } from "@App/app/repo/scripts"; 2 | import type { Subscribe } from "@App/app/repo/subscribe"; 3 | 4 | export type ResourceMeta = { 5 | name: string; 6 | url: string; 7 | ts: number; 8 | mimetype?: string; 9 | }; 10 | 11 | export type ResourceBackup = { 12 | meta: ResourceMeta; 13 | // text数据 14 | source?: string; 15 | // 二进制数据 16 | base64: string; 17 | }; 18 | 19 | export type ValueStorage = { 20 | data: { [key: string]: any }; 21 | ts: number; 22 | }; 23 | 24 | export type ScriptOptions = { 25 | check_for_updates: boolean; 26 | comment: string | null; 27 | compat_foreach: boolean; 28 | compat_metadata: boolean; 29 | compat_prototypes: boolean; 30 | compat_wrappedjsobject: boolean; 31 | compatopts_for_requires: boolean; 32 | noframes: boolean | null; 33 | override: { 34 | merge_connects: boolean; 35 | merge_excludes: boolean; 36 | merge_includes: boolean; 37 | merge_matches: boolean; 38 | orig_connects: Array; 39 | orig_excludes: Array; 40 | orig_includes: Array; 41 | orig_matches: Array; 42 | orig_noframes: boolean | null; 43 | orig_run_at: string; 44 | use_blockers: Array; 45 | use_connects: Array; 46 | use_excludes: Array; 47 | use_includes: Array; 48 | use_matches: Array; 49 | }; 50 | run_at: string | null; 51 | }; 52 | 53 | export type ScriptMeta = { 54 | name: string; 55 | uuid: string; // 此uuid是对tm的兼容处理 56 | sc_uuid: string; // 脚本猫uuid 57 | modified: number; 58 | file_url: string; 59 | subscribe_url?: string; 60 | }; 61 | 62 | export type ScriptOptionsFile = { 63 | options: ScriptOptions; 64 | settings: { enabled: boolean; position: number }; 65 | meta: ScriptMeta; 66 | }; 67 | 68 | export type ScriptBackupData = { 69 | code: string; 70 | options?: ScriptOptionsFile; 71 | storage: ValueStorage; 72 | requires: ResourceBackup[]; 73 | requiresCss: ResourceBackup[]; 74 | resources: ResourceBackup[]; 75 | // 为了兼容暴力猴而设置的字段 76 | enabled?: boolean; 77 | }; 78 | 79 | export type ScriptData = ScriptBackupData & { 80 | script?: { script: Script; oldScript?: Script }; 81 | install: boolean; 82 | error?: string; 83 | }; 84 | 85 | export type SubscribeData = SubscribeBackupData & { 86 | subscribe?: Subscribe; 87 | install: boolean; 88 | }; 89 | 90 | export type SubscribeScript = { 91 | uuid: string; 92 | url: string; 93 | }; 94 | 95 | export type SubscribeMeta = { 96 | name: string; 97 | modified: number; 98 | url: string; 99 | }; 100 | 101 | export type SubscribeOptionsFile = { 102 | settings: { enabled: boolean }; 103 | scripts: { [key: string]: SubscribeScript }; 104 | meta: SubscribeMeta; 105 | }; 106 | 107 | export type SubscribeBackupData = { 108 | source: string; 109 | options?: SubscribeOptionsFile; 110 | }; 111 | 112 | export type BackupData = { 113 | script: ScriptBackupData[]; 114 | subscribe: SubscribeBackupData[]; 115 | }; 116 | --------------------------------------------------------------------------------