├── project ├── ext │ ├── README.md │ ├── src │ │ ├── assets │ │ │ └── main.css │ │ ├── entrypoints │ │ │ ├── editor │ │ │ │ ├── main.ts │ │ │ │ ├── lib │ │ │ │ │ └── code.js │ │ │ │ ├── App.vue │ │ │ │ ├── index.html │ │ │ │ └── components │ │ │ │ │ └── codemirror.vue │ │ │ ├── popup │ │ │ │ ├── main.ts │ │ │ │ ├── App.vue │ │ │ │ ├── index.html │ │ │ │ └── components │ │ │ │ │ └── container.vue │ │ │ ├── content.ts │ │ │ └── background.ts │ │ ├── lib │ │ │ ├── idb │ │ │ │ ├── file │ │ │ │ │ ├── test-3.js │ │ │ │ │ ├── test-1.js │ │ │ │ │ └── test-2.js │ │ │ │ ├── index.test.ts │ │ │ │ └── index.ts │ │ │ ├── rules │ │ │ │ ├── info.text │ │ │ │ └── index.ts │ │ │ ├── rpc │ │ │ │ ├── backgroundToolRPC.ts │ │ │ │ └── backgroundScriptRPC.ts │ │ │ ├── user-script.ts │ │ │ ├── caller.ts │ │ │ ├── service │ │ │ │ ├── backgroundScriptService.ts │ │ │ │ └── backgroundToolService.ts │ │ │ ├── request │ │ │ │ ├── example.ts │ │ │ │ └── index.ts │ │ │ ├── storage │ │ │ │ ├── index.ts │ │ │ │ └── NamedStorage.ts │ │ │ └── tool.ts │ │ ├── util │ │ │ ├── parseCode.ts │ │ │ └── guid.ts │ │ ├── locales │ │ │ ├── zh.yaml │ │ │ └── en.yaml │ │ └── components │ │ │ └── Root.vue │ ├── public │ │ ├── icon │ │ │ ├── 128.png │ │ │ ├── 16.png │ │ │ ├── 32.png │ │ │ ├── 48.png │ │ │ └── 96.png │ │ └── images │ │ │ └── vanilla-pudding.png │ ├── postcss.config.js │ ├── vitest.config.ts │ ├── tsconfig.json │ ├── tailwind.config.js │ ├── .gitignore │ ├── package.json │ ├── tsconfig-types.json │ └── wxt.config.ts └── vpu-test │ ├── README.md │ ├── index.html │ ├── .gitignore │ ├── package.json │ ├── vite.config.js │ └── src │ └── main.js ├── packages ├── message │ ├── .gitignore │ ├── tsconfig.json │ ├── vite.config.ts │ ├── package.json │ └── src │ │ ├── guid.ts │ │ ├── type.ts │ │ └── index.ts ├── create-vpu │ ├── .gitignore │ ├── index.js │ ├── readme.md │ ├── template-vanilla │ │ ├── index.html │ │ ├── _gitignore │ │ ├── src │ │ │ └── main.js │ │ ├── package.json │ │ └── vite.config.js │ ├── template-vue │ │ ├── index.html │ │ ├── _gitignore │ │ ├── src │ │ │ └── main.js │ │ ├── package.json │ │ └── vite.config.js │ ├── tsconfig.json │ ├── build.config.ts │ ├── package.json │ └── src │ │ └── index.ts └── vite-plugin │ ├── .gitignore │ ├── src │ ├── index.ts │ └── transform-userscript │ │ └── index.ts │ ├── tsconfig.json │ ├── vite.config.ts │ └── package.json ├── pnpm-workspace.yaml ├── .editorconfig ├── .npmrc ├── .run ├── 初始化.run.xml └── 编译-message.run.xml ├── .gitignore ├── eslint.config.mjs ├── LICENSE ├── THIRD-PARTY-LICENSE ├── package.json ├── README.md └── README_EN.md /project/ext/README.md: -------------------------------------------------------------------------------- 1 | # vanilla-pudding 插件 2 | -------------------------------------------------------------------------------- /packages/message/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /packages/create-vpu/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /packages/vite-plugin/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /project/vpu-test/README.md: -------------------------------------------------------------------------------- 1 | # @vanilla-pudding/vpu-test 用户脚本功能测试用 2 | -------------------------------------------------------------------------------- /packages/vite-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './transform-userscript' 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | - "project/*" 4 | -------------------------------------------------------------------------------- /packages/create-vpu/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import './dist/index.mjs' 4 | -------------------------------------------------------------------------------- /project/ext/src/assets/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 2 6 | indent_style = space 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | auto-install-peers=true 4 | package-manager-strict=false 5 | -------------------------------------------------------------------------------- /project/ext/public/icon/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xdy1579883916/vanilla-pudding/HEAD/project/ext/public/icon/128.png -------------------------------------------------------------------------------- /project/ext/public/icon/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xdy1579883916/vanilla-pudding/HEAD/project/ext/public/icon/16.png -------------------------------------------------------------------------------- /project/ext/public/icon/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xdy1579883916/vanilla-pudding/HEAD/project/ext/public/icon/32.png -------------------------------------------------------------------------------- /project/ext/public/icon/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xdy1579883916/vanilla-pudding/HEAD/project/ext/public/icon/48.png -------------------------------------------------------------------------------- /project/ext/public/icon/96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xdy1579883916/vanilla-pudding/HEAD/project/ext/public/icon/96.png -------------------------------------------------------------------------------- /project/ext/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /project/ext/public/images/vanilla-pudding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xdy1579883916/vanilla-pudding/HEAD/project/ext/public/images/vanilla-pudding.png -------------------------------------------------------------------------------- /project/ext/src/entrypoints/editor/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | 4 | const app = createApp(App) 5 | app.mount('#app') 6 | -------------------------------------------------------------------------------- /project/ext/src/entrypoints/popup/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | 4 | const app = createApp(App) 5 | app.mount('#app') 6 | -------------------------------------------------------------------------------- /project/ext/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import { WxtVitest } from 'wxt/testing' 3 | 4 | export default defineConfig({ 5 | plugins: [WxtVitest()], 6 | }) 7 | -------------------------------------------------------------------------------- /packages/create-vpu/readme.md: -------------------------------------------------------------------------------- 1 | # create-vanilla-pudding-userscript 2 | 3 | ## 搭建你的第一个 vanilla-pudding 用户脚本项目 4 | 5 | ## 快速开始 6 | 7 | ```bash 8 | npm create vpu@latest 9 | ``` 10 | 11 | 然后按照提示操作! 12 | -------------------------------------------------------------------------------- /project/ext/src/entrypoints/editor/lib/code.js: -------------------------------------------------------------------------------- 1 | // @name new-script 2 | // @match 3 | // @runAt document_idle 4 | 5 | // see doc: https://github.com/Xdy1579883916/vanilla-pudding?tab=readme-ov-file#matadata 6 | console.log(1) 7 | -------------------------------------------------------------------------------- /project/ext/src/lib/idb/file/test-3.js: -------------------------------------------------------------------------------- 1 | // @name 2 | // @match 3 | // @runAt 4 | // @runWith 5 | // @run-with 6 | // @allFrames 7 | // @world 8 | 9 | // see doc: https://github.com/Xdy1579883916/vanilla-pudding?tab=readme-ov-file#matadata 10 | -------------------------------------------------------------------------------- /project/ext/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.wxt/tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "allowImportingTsExtensions": true, 6 | "allowJs": true, 7 | "strict": false, 8 | "noImplicitAny": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /project/ext/src/entrypoints/popup/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 11 | -------------------------------------------------------------------------------- /project/ext/src/entrypoints/editor/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 11 | -------------------------------------------------------------------------------- /project/ext/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./src/entrypoints/**/*.{ts,vue,js,jsx,tsx,html}', './src/components/**/*.{ts,vue,js,jsx,tsx}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | corePlugins: {}, 8 | plugins: [], 9 | } 10 | -------------------------------------------------------------------------------- /project/ext/src/lib/rules/info.text: -------------------------------------------------------------------------------- 1 | await chrome.declarativeNetRequest.getDynamicRules(async (rules) => { 2 | for (const r in rules) { 3 | await chrome.declarativeNetRequest.updateDynamicRules({ 4 | removeRuleIds: [rules[r].id], 5 | }); 6 | } 7 | }); 8 | 9 | await chrome.declarativeNetRequest.getDynamicRules() 10 | -------------------------------------------------------------------------------- /project/vpu-test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vite + Vue 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /project/ext/src/entrypoints/editor/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | vanilla pudding pop 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /project/ext/src/lib/rpc/backgroundToolRPC.ts: -------------------------------------------------------------------------------- 1 | import { defineProxyService } from '@webext-core/proxy-service' 2 | import { BackgroundToolService } from '@/lib/service/backgroundToolService.ts' 3 | 4 | // 背景工具服务 5 | export const [registerBackgroundToolService, getBackgroundToolService] = defineProxyService( 6 | 'BackgroundToolService', 7 | () => new BackgroundToolService(), 8 | ) 9 | -------------------------------------------------------------------------------- /packages/create-vpu/template-vanilla/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vite + Vue 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/create-vpu/template-vue/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vite + Vue 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /project/ext/src/lib/rpc/backgroundScriptRPC.ts: -------------------------------------------------------------------------------- 1 | import { defineProxyService } from '@webext-core/proxy-service' 2 | import { BackgroundScriptService } from '@/lib/service/backgroundScriptService.ts' 3 | 4 | // 背景脚本服务 5 | export const [registerBackgroundScriptService, getBackgroundScriptService] = defineProxyService( 6 | 'BackgroundScriptService', 7 | () => new BackgroundScriptService(), 8 | ) 9 | -------------------------------------------------------------------------------- /project/vpu-test/.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 | -------------------------------------------------------------------------------- /packages/create-vpu/template-vue/_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 | -------------------------------------------------------------------------------- /packages/create-vpu/template-vanilla/_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 | -------------------------------------------------------------------------------- /.run/初始化.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /project/ext/src/util/parseCode.ts: -------------------------------------------------------------------------------- 1 | export function parseCode(code: string, runWith = 'esm') { 2 | if (runWith !== 'esm') { 3 | return code 4 | } 5 | return ` 6 | window.loadESMScript = loadESMScript; 7 | async function loadESMScript(script) { 8 | const blob = new Blob([script], { type: 'application/javascript' }) 9 | const url = URL.createObjectURL(blob) 10 | await import(url) 11 | URL.revokeObjectURL(url) 12 | } 13 | loadESMScript(${JSON.stringify(code)}); 14 | ` 15 | } 16 | -------------------------------------------------------------------------------- /project/ext/src/util/guid.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid' 2 | 3 | /** 4 | * 全局唯一标识符 5 | * @param len 6 | * @param firstU 7 | */ 8 | export function guid(len: number = 32, firstU = true) { 9 | if (firstU) { 10 | len-- 11 | } 12 | const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', len) 13 | const uuid = nanoid() 14 | // 移除第一个字符,并用u替代,因为第一个字符为数值时,该id不能用作id或者class 15 | if (firstU) { 16 | return `u${uuid}` 17 | } 18 | else { 19 | return uuid 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /project/ext/src/lib/idb/file/test-1.js: -------------------------------------------------------------------------------- 1 | // @name Google Translate Auto Languages 2 | // @match *://*.edu.ai/* 3 | // @match *://translate.google.com/* 4 | // @match *://translate.google.com/* 5 | // @match *://translate.google.cn/* 6 | // @runAt document_end 7 | // @runWith esm 8 | // @run-with esm 9 | // @allFrames true 10 | // @world MAIN 11 | // @updateUrl https://update.greasyfork.org/scripts/378166/Google%20Translate%20Auto%20Languages.meta.js 12 | 13 | // see doc: https://github.com/Xdy1579883916/vanilla-pudding?tab=readme-ov-file#matadata 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 修改文件内容后要清空git本地文件缓存: 2 | # git rm -r --cached . 3 | # git add . 4 | # git commit -m 'update .gitignore' 5 | # git pull 6 | # git push 7 | 8 | .DS_Store 9 | node_modules 10 | 11 | 12 | # local env files 13 | .env.local 14 | .env.*.local 15 | 16 | # Log files 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | pnpm-debug.log* 21 | 22 | # Editor directories and files 23 | .idea 24 | .vscode/* 25 | !.vscode/extensions.json 26 | # 解决vscode内的一些格式化配置问题 27 | !.vscode/settings.json 28 | # vue3的基础模板 29 | !.vscode/vue3.code-snippets 30 | pnpm-lock.yaml 31 | -------------------------------------------------------------------------------- /packages/message/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "baseUrl": ".", 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "paths": { 8 | "@/*": ["src/*"] 9 | }, 10 | "strict": false, 11 | "noImplicitAny": false, 12 | "noImplicitThis": false, 13 | "noEmit": true, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "isolatedModules": true, 18 | "skipLibCheck": true 19 | }, 20 | "include": ["src/**/*.ts", "test/**/*.ts", "vite.config.ts"], 21 | "exclude": ["node_modules", "dist"] 22 | } 23 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | rules: { 5 | 'no-console': 'off', 6 | 'no-debugger': 'off', 7 | 'no-unused-vars': 'off', 8 | 'ts/no-unused-expressions': 'off', 9 | 'no-restricted-globals': 'off', 10 | 'node/prefer-global/process': 'off', 11 | 'node/handle-callback-err': 'off', 12 | 'unused-imports/no-unused-vars': 'off', 13 | 'vue/block-order': [ 14 | 'error', 15 | { order: ['style', 'template', 'script:not([setup])', 'script[setup]'] }, 16 | ], 17 | }, 18 | stylistic: { 19 | indent: 2, 20 | }, 21 | regexp: false, 22 | unicorn: false, 23 | }) 24 | -------------------------------------------------------------------------------- /packages/vite-plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "baseUrl": ".", 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "paths": { 8 | "@/*": ["src/*"] 9 | }, 10 | "strict": false, 11 | "noImplicitAny": false, 12 | "noImplicitThis": false, 13 | "noEmit": true, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "isolatedModules": true, 18 | "skipLibCheck": true 19 | }, 20 | "include": ["src/**/*.ts", "test/**/*.ts", "vite.config.ts"], 21 | "exclude": ["node_modules", "dist"] 22 | } 23 | -------------------------------------------------------------------------------- /project/ext/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vanilla-pudding/ext", 3 | "type": "module", 4 | "version": "1.15.0", 5 | "private": true, 6 | "description": "插件", 7 | "scripts": { 8 | "rm-all": "rimraf dist node_modules", 9 | "dev": "wxt", 10 | "dev:firefox": "wxt -b firefox", 11 | "build": "wxt build", 12 | "build:firefox": "wxt build -b firefox", 13 | "zip": "wxt zip", 14 | "zip:firefox": "wxt zip -b firefox", 15 | "compile": "tsc --noEmit", 16 | "gen-types": "tsc -p tsconfig-types.json", 17 | "postinstall": "wxt prepare", 18 | "test": "vitest" 19 | }, 20 | "dependencies": { 21 | "@vanilla-pudding/message": "workspace:*" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /project/ext/src/locales/zh.yaml: -------------------------------------------------------------------------------- 1 | extName: 香草布丁🌿🍮 2 | extDescription: 香草布丁用户脚本管理器,可以帮助您管理和运行浏览器用户脚本 3 | openSource: https://github.com/Xdy1579883916/vanilla-pudding 4 | noSupportTip: 插件暂不可用

点击了解如何开启 5 | noSupportTipLink: https://developer.chrome.com/blog/chrome-userscript?hl=zh_cn 6 | 7 | script: 8 | empty: 你的脚本列表空空的 9 | manage: 管理脚本 10 | create: 新增 11 | create2: 新增一个吧 12 | update: 更新 13 | export: 导出 14 | import: 导入 15 | clear: 清空 16 | failed: 脚本加载失败, 无脚本Id 17 | saved: 脚本已保存 ~ 18 | searchFilter: 搜索过滤 19 | export: 20 | file: 香草布丁用户脚本导出 21 | delete: 22 | title: 确认删除吗? 23 | content: 备份了吗? 删了可就没了哦~ 24 | positive: 确认 25 | negative: 算了 26 | success: 脚本已删除 27 | clear: 28 | title: 确认清空脚本吗? 29 | success: 脚本已清空 30 | -------------------------------------------------------------------------------- /packages/message/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import { defineConfig } from 'vite' 3 | import dts from 'vite-plugin-dts' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig(({ mode }) => { 7 | const isDev = mode === 'development' 8 | 9 | return { 10 | plugins: [ 11 | dts({ 12 | entryRoot: 'src', 13 | }), 14 | ], 15 | build: { 16 | modulePreload: false, 17 | lib: { 18 | entry: resolve(__dirname, 'src/index.ts'), 19 | formats: ['es', 'cjs'], 20 | fileName: 'index', 21 | }, 22 | target: 'esnext', 23 | minify: false, 24 | rollupOptions: { 25 | external: ['nanoid'], 26 | }, 27 | }, 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /project/ext/tsconfig-types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.wxt/tsconfig.json", 3 | "exclude": [ 4 | "src/entrypoints/**/*" 5 | ], 6 | "include": [ 7 | "src/lib/**/*" 8 | ], 9 | "compilerOptions": { 10 | "allowImportingTsExtensions": true, 11 | "strict": false, 12 | "noImplicitAny": false, 13 | "allowJs": true, 14 | // Generate d.ts files 15 | // only output d.ts files 16 | "emitDeclarationOnly": true, 17 | // Types should go into this directory. 18 | // Removing this would place the .d.ts files 19 | // next to the .js files 20 | // "outDir": ".types", 21 | "declaration": true, 22 | "noEmit": false, 23 | "rootDir": ".", 24 | "outFile": ".types/types.d.ts" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/vite-plugin/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import { defineConfig } from 'vite' 3 | import dts from 'vite-plugin-dts' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig(({ mode }) => { 7 | const isDev = mode === 'development' 8 | 9 | return { 10 | plugins: [ 11 | dts({ 12 | entryRoot: 'src', 13 | }), 14 | ], 15 | build: { 16 | modulePreload: false, 17 | lib: { 18 | entry: resolve(__dirname, 'src/index.ts'), 19 | formats: ['es', 'cjs'], 20 | fileName: 'index', 21 | }, 22 | target: 'esnext', 23 | minify: false, 24 | rollupOptions: { 25 | external: ['nanoid'], 26 | }, 27 | }, 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /packages/create-vpu/build.config.ts: -------------------------------------------------------------------------------- 1 | // import path from 'node:path' 2 | // import url from 'node:url' 3 | import { defineBuildConfig } from 'unbuild' 4 | 5 | // const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) 6 | 7 | export default defineBuildConfig({ 8 | entries: ['src/index'], 9 | clean: true, 10 | rollup: { 11 | inlineDependencies: true, 12 | esbuild: { 13 | target: 'node18', 14 | minify: true, 15 | }, 16 | }, 17 | alias: { 18 | // we can always use non-transpiled code since we support node 18+ 19 | prompts: 'prompts/lib/index.js', 20 | }, 21 | hooks: { 22 | 'rollup:options': (ctx, options) => { 23 | options.plugins = [ 24 | options.plugins, 25 | ] 26 | }, 27 | }, 28 | }) 29 | -------------------------------------------------------------------------------- /packages/message/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vanilla-pudding/message", 3 | "type": "module", 4 | "version": "1.4.0", 5 | "private": false, 6 | "description": "香草布丁-消息通信包", 7 | "author": "deyu", 8 | "license": "MIT", 9 | "exports": { 10 | "./package.json": "./package.json", 11 | ".": { 12 | "import": "./dist/index.js", 13 | "require": "./dist/index.cjs" 14 | } 15 | }, 16 | "main": "dist/index.cjs", 17 | "module": "dist/index.js", 18 | "types": "dist/index.d.ts", 19 | "files": [ 20 | "dist" 21 | ], 22 | "scripts": { 23 | "rm-all": "rimraf .output node_modules", 24 | "build": "vite build", 25 | "prepublishOnly": "pnpm build", 26 | "push": "npm publish" 27 | }, 28 | "publishConfig": { 29 | "access": "public", 30 | "registry": "https://registry.npmjs.org/" 31 | }, 32 | "devDependencies": {} 33 | } 34 | -------------------------------------------------------------------------------- /packages/vite-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vanilla-pudding/vite-plugin", 3 | "type": "module", 4 | "version": "1.0.2", 5 | "private": false, 6 | "description": "香草布丁-vite-plugin", 7 | "author": "deyu", 8 | "license": "MIT", 9 | "exports": { 10 | "./package.json": "./package.json", 11 | ".": { 12 | "import": "./dist/index.js", 13 | "require": "./dist/index.cjs" 14 | } 15 | }, 16 | "main": "dist/index.cjs", 17 | "module": "dist/index.js", 18 | "types": "dist/index.d.ts", 19 | "files": [ 20 | "dist" 21 | ], 22 | "scripts": { 23 | "rm-all": "rimraf .output node_modules", 24 | "build": "vite build", 25 | "prepublishOnly": "pnpm build", 26 | "push": "npm publish" 27 | }, 28 | "publishConfig": { 29 | "access": "public", 30 | "registry": "https://registry.npmjs.org/" 31 | }, 32 | "devDependencies": {} 33 | } 34 | -------------------------------------------------------------------------------- /project/vpu-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vanilla-pudding/vpu-test", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "private": false, 6 | "description": "用户脚本功能测试用", 7 | "exports": { 8 | "./package.json": "./package.json", 9 | ".": { 10 | "import": "./dist/index.js", 11 | "default": "./dist/index.js" 12 | } 13 | }, 14 | "main": "dist/index.js", 15 | "module": "dist/index.js", 16 | "files": [ 17 | "dist" 18 | ], 19 | "scripts": { 20 | "dev": "vite", 21 | "build": "vite build", 22 | "preview": "vite preview", 23 | "prepublishOnly": "pnpm build", 24 | "push": "npm publish" 25 | }, 26 | "publishConfig": { 27 | "access": "public", 28 | "registry": "https://registry.npmjs.org/" 29 | }, 30 | "dependencies": { 31 | "@vanilla-pudding/message": "workspace:*", 32 | "@vanilla-pudding/vite-plugin": "workspace:*" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /project/ext/src/components/Root.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 33 | -------------------------------------------------------------------------------- /packages/create-vpu/template-vue/src/main.js: -------------------------------------------------------------------------------- 1 | import { useExt } from '@vanilla-pudding/message' 2 | 3 | const bgt = useExt().bgt 4 | 5 | window.test = { 6 | async hello() { 7 | const hello = await bgt.hello('你好') 8 | console.log('hello', hello) 9 | }, 10 | async extLocalStore() { 11 | await bgt.extLocalStore.set('name', '张三', 1) 12 | const local_name = await bgt.extLocalStore.get('name') 13 | console.log('extLocalStore', local_name) 14 | }, 15 | async extNamedStore() { 16 | const namespace = 'test' 17 | await bgt.extNamedStore.set(namespace, 'name', '张三', 1) 18 | const name = await bgt.extNamedStore.get(namespace, 'name') 19 | 20 | console.log('extNamedStore', name) 21 | }, 22 | async doRequest() { 23 | await bgt.doRequestFy( 24 | 'GET', 25 | 'https://www.alibaba.com/trade/search?tab=supplier&SearchText=dress', 26 | ).then((res) => { 27 | console.log('doRequestFy', res) 28 | }) 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /packages/create-vpu/template-vanilla/src/main.js: -------------------------------------------------------------------------------- 1 | import { useExt } from '@vanilla-pudding/message' 2 | 3 | const bgt = useExt().bgt 4 | 5 | window.test = { 6 | async hello() { 7 | const hello = await bgt.hello('你好') 8 | console.log('hello', hello) 9 | }, 10 | async extLocalStore() { 11 | await bgt.extLocalStore.set('name', '张三', 1) 12 | const local_name = await bgt.extLocalStore.get('name') 13 | console.log('extLocalStore', local_name) 14 | }, 15 | async extNamedStore() { 16 | const namespace = 'test' 17 | await bgt.extNamedStore.set(namespace, 'name', '张三', 1) 18 | const name = await bgt.extNamedStore.get(namespace, 'name') 19 | 20 | console.log('extNamedStore', name) 21 | }, 22 | async doRequest() { 23 | await bgt.doRequestFy( 24 | 'GET', 25 | 'https://www.alibaba.com/trade/search?tab=supplier&SearchText=dress', 26 | ).then((res) => { 27 | console.log('doRequestFy', res) 28 | }) 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /packages/create-vpu/template-vanilla/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "userscript-starter", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "private": false, 6 | "exports": { 7 | "./package.json": "./package.json", 8 | ".": { 9 | "import": "./dist/index.js", 10 | "default": "./dist/index.js" 11 | } 12 | }, 13 | "main": "dist/index.js", 14 | "module": "dist/index.js", 15 | "files": [ 16 | "dist" 17 | ], 18 | "scripts": { 19 | "dev": "vite", 20 | "build": "vite build", 21 | "preview": "vite preview", 22 | "prepublishOnly": "pnpm build", 23 | "push": "npm publish" 24 | }, 25 | "publishConfig": { 26 | "access": "public", 27 | "registry": "https://registry.npmjs.org/" 28 | }, 29 | "dependencies": { 30 | "@vanilla-pudding/message": "latest" 31 | }, 32 | "devDependencies": { 33 | "@types/chrome": "latest", 34 | "@vanilla-pudding/vite-plugin": "^1.0.0", 35 | "vite": "^5.3.1" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/create-vpu/template-vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "userscript-starter", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "private": false, 6 | "exports": { 7 | "./package.json": "./package.json", 8 | ".": { 9 | "import": "./dist/index.js", 10 | "default": "./dist/index.js" 11 | } 12 | }, 13 | "main": "dist/index.js", 14 | "module": "dist/index.js", 15 | "files": [ 16 | "dist" 17 | ], 18 | "scripts": { 19 | "dev": "vite", 20 | "build": "vite build", 21 | "preview": "vite preview", 22 | "prepublishOnly": "pnpm build", 23 | "push": "npm publish" 24 | }, 25 | "publishConfig": { 26 | "access": "public", 27 | "registry": "https://registry.npmjs.org/" 28 | }, 29 | "dependencies": { 30 | "@vanilla-pudding/message": "latest", 31 | "vue": "^3.4.30" 32 | }, 33 | "devDependencies": { 34 | "@types/chrome": "latest", 35 | "@vanilla-pudding/vite-plugin": "^1.0.0", 36 | "@vitejs/plugin-vue": "^5.0.5", 37 | "vite": "^5.3.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/create-vpu/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-vpu", 3 | "type": "module", 4 | "version": "1.8.0", 5 | "description": "创建香草布丁用户脚本 create-vanilla-pudding-userscript", 6 | "author": "xiadeyu", 7 | "license": "MIT", 8 | "keywords": [], 9 | "bin": { 10 | "create-vpu": "index.js", 11 | "cvpu": "index.js" 12 | }, 13 | "files": [ 14 | "dist", 15 | "index.js", 16 | "template-*/**" 17 | ], 18 | "scripts": { 19 | "dev": "unbuild --stub", 20 | "build": "unbuild", 21 | "typecheck": "tsc --noEmit", 22 | "prepublishOnly": "npm run build", 23 | "push": "npm publish" 24 | }, 25 | "publishConfig": { 26 | "access": "public", 27 | "registry": "https://registry.npmjs.org/" 28 | }, 29 | "devDependencies": { 30 | "@types/cross-spawn": "^6.0.6", 31 | "@types/minimist": "^1.2.5", 32 | "@types/prompts": "^2.4.9", 33 | "cross-spawn": "^7.0.3", 34 | "kolorist": "^1.8.0", 35 | "minimist": "^1.2.8", 36 | "prompts": "^2.4.2", 37 | "unbuild": "^2.0.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /project/ext/src/locales/en.yaml: -------------------------------------------------------------------------------- 1 | extName: Vanilla Pudding🌿🍮 2 | extDescription: Vanilla Pudding Userscript Manager helps you manage and run browser userscripts 3 | openSource: https://github.com/Xdy1579883916/vanilla-pudding 4 | noSupportTip: The plugin is temporarily unavailable

Click to learn how to enable it 5 | noSupportTipLink: https://developer.chrome.com/blog/chrome-userscript 6 | 7 | script: 8 | empty: Your script list is empty 9 | manage: Script Management 10 | create: Add Script 11 | create2: Add Script 12 | update: Update Script 13 | export: Export Script 14 | import: Import Script 15 | clear: Clear All Script 16 | failed: Script loading failed, no script ID 17 | saved: Saved script ~ 18 | searchFilter: Search Filter 19 | export: 20 | file: vanilla-pudding-userscript-export 21 | delete: 22 | title: Are you sure to delete ? 23 | content: Have you backed up? If you delete it, it will be gone~ 24 | positive: Confirmed 25 | negative: Forget it 26 | success: The script has been deleted 27 | clear: 28 | title: Are you sure to clear all scripts ? 29 | success: The script has been cleared 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 XiaDeYu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /THIRD-PARTY-LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 BlackGlory 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/message/src/guid.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 全局唯一标识符(uuid,Globally Unique Identifier),也称作 uuid(Universally Unique Identifier) 3 | * @param {number} len uuid的长度 4 | * @param {boolean} firstU 将返回的首字母置为"u" 5 | * @param radix 生成uuid的基数(意味着返回的字符串都是这个基数),2-二进制,8-八进制,10-十进制,16-十六进制 6 | */ 7 | export function guid(len = 32, firstU = true, radix = null) { 8 | const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('') 9 | const uuid = [] 10 | radix = radix || chars.length 11 | 12 | if (len) { 13 | // 如果指定uuid长度,只是取随机的字符,0|x为位运算,能去掉x的小数位,返回整数位 14 | for (let i = 0; i < len; i++) uuid[i] = chars[0 | Math.random() * radix] 15 | } 16 | else { 17 | let r 18 | // rfc4122标准要求返回的uuid中,某些位为固定的字符 19 | uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-' 20 | uuid[14] = '4' 21 | 22 | for (let i = 0; i < 36; i++) { 23 | if (!uuid[i]) { 24 | r = 0 | Math.random() * 16 25 | uuid[i] = chars[(i === 19) ? (r & 0x3) | 0x8 : r] 26 | } 27 | } 28 | } 29 | // 移除第一个字符,并用u替代,因为第一个字符为数值时,该guid不能用作id或者class 30 | if (firstU) { 31 | uuid.shift() 32 | return `u${uuid.join('')}` 33 | } 34 | else { 35 | return uuid.join('') 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /project/ext/src/entrypoints/content.ts: -------------------------------------------------------------------------------- 1 | import { emit, Event4ChromeKey, Event4PageKey, ExtManifestKey, listen } from '@vanilla-pudding/message' 2 | import { caller } from '@/lib/caller.ts' 3 | import { getBackgroundToolService } from '@/lib/rpc/backgroundToolRPC.ts' 4 | 5 | export default defineContentScript({ 6 | matches: [''], 7 | allFrames: true, 8 | // 需要尽可能快地提供通讯支持 9 | runAt: 'document_start', 10 | async main() { 11 | const backgroundTool = getBackgroundToolService() 12 | const info = chrome.runtime.getManifest() 13 | console.log('Hello content.', info.version) 14 | 15 | if (!document.documentElement) { 16 | return 17 | } 18 | 19 | // 1、为dom 添加初始化完成标记 20 | document.documentElement.setAttribute(ExtManifestKey, info.version) 21 | // 2、为 document 添加获取插件清单的监听事件 22 | listen(ExtManifestKey, async (event: any) => { 23 | console.log('收到 ExtManifestKey', event) 24 | emit(Event4PageKey, info) 25 | }) 26 | // 创建事件监听 27 | listen(Event4ChromeKey, async (event: any) => { 28 | console.log('收到 Event4ChromeKey', event) 29 | const { detail } = event || {} 30 | const res = await caller(backgroundTool, detail) 31 | // 处理完成之后, 返回数据给调用方 32 | emit(Event4PageKey, res) 33 | }) 34 | }, 35 | }) 36 | -------------------------------------------------------------------------------- /packages/create-vpu/template-vanilla/vite.config.js: -------------------------------------------------------------------------------- 1 | import { transformUserScript } from '@vanilla-pudding/vite-plugin' 2 | import { defineConfig } from 'vite' 3 | import pkg from './package.json' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig(({ mode }) => { 7 | // eslint-disable-next-line no-unused-vars 8 | const isDev = mode === 'development' 9 | // 可直接使用网络的ESM的CDN包 10 | const cdnScripts = { 11 | '@vanilla-pudding/message': 'https://unpkg.com/@vanilla-pudding/message/dist/index.js', 12 | } 13 | 14 | return { 15 | server: { 16 | port: 5177, 17 | }, 18 | plugins: [ 19 | transformUserScript({ 20 | scriptMeta: { 21 | name: pkg.name, 22 | match: '*://www.baidu.com/*', 23 | }, 24 | cdnScripts, 25 | }), 26 | ], 27 | build: { 28 | modulePreload: false, 29 | // minify: "terser", 30 | // terserOptions: { 31 | // compress: { 32 | // drop_console: true, 33 | // drop_debugger: true, 34 | // pure_funcs: ["console.log"], 35 | // }, 36 | // }, 37 | rollupOptions: { 38 | output: { 39 | entryFileNames: '[name].js', 40 | format: 'esm', 41 | }, 42 | external: Object.keys(cdnScripts), 43 | }, 44 | }, 45 | } 46 | }) 47 | -------------------------------------------------------------------------------- /project/vpu-test/vite.config.js: -------------------------------------------------------------------------------- 1 | import { transformUserScript } from '@vanilla-pudding/vite-plugin' 2 | import vue from '@vitejs/plugin-vue' 3 | import deps from 'deyu-deps' 4 | import { defineConfig } from 'vite' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig(({ mode }) => { 8 | const isDev = mode === 'development' 9 | const cdnScripts = { 10 | ...deps, 11 | // 或许总是应该引入最新的 12 | '@vanilla-pudding/message': 'https://unpkg.com/@vanilla-pudding/message/dist/index.js', 13 | } 14 | 15 | return { 16 | server: { 17 | port: 5177, 18 | }, 19 | plugins: [ 20 | vue(), 21 | transformUserScript({ 22 | scriptMeta: { 23 | name: '消息通信测试', 24 | match: '*://www.baidu.com/*', 25 | runAt: 'document_start', 26 | }, 27 | cdnScripts, 28 | }), 29 | ], 30 | build: { 31 | modulePreload: false, 32 | minify: 'terser', 33 | terserOptions: { 34 | compress: { 35 | drop_console: true, 36 | drop_debugger: true, 37 | pure_funcs: ['console.log'], 38 | }, 39 | }, 40 | rollupOptions: { 41 | output: { 42 | entryFileNames: '[name].js', 43 | format: 'esm', 44 | }, 45 | external: Object.keys(cdnScripts), 46 | }, 47 | }, 48 | } 49 | }) 50 | -------------------------------------------------------------------------------- /project/ext/src/lib/idb/file/test-2.js: -------------------------------------------------------------------------------- 1 | // @name 自动xx 2 | // @match *://*.edu.ai/* 3 | // @runAt document_end 4 | // @runWith esm 5 | // @run-with 无效value 6 | // @allFrames true 7 | // @world MAIN 8 | 9 | // 意料之外的 meta ↓↓↓↓↓↓ 10 | // ==UserScript== 11 | // @name 💯 懒人专用系列 ——— 视频下载 12 | // @namespace lr-toolbox-VideoDownload 13 | // @version 1.0.23 14 | // @description ⭕支持下载B站(bilibili),抖音,快手,西瓜视频,Youtube等网站视频。❌拒绝收费。⭕持续更新。 15 | // @author lanhaha 16 | // @icon https://lf1-cdn-tos.bytegoofy.com/goofy/ies/douyin_web/public/favicon.ico 17 | // @match *://*.douyin.com/* 18 | // @match *://*.kuaishou.com/* 19 | // @match *://*.ixigua.com/* 20 | // @match *://*.bilibili.com/* 21 | // @match *://*.youtube.com/* 22 | // @require https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/crypto-js/4.1.1/crypto-js.min.js 23 | // @grant GM_registerMenuCommand 24 | // @grant GM_setValue 25 | // @grant GM_getValue 26 | // @grant GM_deleteValue 27 | // @grant GM_download 28 | // @grant GM_setClipboard 29 | // @grant GM_xmlhttpRequest 30 | // @connect iesdouyin.com 31 | // @connect 47.99.158.118 32 | // @connect api.typechrome.com 33 | // @connect gitlab.com 34 | // ==/UserScript== 35 | 36 | // see doc: https://github.com/Xdy1579883916/vanilla-pudding?tab=readme-ov-file#matadata 37 | -------------------------------------------------------------------------------- /packages/create-vpu/template-vue/vite.config.js: -------------------------------------------------------------------------------- 1 | import { transformUserScript } from '@vanilla-pudding/vite-plugin' 2 | import vue from '@vitejs/plugin-vue' 3 | import { defineConfig } from 'vite' 4 | import pkg from './package.json' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig(({ mode }) => { 8 | // eslint-disable-next-line no-unused-vars 9 | const isDev = mode === 'development' 10 | // 可直接使用网络的ESM的CDN包 11 | const cdnScripts = { 12 | '@vanilla-pudding/message': 'https://unpkg.com/@vanilla-pudding/message/dist/index.js', 13 | } 14 | 15 | return { 16 | server: { 17 | port: 5177, 18 | }, 19 | plugins: [ 20 | vue(), 21 | transformUserScript({ 22 | scriptMeta: { 23 | name: pkg.name, 24 | match: '*://www.baidu.com/*', 25 | }, 26 | cdnScripts, 27 | }), 28 | ], 29 | build: { 30 | modulePreload: false, 31 | // minify: "terser", 32 | // terserOptions: { 33 | // compress: { 34 | // drop_console: true, 35 | // drop_debugger: true, 36 | // pure_funcs: ["console.log"], 37 | // }, 38 | // }, 39 | rollupOptions: { 40 | output: { 41 | entryFileNames: '[name].js', 42 | format: 'esm', 43 | }, 44 | external: Object.keys(cdnScripts), 45 | }, 46 | }, 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /project/ext/src/lib/user-script.ts: -------------------------------------------------------------------------------- 1 | import { parseCode } from '@/util/parseCode.ts' 2 | 3 | export function isUserScriptsAPIAvailable() { 4 | try { 5 | chrome.userScripts.getScripts() 6 | return true 7 | } 8 | catch { 9 | return false 10 | } 11 | } 12 | 13 | export async function configureCSP() { 14 | await chrome.userScripts.configureWorld({ 15 | csp: 'default-src * data: blob: \'unsafe-eval\' \'unsafe-inline\'', 16 | }) 17 | } 18 | 19 | export async function unregisterAllUserScripts() { 20 | await chrome.userScripts.unregister() 21 | } 22 | 23 | export async function registerUserScript({ id, code, runWith, updateURLs, name, enabled, ...other }) { 24 | await unregisterUserScript(id) 25 | console.log('info', { id, code, updateURLs, name }, other) 26 | 27 | // 目前支持的配置 28 | const keys = [ 29 | 'runAt', 30 | 'matches', 31 | 'excludeMatches', 32 | 'excludeGlobs', 33 | 'includeGlobs', 34 | 'allFrames', 35 | 'world', 36 | ] 37 | 38 | const config = keys.reduce((pre, key) => { 39 | const val = other[key] 40 | if (val) { 41 | pre[key] = val 42 | } 43 | return pre 44 | }, {}) 45 | 46 | if (name) { 47 | await chrome.userScripts.register([{ 48 | ...config, 49 | id, 50 | js: [{ code: parseCode(code, runWith) }], 51 | }]) 52 | } 53 | } 54 | 55 | export async function unregisterUserScript(id) { 56 | try { 57 | await chrome.userScripts.unregister({ ids: [id] }) 58 | } 59 | catch { 60 | // 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /project/ext/src/lib/caller.ts: -------------------------------------------------------------------------------- 1 | import { getRow } from '@/lib/tool.ts' 2 | 3 | interface Result { 4 | success: boolean 5 | data?: any 6 | msg?: any 7 | // 插件需要用到 8 | callbackId?: string 9 | 10 | [k: string]: any 11 | } 12 | 13 | interface BackgroundAction { 14 | // 回调id 15 | callbackId: string 16 | // 方法名 17 | funName: string 18 | // 参数 19 | args: any[] 20 | 21 | [k: string]: any 22 | } 23 | 24 | type ChromeResultType = (callbackId: string, data: any, msg?: any) => Result 25 | 26 | const extError: ChromeResultType = function (callbackId: string, data: any, msg?: any): Result { 27 | return { 28 | callbackId, 29 | success: false, 30 | data, 31 | msg, 32 | } 33 | } 34 | 35 | const extSuccess: ChromeResultType = function (callbackId: string, data: any, msg?: any): Result { 36 | return { 37 | callbackId, 38 | success: true, 39 | data, 40 | msg, 41 | } 42 | } 43 | 44 | export async function caller(backgroundTool: any, action: BackgroundAction): Promise { 45 | const { callbackId, funName, args } = action 46 | try { 47 | if (!callbackId || !funName) { 48 | throw new Error('不支持的脚本类型!') 49 | } 50 | const fun = getRow(backgroundTool, funName, null) 51 | if (!fun) { 52 | throw new Error('不支持的脚本类型!') 53 | } 54 | const res = await fun(...(args || [])) 55 | return Promise.resolve(extSuccess(callbackId, res)) 56 | } 57 | catch (e) { 58 | let msg = e.message || e || '' 59 | if (msg.includes('Extension context invalidated.')) { 60 | msg = '插件关闭了!' 61 | } 62 | 63 | return Promise.resolve(extError(callbackId, action, msg)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /project/ext/wxt.config.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from 'vite' 2 | import type { WxtViteConfig } from 'wxt' 3 | import vue from '@vitejs/plugin-vue' 4 | import { defineConfig } from 'wxt' 5 | 6 | // See https://wxt.dev/api/config.html 7 | export default defineConfig({ 8 | srcDir: 'src', 9 | publicDir: 'public', 10 | modulesDir: 'modules', 11 | modules: [ 12 | '@wxt-dev/i18n/module', 13 | ], 14 | imports: { 15 | addons: { 16 | vueTemplate: true, 17 | }, 18 | }, 19 | webExt: { 20 | disabled: true, 21 | }, 22 | manifest: { 23 | default_locale: 'en', 24 | name: '__MSG_extName__', 25 | description: '__MSG_extDescription__', 26 | minimum_chrome_version: '120', 27 | permissions: [ 28 | 'storage', 29 | 'userScripts', 30 | 'activeTab', 31 | 'background', 32 | 'declarativeNetRequest', 33 | 'declarativeNetRequestFeedback', 34 | 'cookies', 35 | 'downloads', 36 | 'unlimitedStorage', 37 | ], 38 | host_permissions: [ 39 | '', 40 | ], 41 | }, 42 | vite: ({ mode }) => { 43 | const isDev = mode === 'development' 44 | 45 | function createMinifyOptions(): any { 46 | if (!isDev) { 47 | return { 48 | minify: 'terser', 49 | terserOptions: { 50 | compress: { 51 | drop_console: true, 52 | drop_debugger: true, 53 | pure_funcs: ['console.log'], 54 | }, 55 | }, 56 | } 57 | } 58 | return {} 59 | } 60 | 61 | return { 62 | plugins: [vue()], 63 | build: { 64 | modulePreload: { 65 | polyfill: false, 66 | }, 67 | ...createMinifyOptions(), 68 | target: 'esnext', 69 | // Enabling sourcemaps with Vue during development is known to cause problems with Vue 70 | sourcemap: false, 71 | }, 72 | } as UserConfig as WxtViteConfig 73 | }, 74 | }) 75 | -------------------------------------------------------------------------------- /project/vpu-test/src/main.js: -------------------------------------------------------------------------------- 1 | import { useExt } from '@vanilla-pudding/message' 2 | 3 | const bgt = useExt().bgt 4 | 5 | async function test() { 6 | const hello = await bgt.hello('你好') 7 | console.log('hello', hello) 8 | 9 | await bgt.extLocalStore.set('name', '张三', 1) 10 | const local_name = await bgt.extLocalStore.get('name') 11 | console.log('local_name', local_name) 12 | 13 | await bgt.extNamedStore.set('dxb', 'name', '张三', 1) 14 | const name = await bgt.extNamedStore.get('dxb', 'name') 15 | 16 | console.log('extNamedStore', name) 17 | 18 | // console.log(await bgt.ruleDNRTool.get()) 19 | await bgt.doRequestFy( 20 | 'GET', 21 | 'https://www.alibaba.com/trade/search?tab=supplier&SearchText=dress', 22 | ).then((res) => { 23 | console.log('doRequestFy', res) 24 | }) 25 | } 26 | 27 | // 测试文件上传 28 | async function test_file_upload() { 29 | const blob = await fetch('https://cbu01.alicdn.com/img/ibank/O1CN012lfu0w2A2vEHsglkb_!!2216208058146-0-cib.310x310.jpg').then(r => r.blob()) 30 | const blob_url = URL.createObjectURL(blob) 31 | 32 | // https://www.amazon.com/s?k=SHOP+THE+LOOK&ref=nb_sb_noss 33 | const url = 'https://www.amazon.com/stylesnap/upload' 34 | await useExt().bgt.doRequestFy( 35 | 'POST', 36 | url, 37 | { 38 | body: { 39 | 'explore-looks.jpg': { 40 | uri: blob_url, 41 | filename: 'explore-looks.jpg', 42 | }, 43 | 'fileFields': [ 44 | 'explore-looks.jpg', 45 | ], 46 | // or ↓ 47 | // "explore-looks.jpg": blob_url, 48 | // blobFields: [ 49 | // "explore-looks.jpg", 50 | // ] 51 | }, 52 | }, 53 | { 54 | content_type: 'formData', 55 | params: { 56 | stylesnapToken: 'hEfEmyJn5aUzNDSYmFLSonuTrHLUGFBrfwQDSaN%2BnpIYAAAAAGbttPUAAAAB', 57 | }, 58 | }, 59 | ).then((res) => { 60 | console.log(res) 61 | }) 62 | } 63 | 64 | window.bgt = bgt 65 | window.test = test 66 | window.test_file_upload = test_file_upload 67 | -------------------------------------------------------------------------------- /project/ext/src/lib/service/backgroundScriptService.ts: -------------------------------------------------------------------------------- 1 | import { ScriptDAO } from '@/lib/idb' 2 | import { registerUserScript, unregisterUserScript } from '@/lib/user-script.ts' 3 | import { guid } from '@/util/guid.ts' 4 | 5 | export class BackgroundScriptService extends ScriptDAO { 6 | constructor() { 7 | super() 8 | } 9 | 10 | generateUserScriptId() { 11 | return guid() 12 | }; 13 | 14 | async getUserScriptList() { 15 | const userScripts = await this.getAllUserScripts() 16 | return userScripts.map(x => ({ 17 | id: x.id, 18 | enabled: x.enabled, 19 | name: x.name, 20 | matches: x.matches, 21 | updateURLs: x.updateURLs, 22 | })) 23 | } 24 | 25 | async setUserScriptEnabled(id, enabled) { 26 | await this.updateUserScriptEnabled(id, enabled) 27 | if (enabled) { 28 | const userScript = await this.getUserScript(id) 29 | if (userScript) { 30 | await registerUserScript(userScript) 31 | } 32 | } 33 | else { 34 | await unregisterUserScript(id) 35 | } 36 | return null 37 | } 38 | 39 | async removeUserScript(id) { 40 | await this.deleteUserScript(id) 41 | await unregisterUserScript(id) 42 | return null 43 | } 44 | 45 | async removeAllUserScript() { 46 | const userScripts = await this.getAllUserScripts() 47 | for (const userScript of userScripts) { 48 | await this.removeUserScript(userScript.id) 49 | } 50 | return null 51 | } 52 | 53 | async setUserScript(id, code) { 54 | await this.upsertUserScript(id, code) 55 | const userScript = await this.getUserScript(id) 56 | if (userScript && userScript.enabled) { 57 | await registerUserScript(userScript) 58 | } 59 | return true 60 | } 61 | 62 | async upgradeUserScriptToLatest(id) { 63 | const userScript = await this.getUserScript(id) 64 | if (userScript) { 65 | for (const updateURL of userScript.updateURLs) { 66 | try { 67 | const code = await fetch(updateURL).then(res => res.text()) 68 | await this.setUserScript(id, code) 69 | return true 70 | } 71 | catch { 72 | // 73 | } 74 | } 75 | } 76 | return false 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /project/ext/src/lib/request/example.ts: -------------------------------------------------------------------------------- 1 | let backgroundToolService: any 2 | 3 | // 测试 get 请求 4 | async function test_get() { 5 | return backgroundToolService.doRequest('GET', 'https://www.alibaba.com/product-detail/2022-New-wholesale-sweet-love-roses_1600382993364.html') 6 | } 7 | 8 | // 测试 post 请求 9 | async function test_post() { 10 | const url = 'https://profile.alibaba.com/selection/ajax/product_detail_ajax.do' 11 | return backgroundToolService.doRequest( 12 | 'POST', 13 | url, 14 | {}, 15 | { 16 | productId: '1600382993364', 17 | countryCode: 'all', 18 | }, 19 | ) 20 | } 21 | 22 | // 测试 post 请求, +跨域配置 + diyHeaders 23 | async function test_post_by_cors() { 24 | const url = 'https://profile.alibaba.com/selection/ajax/product_detail_ajax.do' 25 | return backgroundToolService.doRequest( 26 | 'POST', 27 | url, 28 | { 29 | params: { 30 | t: Date.now(), 31 | }, 32 | meta: { 33 | cors: JSON.stringify({ 34 | monitorUrl: url, 35 | originValue: 'https://profile.alibaba.com', 36 | refererValue: 'https://profile.alibaba.com/profile/detail_buyer_select.htm', 37 | diyHeaders: [ 38 | { 39 | header: 'Sec-Ch-Ua', 40 | operation: 'set', 41 | value: 'vanilla pudding";v="119", "Chromium";v="119", "Not?A_Brand";v="24', 42 | }, 43 | { header: 'Sec-Ch-Ua-Mobile', operation: 'set', value: '?0' }, 44 | { header: 'Sec-Ch-Ua-Platform', operation: 'set', value: 'Windows' }, 45 | { header: 'bx-v', operation: 'set', value: '2.5.6' }, 46 | ], 47 | }), 48 | }, 49 | }, 50 | { 51 | productId: '1600382993364', 52 | countryCode: 'all', 53 | }, 54 | ) 55 | } 56 | 57 | async function test_meta() { 58 | const url = 'https://profile.alibaba.com/selection/ajax/product_detail_ajax.do' 59 | return backgroundToolService.doRequest( 60 | 'POST', 61 | url, 62 | { 63 | params: { 64 | productId: '1600382993364', 65 | countryCode: 'all', 66 | }, 67 | meta: { 68 | content_type: 'json', 69 | response_type: 'charset_encode', 70 | }, 71 | }, 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vanilla-pudding", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "private": true, 6 | "description": "香草布丁🌿🍮脚本管理", 7 | "author": "1579883916@qq.com", 8 | "scripts": { 9 | "preinit": "run-p rm:* && run-s step-1:*", 10 | "step-1:init": "pnpm i", 11 | "step-1:build": "pnpm build-packages", 12 | "rm:vite-plugin": "pnpm run -C packages/vite-plugin rm-all", 13 | "rm:message": "pnpm run -C packages/message rm-all", 14 | "rm:ext": "pnpm run -C project/ext rm-all", 15 | "rm:self": "rimraf node_modules", 16 | "build-packages": "run-s packages:*", 17 | "packages:vite-plugin": "pnpm run -C packages/vite-plugin build", 18 | "packages:message": "pnpm run -C packages/message build", 19 | "build:ext": "pnpm run -C project/ext build", 20 | "prepare": "simple-git-hooks" 21 | }, 22 | "dependencies": { 23 | "@webext-core/proxy-service": "^1.2.1", 24 | "@wxt-dev/i18n": "^0.1.1", 25 | "dexie": "^4.0.11", 26 | "deyu-deps": "^0.0.7", 27 | "monaco-editor": "^0.52.2", 28 | "naive-ui": "^2.41.1", 29 | "nanoid": "^5.1.5", 30 | "qs": "^6.14.0", 31 | "quick-fy": "^0.3.1", 32 | "vue": "^3.5.16" 33 | }, 34 | "devDependencies": { 35 | "@antfu/eslint-config": "^3.16.0", 36 | "@types/chrome": "latest", 37 | "@types/minimist": "^1.2.5", 38 | "@types/node": "latest", 39 | "@types/prompts": "^2.4.9", 40 | "@types/qs": "^6.14.0", 41 | "@vicons/fa": "^0.12.0", 42 | "@vicons/fluent": "^0.12.0", 43 | "@vitejs/plugin-vue": "^5.2.4", 44 | "autoprefixer": "^10.4.21", 45 | "eslint": "^9.29.0", 46 | "lint-staged": "^15.5.2", 47 | "npm-run-all2": "^6.2.6", 48 | "postcss": "^8.5.6", 49 | "rimraf": "^6.0.1", 50 | "sass": "^1.89.2", 51 | "simple-git-hooks": "^2.13.0", 52 | "tailwindcss": "^3.4.17", 53 | "terser": "^5.42.0", 54 | "typescript": "^5.8.3", 55 | "unplugin-auto-import": "^0.18.6", 56 | "unplugin-vue-components": "^0.27.5", 57 | "vite": "^5.4.19", 58 | "vite-plugin-dts": "^4.5.4", 59 | "vitest": "^2.1.9", 60 | "vue-tsc": "^2.2.10", 61 | "wxt": "^0.20.7" 62 | }, 63 | "simple-git-hooks": { 64 | "pre-commit": "pnpm lint-staged" 65 | }, 66 | "lint-staged": { 67 | "*": "eslint --fix" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /project/ext/src/lib/idb/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import code_test1 from './file/test-1.js?raw' 3 | import code_test2 from './file/test-2.js?raw' 4 | import code_test3 from './file/test-3.js?raw' 5 | import { parseMetadata } from './index.ts' 6 | 7 | describe('测试 parseMetadata', () => { 8 | it('不规范格式的meta容错性', () => { 9 | const parser = parseMetadata(code_test1) 10 | expect(parser).toMatchObject({ 11 | allFrames: true, 12 | excludeGlobs: [], 13 | excludeMatches: [], 14 | includeGlobs: [], 15 | matches: [ 16 | '*://*.edu.ai/*', 17 | '*://translate.google.com/*', 18 | '*://translate.google.cn/*', 19 | ], 20 | name: 'Google Translate Auto Languages', 21 | runAt: 'document_end', 22 | runWith: 'esm', 23 | updateURLs: [ 24 | 'https://update.greasyfork.org/scripts/378166/Google%20Translate%20Auto%20Languages.meta.js', 25 | ], 26 | world: 'MAIN', 27 | }) 28 | // expect(parser).toMatchInlineSnapshot() 29 | }) 30 | it('意料之外的 meta', () => { 31 | // 1、意料之外的 meta 虽然被正常解析,但最终会被忽略 32 | // 2、如果存在重复定义 33 | // a: 支持数组的数据会追加 34 | // b: 不支持数组的,后定义的 meta 会覆盖上一个 35 | // 3、忽略掉无效的 meta 36 | 37 | const parser = parseMetadata(code_test2) 38 | expect(parser).toMatchObject({ 39 | allFrames: true, 40 | excludeGlobs: [], 41 | excludeMatches: [], 42 | includeGlobs: [], 43 | matches: [ 44 | '*://*.edu.ai/*', 45 | '*://*.douyin.com/*', 46 | '*://*.kuaishou.com/*', 47 | '*://*.ixigua.com/*', 48 | '*://*.bilibili.com/*', 49 | '*://*.youtube.com/*', 50 | ], 51 | name: '💯 懒人专用系列 ——— 视频下载', 52 | runAt: 'document_end', 53 | runWith: 'esm', 54 | updateURLs: [], 55 | world: 'MAIN', 56 | }) 57 | // expect(parser).toMatchInlineSnapshot() 58 | }) 59 | it('具有一定的防御力', () => { 60 | const parser = parseMetadata(code_test3) 61 | expect(parser).toMatchObject({ 62 | allFrames: false, 63 | excludeGlobs: [], 64 | excludeMatches: [], 65 | includeGlobs: [], 66 | matches: [], 67 | name: 'new-script', 68 | runAt: 'document_idle', 69 | runWith: 'esm', 70 | updateURLs: [], 71 | world: 'USER_SCRIPT', 72 | }) 73 | // expect(parser).toMatchInlineSnapshot() 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /packages/vite-plugin/src/transform-userscript/index.ts: -------------------------------------------------------------------------------- 1 | type StrOrStrArr = string | string[] 2 | 3 | interface ScriptMeta { 4 | 'name': string 5 | 'world'?: chrome.userScripts.ExecutionWorld 6 | 'runAt'?: chrome.extensionTypes.RunAt 7 | 'run-at'?: chrome.extensionTypes.RunAt 8 | 'allFrames'?: boolean 9 | 'all-frames'?: boolean 10 | 'match'?: StrOrStrArr 11 | 'exclude-match'?: StrOrStrArr 12 | 'excludeMatch'?: StrOrStrArr 13 | 'exclude-glob'?: StrOrStrArr 14 | 'excludeGlob'?: StrOrStrArr 15 | 'include-glob'?: StrOrStrArr 16 | 'includeGlob'?: StrOrStrArr 17 | 'update-url'?: StrOrStrArr 18 | 'updateUrl'?: StrOrStrArr 19 | // 用户脚本注入的方式, 默认: esm 20 | 'run-with'?: 'esm' | 'raw' 21 | 'runWith'?: 'esm' | 'raw' 22 | } 23 | 24 | interface Options { 25 | isDev: boolean 26 | cdnScripts: Record 27 | scriptMeta: ScriptMeta 28 | } 29 | 30 | export function transformUserScript({ cdnScripts, scriptMeta }: Options) { 31 | const name = 'transformUserScript' 32 | // 将metaStr计算移到generateBundle外部以优化性能 33 | const metaStr = generateMetaStr(scriptMeta) 34 | 35 | return { 36 | name, 37 | enforce: 'post', 38 | async renderChunk(code: string, meta: { type: string }) { 39 | if (meta.type === 'chunk') { 40 | // 替换import的相对路径和动态导入 41 | const newCode = replaceImports(code, cdnScripts) 42 | return { 43 | code: newCode, 44 | } 45 | } 46 | return { code } 47 | }, 48 | async generateBundle(_, bundle) { 49 | for (const fileName in bundle) { 50 | const chunk = bundle[fileName] 51 | // 直接使用预先计算的metaStr 52 | chunk.code = metaStr + chunk.code 53 | } 54 | }, 55 | closeBundle() { 56 | console.log(`[Plugin] ${name} -> ending~`) 57 | }, 58 | } 59 | } 60 | 61 | export function replaceImports(code: string, cdnScripts: Record): string { 62 | let newCode = code 63 | Object.entries(cdnScripts).forEach(([scriptName, cdnURL]) => { 64 | newCode = newCode.replace(new RegExp(`(import|from)\\s?["'](${scriptName})["'];`, 'gi'), `$1 "${cdnURL}";`) 65 | }) 66 | return newCode 67 | } 68 | 69 | export function generateMetaStr(scriptMeta: ScriptMeta): string { 70 | return Object.entries(scriptMeta).reduce((pre, [k, v]) => { 71 | if (Array.isArray(v)) { 72 | v.forEach((item) => { 73 | pre += `// @${k} ${item}\n` 74 | }) 75 | } 76 | else { 77 | pre += `// @${k} ${v}\n` 78 | } 79 | return pre 80 | }, '') 81 | } 82 | -------------------------------------------------------------------------------- /project/ext/src/lib/rules/index.ts: -------------------------------------------------------------------------------- 1 | import { parse2Hash, parseURL } from '@/lib/tool.ts' 2 | 3 | enum RuleActionType { 4 | BLOCK = 'block', 5 | REDIRECT = 'redirect', 6 | ALLOW = 'allow', 7 | UPGRADE_SCHEME = 'upgradeScheme', 8 | MODIFY_HEADERS = 'modifyHeaders', 9 | ALLOW_ALL_REQUESTS = 'allowAllRequests', 10 | } 11 | 12 | /** 这描述了网络请求的资源类型. */ 13 | enum ResourceType { 14 | MAIN_FRAME = 'main_frame', 15 | SUB_FRAME = 'sub_frame', 16 | STYLESHEET = 'stylesheet', 17 | SCRIPT = 'script', 18 | IMAGE = 'image', 19 | FONT = 'font', 20 | OBJECT = 'object', 21 | XMLHTTPREQUEST = 'xmlhttprequest', 22 | PING = 'ping', 23 | CSP_REPORT = 'csp_report', 24 | MEDIA = 'media', 25 | WEBSOCKET = 'websocket', 26 | OTHER = 'other', 27 | } 28 | 29 | /** 这描述了“modifyHeaders”规则的可能操作. */ 30 | enum HeaderOperation { 31 | APPEND = 'append', 32 | SET = 'set', 33 | REMOVE = 'remove', 34 | } 35 | 36 | const AllResourceType: ResourceType[] = [ 37 | ResourceType.MAIN_FRAME, 38 | ResourceType.SUB_FRAME, 39 | ResourceType.STYLESHEET, 40 | ResourceType.SCRIPT, 41 | ResourceType.IMAGE, 42 | ResourceType.FONT, 43 | ResourceType.OBJECT, 44 | ResourceType.XMLHTTPREQUEST, 45 | ResourceType.PING, 46 | ResourceType.CSP_REPORT, 47 | ResourceType.MEDIA, 48 | ResourceType.WEBSOCKET, 49 | ResourceType.OTHER, 50 | ] 51 | 52 | export interface TWdeCors { 53 | originValue: string 54 | refererValue: string 55 | monitorUrl: string 56 | monitorDomain?: string 57 | diyHeaders?: Array<{ 58 | operation: 'append' | 'set' | 'remove' 59 | header: string 60 | value: string 61 | }> 62 | } 63 | 64 | export class RuleDNRTool { 65 | async update(opt: any): Promise { 66 | await chrome.declarativeNetRequest.updateDynamicRules(opt) 67 | } 68 | 69 | async rm(ids: number[] | undefined): Promise { 70 | if (ids && ids.length > 0) { 71 | await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: ids }) 72 | } 73 | } 74 | 75 | async clear(): Promise { 76 | const rules = await chrome.declarativeNetRequest.getDynamicRules() 77 | for (const rule of rules) { 78 | await this.rm([rule.id]) 79 | } 80 | } 81 | 82 | async get(id: number | undefined): Promise { 83 | const rules = await chrome.declarativeNetRequest.getDynamicRules() 84 | return id !== undefined ? rules.filter(v => v.id === id) : rules 85 | } 86 | 87 | async addByHeader(opt: TWdeCors): Promise { 88 | const { originValue, refererValue, monitorUrl, monitorDomain, diyHeaders } = opt 89 | const host = parseURL(monitorUrl).host 90 | const id = parse2Hash(originValue) 91 | const rule = { 92 | id, 93 | priority: 3, 94 | action: { 95 | type: RuleActionType.MODIFY_HEADERS, 96 | requestHeaders: [ 97 | { header: 'Origin', operation: HeaderOperation.SET, value: originValue }, 98 | { header: 'Referer', operation: HeaderOperation.SET, value: refererValue }, 99 | ...(diyHeaders || []), 100 | ], 101 | }, 102 | condition: { 103 | urlFilter: `*${host}*`, 104 | resourceTypes: AllResourceType, 105 | }, 106 | } 107 | await this.update({ addRules: [rule], removeRuleIds: [id] }) 108 | } 109 | 110 | async rmByHeader(opt: TWdeCors): Promise { 111 | const { originValue } = opt 112 | const id = parse2Hash(originValue) 113 | await this.rm([id]) 114 | } 115 | } 116 | 117 | // DNR: declarativeNetRequest 118 | export const ruleDNRTool = new RuleDNRTool() 119 | -------------------------------------------------------------------------------- /project/ext/src/lib/storage/index.ts: -------------------------------------------------------------------------------- 1 | import { first } from 'lodash-es' 2 | import { joinToStr, parseToReg } from '@/lib/tool.ts' 3 | 4 | export interface TSetByKeyArrOpt { 5 | expired?: number 6 | joinStr?: string 7 | } 8 | 9 | type StorageType = 'sync' | 'local' | 'session' 10 | 11 | const StorageInsNameMap = { 12 | sync: chrome.storage.sync, 13 | local: chrome.storage.local, 14 | session: chrome.storage.session, 15 | } 16 | 17 | export class StorageInstance { 18 | private store: chrome.storage.SyncStorageArea | chrome.storage.LocalStorageArea | chrome.storage.SessionStorageArea 19 | 20 | constructor(name: StorageType) { 21 | this.store = StorageInsNameMap[name] 22 | } 23 | 24 | async _get(keys?: string | string[] | { [key: string]: any } | null): Promise { 25 | return this.store.get(keys || null) 26 | } 27 | 28 | set(key: string, value: any, expireTime?: number): Promise { 29 | const data: { [key: string]: any } = { [key]: value } 30 | if (expireTime) { 31 | data[`${key}_expire`] = Date.now() + expireTime * 864e5 32 | } 33 | return this.store.set(data) 34 | } 35 | 36 | setByKeyArr(keyArr: string[], value: any, { expired, joinStr = '_' }: TSetByKeyArrOpt = {}): Promise { 37 | return this.set(joinToStr(keyArr, joinStr), value, expired) 38 | } 39 | 40 | async get(key?: string): Promise { 41 | if (!key) { 42 | return this._get() 43 | } 44 | const item = await this._get([key, `${key}_expire`]) 45 | return item[key] || null 46 | } 47 | 48 | async getByStrict(key?: string): Promise { 49 | if (!key) { 50 | await this.cleanAllExpireData() 51 | return this._get() 52 | } 53 | 54 | const item = await this._get([key, `${key}_expire`]) 55 | const expireTime = item[`${key}_expire`] 56 | if (expireTime && Date.now() >= Number(expireTime)) { 57 | await this.remove(key) 58 | return null 59 | } 60 | return item[key] || null 61 | } 62 | 63 | async findByReg(pattern: RegExp, mode: 'keys' | 'values' | 'entries' | 'one' = 'keys'): Promise { 64 | const data = await this._get() 65 | const new_pattern = parseToReg(pattern) 66 | const keys = Object.keys(data).filter(key => new_pattern.test(key)) 67 | 68 | switch (mode) { 69 | case 'values': 70 | return keys.map(key => data[key]) 71 | case 'entries': 72 | return keys.reduce((acc, key) => ({ ...acc, [key]: data[key] }), {}) 73 | case 'one': { 74 | const key = first(keys) 75 | return key ? data[key] : null 76 | } 77 | default: 78 | return keys 79 | } 80 | } 81 | 82 | remove(key: string): Promise { 83 | return this.store.remove([key, `${key}_expire`]) 84 | } 85 | 86 | removeByKeys(keys: string[]): Promise { 87 | if (!keys.length) 88 | return Promise.resolve() 89 | const newKeys = keys.flatMap(key => [key, `${key}_expire`]) 90 | return this.store.remove(newKeys) 91 | } 92 | 93 | removeAll(): Promise { 94 | return this.store.clear() 95 | } 96 | 97 | async removeByReg(pattern: RegExp): Promise { 98 | const keys: string[] = await this.findByReg(pattern) 99 | await this.removeByKeys(keys) 100 | } 101 | 102 | async cleanAllExpireData(): Promise { 103 | const expiredObject = await this.findByReg(/.*_expire/, 'entries') 104 | const needRemoveKeys = Object.entries(expiredObject) 105 | .filter(([, expired]) => Date.now() >= Number(expired)) 106 | .map(([k]) => k.replace('_expire', '')) 107 | 108 | await this.removeByKeys(needRemoveKeys) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /project/ext/src/entrypoints/editor/components/codemirror.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | 18 | 130 | -------------------------------------------------------------------------------- /project/ext/src/entrypoints/background.ts: -------------------------------------------------------------------------------- 1 | import { registerBackgroundScriptService } from '@/lib/rpc/backgroundScriptRPC.ts' 2 | import { registerBackgroundToolService } from '@/lib/rpc/backgroundToolRPC.ts' 3 | import { 4 | configureCSP, 5 | isUserScriptsAPIAvailable, 6 | registerUserScript, 7 | unregisterAllUserScripts, 8 | } from '@/lib/user-script.ts' 9 | 10 | export default defineBackground(() => { 11 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => true) 12 | // 后台 worker 每5分钟就会休眠,所以定时唤醒一下 13 | function keepServiceWorkerAlive() { 14 | setInterval(async () => { 15 | await chrome.runtime.getPlatformInfo() 16 | }, 4e3) 17 | } 18 | 19 | console.log('Hello background!', { id: chrome.runtime.id }) 20 | const backgroundScriptService = registerBackgroundScriptService() 21 | const backgroundToolService = registerBackgroundToolService() 22 | 23 | Object.assign(self, { 24 | backgroundScriptService, 25 | backgroundToolService, 26 | }) 27 | 28 | console.log('background - service', self) 29 | 30 | const LaunchReason = { 31 | Install: 0, 32 | Update: 1, 33 | Enable: 2, 34 | Activate: 3, 35 | } 36 | const LaunchReasonMap = Object.fromEntries(Object.entries(LaunchReason).map(([k, v]) => [v, k])) 37 | 38 | const installDetailsPromise = new Promise((resolve) => { 39 | chrome.runtime.onInstalled.addListener(resolve) 40 | }) 41 | 42 | class TimeoutError extends Error { 43 | } 44 | 45 | function timeout(ms) { 46 | return new Promise((_, reject) => { 47 | setTimeout(() => reject(new TimeoutError('timeout')), ms) 48 | }) 49 | } 50 | 51 | function assert(condition, message = 'Assertion failed') { 52 | if (!condition) 53 | throw new Error(message) 54 | } 55 | 56 | async function waitForLaunch() { 57 | const sessionStore = backgroundToolService.extSessionStore 58 | const activeFlagKey = '__ActiveFlag__' 59 | const isActive = await sessionStore.get(activeFlagKey) 60 | if (isActive) { 61 | return { reason: LaunchReason.Activate } 62 | } 63 | else { 64 | await sessionStore.set(activeFlagKey, true) 65 | try { 66 | const details = (await Promise.race([ 67 | installDetailsPromise, // 在一般启动时, 该Promise永远不会完成, 他只在安装、更新时完成 68 | timeout(1000), // 如果超时, 说明是一般启动. 69 | ])) as chrome.runtime.InstalledDetails 70 | switch (details.reason) { 71 | case 'install': { 72 | return { reason: LaunchReason.Install } 73 | } 74 | case 'update': { 75 | assert(details.previousVersion, 'The details.previousVersion is undefined, which is unexpected.') 76 | return { 77 | reason: LaunchReason.Update, 78 | previousVersion: details.previousVersion, 79 | } 80 | } 81 | default: { 82 | return { reason: LaunchReason.Enable } 83 | } 84 | } 85 | } 86 | catch (e) { 87 | if (e instanceof TimeoutError) { 88 | return { reason: LaunchReason.Enable } 89 | } 90 | else { 91 | throw e 92 | } 93 | } 94 | } 95 | } 96 | 97 | async function migrate(previousVersion) { 98 | await pipeAsync(previousVersion) 99 | } 100 | 101 | async function pipeAsync(value, ...operators) { 102 | let result = await value 103 | for (const operator of operators) { 104 | result = await operator(result) 105 | } 106 | return result 107 | } 108 | 109 | waitForLaunch().then(async (details) => { 110 | console.info(`Launched by ${LaunchReasonMap[details.reason]}`, details) 111 | switch (details.reason) { 112 | case LaunchReason.Install: { 113 | // 在安装后初始化. 114 | break 115 | } 116 | case LaunchReason.Update: { 117 | // 在升级后执行迁移. 118 | await migrate(details.previousVersion) 119 | break 120 | } 121 | } 122 | try { 123 | keepServiceWorkerAlive() 124 | if (isUserScriptsAPIAvailable()) { 125 | await configureCSP() 126 | await unregisterAllUserScripts() 127 | const enabledScripts = await backgroundScriptService.getAllEnabledUserScripts() 128 | for (const userScript of enabledScripts) { 129 | await registerUserScript(userScript) 130 | } 131 | } 132 | } 133 | catch (e) { 134 | // 浏览器没有开启开发者模式 135 | console.error(e) 136 | } 137 | }) 138 | }) 139 | -------------------------------------------------------------------------------- /project/ext/src/lib/tool.ts: -------------------------------------------------------------------------------- 1 | import { get, isBoolean, isNil, isNumber } from 'lodash-es' 2 | 3 | export function getRow(row: any, path: string | string[], defaultValue: any = '-', parseFun?: (...arg: any) => unknown) { 4 | const value: any = get(row, path) 5 | if (!isNil(value) && parseFun) 6 | return fmt(parseFun(value), defaultValue) 7 | 8 | return fmt(value, defaultValue) 9 | } 10 | 11 | function fmt(v: any, defaultValue: any) { 12 | if (isNumber(v) || isBoolean(v)) 13 | return v 14 | return v || defaultValue 15 | } 16 | 17 | export function parseArr(data, spec = false) { 18 | if (Array.isArray(data)) 19 | return data 20 | 21 | if (data) { 22 | try { 23 | if (isJSONStr(data)) 24 | return JSON.parse(data) 25 | 26 | if (spec) { 27 | return data 28 | .replace(/^\[|\]$/g, '') 29 | .split(/,/) 30 | .map(v => v.trim()) 31 | } 32 | return [] 33 | } 34 | catch (e) { 35 | return [] 36 | } 37 | } 38 | else { 39 | return [] 40 | } 41 | } 42 | 43 | export function isJSONStr(str: string) { 44 | try { 45 | JSON.parse(str) 46 | return true 47 | } 48 | catch (e) { 49 | return false 50 | } 51 | } 52 | 53 | // 安全的解析json 54 | export function parseJson(data: any): any { 55 | if (typeof data === 'object') 56 | return data || {} 57 | try { 58 | return JSON.parse(data) || {} 59 | } 60 | catch (e) { 61 | return {} 62 | } 63 | } 64 | 65 | export function parseFilterArr(arr) { 66 | return parseArr(arr).filter(Boolean) 67 | } 68 | 69 | export function appendProtocol(url, protocol = 'https:') { 70 | if (/^\/\//.test(url)) 71 | return protocol + url 72 | else if (!/:\/\//.test(url)) 73 | return `${protocol}//${url}` 74 | else 75 | return url 76 | } 77 | 78 | export function parseURL(url) { 79 | const getHostName = (url2) => { 80 | const match = url2.match(/:\/\/(www\d?\.)?(.[^/:]+)/i) 81 | if (match != null && match.length > 2 && typeof match[2] === 'string' && match[2].length > 0) 82 | return match[2] 83 | else return null 84 | } 85 | const getDomain = (url2) => { 86 | const hostName = getHostName(url2) 87 | let domain = hostName 88 | if (hostName != null) { 89 | const parts = hostName.split('.').reverse() 90 | if (parts != null && parts.length > 1) { 91 | domain = `${parts[1]}.${parts[0]}` 92 | if (hostName.toLowerCase().includes('.co.uk') && parts.length > 2) 93 | domain = `${parts[2]}.${domain}` 94 | } 95 | } 96 | return domain 97 | } 98 | const a = new URL(appendProtocol(url)) 99 | return { 100 | href: url, 101 | url: `${a.protocol}//${a.hostname}${a.pathname.replace(/^([^/])/, '/$1')}${a.search}`, 102 | origin: `${a.protocol}//${a.hostname}`, 103 | protocol: a.protocol, 104 | host: a.hostname, 105 | port: a.port, 106 | pathname: a.pathname.replace(/^([^/])/, '/$1'), 107 | search: a.search, 108 | hash: a.hash.replace('#', ''), 109 | params: (function () { 110 | const ret = {} 111 | const seg = a.search.replace(/^\?/, '').split('&') 112 | const len = seg.length 113 | let i = 0 114 | for (; i < len; i++) { 115 | if (!seg[i]) 116 | continue 117 | 118 | const [key, value] = seg[i].split('=') 119 | ret[key] = value 120 | } 121 | return ret 122 | })(), 123 | file: (a.pathname.match(/\/([^/?#]+)$/i) || [''])[1], 124 | domain: getDomain(url), 125 | } 126 | } 127 | 128 | export function parse2Hash(data: any): number { 129 | const str = String(data || '') 130 | 131 | let hash = 0 132 | let i 133 | let chr 134 | if (str.length === 0) 135 | return hash 136 | for (i = 0; i < str.length; i++) { 137 | chr = str.charCodeAt(i) 138 | hash = (hash << 5) - hash + chr 139 | hash |= 0 140 | } 141 | return Math.abs(hash) 142 | } 143 | 144 | export function joinToStr(arr, joinStr = '_', filter = true) { 145 | if (!Array.isArray(arr)) { 146 | return JSON.stringify(arr) || '' 147 | } 148 | if (filter) { 149 | arr = arr.filter(Boolean) 150 | } 151 | return arr.join(joinStr) 152 | } 153 | 154 | export function clone(...args) { 155 | function fn(v) { 156 | return typeof v === 'object' && v !== null ? JSON.parse(JSON.stringify(v)) : v 157 | } 158 | 159 | return args.length > 1 ? args.map(v => fn(v)) : fn(args[0]) 160 | } 161 | 162 | export function parseToReg(pattern) { 163 | return new RegExp(pattern) 164 | } 165 | 166 | export function check(data: any, type: string) { 167 | return Object.prototype.toString.call(data) === `[object ${type}]` 168 | } 169 | -------------------------------------------------------------------------------- /project/ext/src/lib/service/backgroundToolService.ts: -------------------------------------------------------------------------------- 1 | import { extRequest, extRequestFy } from '@/lib/request' 2 | import { RuleDNRTool } from '@/lib/rules' 3 | import { StorageInstance } from '@/lib/storage' 4 | import { NamedStorageInstance } from '@/lib/storage/NamedStorage' 5 | 6 | export class BackgroundToolService { 7 | extSessionStore: StorageInstance 8 | extSyncStore: StorageInstance 9 | extLocalStore: StorageInstance 10 | extNamedStore: NamedStorageInstance 11 | ruleDNRTool: RuleDNRTool 12 | // 请求 API 13 | doRequest = extRequest 14 | doRequestFy = extRequestFy 15 | 16 | constructor() { 17 | // session sync local 存储库 API 18 | this.extSessionStore = new StorageInstance('session') 19 | this.extSyncStore = new StorageInstance('sync') 20 | this.extLocalStore = new StorageInstance('local') 21 | 22 | // 使用 IndexDB 实现, 类似local带命名空间隔离的 storage API 23 | this.extNamedStore = new NamedStorageInstance() 24 | 25 | // DNR API 26 | this.ruleDNRTool = new RuleDNRTool() 27 | 28 | // 绑定所有方法以确保 `this` 上下文正确 29 | this.bindMethods(this.ruleDNRTool) 30 | this.bindMethods(this.extNamedStore) 31 | this.bindMethods(this.extSessionStore) 32 | this.bindMethods(this.extSyncStore) 33 | this.bindMethods(this.extLocalStore) 34 | } 35 | 36 | private bindMethods(objFun: any) { 37 | for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(objFun))) { 38 | if (typeof objFun[key] === 'function') { 39 | objFun[key] = objFun[key].bind(objFun) 40 | } 41 | } 42 | } 43 | 44 | async hello(msg?: string) { 45 | return `hi, this your msg: [${msg || 'null'}]` 46 | } 47 | 48 | // 下载文件 49 | async download(option: chrome.downloads.DownloadOptions) { 50 | return chrome.downloads.download(option) 51 | } 52 | 53 | // cookie API 54 | async cookieGet(option: chrome.cookies.CookieDetails) { 55 | return chrome.cookies.get(option) 56 | } 57 | 58 | async cookieGetAll(option: chrome.cookies.GetAllDetails) { 59 | return chrome.cookies.getAll(option) 60 | } 61 | 62 | async cookieSet(option: chrome.cookies.SetDetails) { 63 | return chrome.cookies.set(option) 64 | } 65 | 66 | async cookieRemove(option: chrome.cookies.CookieDetails) { 67 | return chrome.cookies.remove(option) 68 | } 69 | 70 | async cookieGetAllStores() { 71 | return chrome.cookies.getAllCookieStores() 72 | } 73 | 74 | // source API 75 | async getURL(path: string) { 76 | return chrome.runtime.getURL(path) 77 | } 78 | 79 | // windows API 80 | async windowsGet(windowId: number, queryOptions?: chrome.windows.QueryOptions) { 81 | return chrome.windows.get(windowId, queryOptions) 82 | } 83 | 84 | async windowsGetAll(queryOptions?: chrome.windows.QueryOptions) { 85 | return chrome.windows.getAll(queryOptions) 86 | } 87 | 88 | async windowsGetCurrent(queryOptions?: chrome.windows.QueryOptions) { 89 | return chrome.windows.getCurrent(queryOptions) 90 | } 91 | 92 | async windowsGetLastFocused(queryOptions?: chrome.windows.QueryOptions) { 93 | return chrome.windows.getLastFocused(queryOptions) 94 | } 95 | 96 | async windowsCreate(createData: chrome.windows.CreateData) { 97 | return chrome.windows.create(createData) 98 | } 99 | 100 | async windowsUpdate(windowId: number, updateInfo: chrome.windows.UpdateInfo) { 101 | return chrome.windows.update(windowId, updateInfo) 102 | } 103 | 104 | async windowsRemove(windowId: number) { 105 | return chrome.windows.remove(windowId) 106 | } 107 | 108 | // tab API 109 | async tabsRemove(tabId: number) { 110 | return chrome.tabs.remove(tabId) 111 | } 112 | 113 | async tabsQuery(queryInfo: chrome.tabs.QueryInfo) { 114 | return chrome.tabs.query(queryInfo || {}) 115 | } 116 | 117 | async tabsOpenPageByURL(url: string) { 118 | return this.tabsCreate({ url, active: true }) 119 | } 120 | 121 | async tabsCreate(createProperties: chrome.tabs.CreateProperties) { 122 | return chrome.tabs.create(createProperties) 123 | } 124 | 125 | async tabsGetActive(currentWin: boolean = true) { 126 | let windowId: number 127 | if (currentWin) { 128 | const currentWindow = await this.windowsGetLastFocused() 129 | windowId = currentWindow.id 130 | } 131 | return chrome.tabs.query({ active: true, windowId }) 132 | } 133 | 134 | async tabsGetActiveWindowId() { 135 | const [firstTab] = await this.tabsGetActive() 136 | return firstTab?.windowId 137 | } 138 | 139 | async tabsCaptureActiveTab(opt: chrome.extensionTypes.ImageDetails) { 140 | opt = Object.assign({ format: 'png' }, opt) 141 | const id = await this.tabsGetActiveWindowId() 142 | return chrome.tabs.captureVisibleTab(id, opt) 143 | } 144 | 145 | async tabsUpdate(tabId: number, updateProperties: chrome.tabs.UpdateProperties) { 146 | return chrome.tabs.update(tabId, updateProperties) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 香草布丁🌿🍮 用户脚本加载器和管理器 2 | 3 | ![logo](/project/ext/public/icon/128.png) 4 | 5 | ## 简体中文 | [English](./README_EN.md) 6 | 7 | ## 名称由来 8 | 9 | - JavaScript 又被大家称为 vanilla-js 10 | - 而脚本管理器类似于 JavaScript 的 “补丁” 11 | - 因此本项目被我称为 《香草布丁》 vanilla pudding。 12 | 13 | ## 插件安装与环境要求 14 | 15 | - 从 ChromeWebStore [安装](https://chrome.google.com/webstore/detail/fencadnndhdeggodopebjgdfdlhcimfk) 16 | - 适用于现代浏览器的简约 JavaScript 用户脚本加载器和管理器。 17 | - 为了使用此扩展程序,您需要 Chrome 120 或更高版本,并启用[开发者模式](https://www.tampermonkey.net/faq.php#Q209)。 18 | 19 | ## 你什么时候需要这个? 20 | 21 | - 一个现代的脚本管理器,默认支持[ESM](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 22 | 的脚本加载器 23 | - 一个类似 [Tampermonkey](https://www.tampermonkey.net/) ,但不支持 GM_* API的脚本管理器 24 | - 内置 MonacoEditor 25 | - 一个强大的管理器,支持来自插件的高级API能力 [服务源码](project/ext/src/lib/service/backgroundToolService.ts) 26 | - [x] Cookie `chrome.cookies` 27 | - [x] Tabs `chrome.tabs` 28 | - [x] Storage (`chrome.storage.sync`、`chrome.storage.local`、`chrome.storage.session`) 提供插件级存储能力。 29 | - [x] NamespaceStorage 基于 indexDB, 由 Dexie 驱动,提供带有命名空间的插件级存储能力。 30 | - [x] RuleDNRTool `chrome.declarativeNetRequest` 31 | - [x] 插件请求, 基于 [quick-fy](https://github.com/Xdy1579883916/quick-fy) 32 | - 请求跨域 33 | - 修改请求头 34 | - 使用示例模版,通过 vite 构建用户脚本(此部分下面有详细介绍)。 35 | 36 | ## MataData 37 | 38 | 您需要将元数据以注释的形式写在脚本的开头,格式请参考示例。 39 | 40 | ```ts 41 | type StrOrStrArr = string | string[] 42 | 43 | // 1、支持驼峰和短横线风格 44 | // 2、虽然这里的ts类型主要是为vite 插件提供类型提示, 但是命名仍然和注释语法保持一致,这与谷歌插件实际值略有出入 45 | interface ScriptMeta { 46 | // 用户脚本的名称 47 | 'name': string 48 | // 用户脚本的更新URL,这是可选的,点击更新按钮,插件将会访问该 URL 以使用户脚本保持最新。 49 | // 您可以通过多个指定多个更新URL,插件将按顺序逐一检查它们,直到找到可用的用户脚本 50 | 'update-url'?: StrOrStrArr 51 | 'updateUrl'?: StrOrStrArr 52 | // 下面的属性来自 chrome.userScripts.RegisteredUserScrip, 仅在支持的值为 StrOrStrArr 的属性名上略有出入 53 | 'world'?: chrome.userScripts.ExecutionWorld 54 | 'runAt'?: chrome.userScripts.RunAt 55 | 'run-at'?: chrome.userScripts.RunAt 56 | 'allFrames'?: boolean 57 | 'all-frames'?: boolean 58 | 'match'?: StrOrStrArr 59 | 'exclude-match'?: StrOrStrArr 60 | 'excludeMatch'?: StrOrStrArr 61 | 'exclude-glob'?: StrOrStrArr 62 | 'excludeGlob'?: StrOrStrArr 63 | 'include-glob'?: StrOrStrArr 64 | 'includeGlob'?: StrOrStrArr 65 | // 用户脚本注入的方式, 默认: esm 66 | 'run-with'?: 'esm' | 'raw' 67 | 'runWith'?: 'esm' | 'raw' 68 | } 69 | ``` 70 | 71 | ## 案例一 72 | 73 | ```js 74 | // @name allow-temu-translate 75 | // @match https://www.temu.com/* 76 | // @runAt document_start 77 | (function () { 78 | document.documentElement.setAttribute('translate', '') 79 | })() 80 | ``` 81 | 82 | ## 案例二 ESM 支持, 来自 [eternity](https://github.com/BlackGlory/eternity?tab=readme-ov-file#example) 83 | 84 | ```js 85 | // @name Hello World 86 | // @match 87 | import { addStyleSheet } from 'https://esm.sh/userstyle@0.2.1' 88 | 89 | addStyleSheet(` 90 | *:before { 91 | content: 'Hello' 92 | } 93 | 94 | *:after { 95 | content: 'World' 96 | } 97 | `) 98 | ``` 99 | 100 | ## 案例三 来自 greasyfork 的迁移 [google-translate-auto-languages](https://greasyfork.org/en/scripts/378166-google-translate-auto-languages/code) 101 | ```js 102 | // @name Google Translate Auto Languages 103 | // @runAt document_idle 104 | // @allFrames false 105 | // @world USER_SCRIPT 106 | // @runWith esm 107 | // @match *://translate.google.com/* 108 | // @match *://translate.google.cn/* 109 | 110 | 'use strict' 111 | const firstLangRule = /English|英语/ 112 | const firstLang = 'en' 113 | const secondLang = 'zh-CN' 114 | const detectTab = document.querySelector('[role=tab]') 115 | new MutationObserver(() => { 116 | const isFirstLang = firstLangRule.test(detectTab.textContent) 117 | const lang = isFirstLang ? secondLang : firstLang 118 | const selector = `[data-popup-corner]~* [data-language-code=${lang}]` 119 | const tab = document.querySelector(selector) 120 | if (tab.getAttribute('aria-selected') !== 'true') 121 | tab.click() 122 | }).observe(detectTab, { characterData: true, subtree: true }) 123 | if (detectTab.getAttribute('aria-selected') !== 'true') 124 | detectTab.click() 125 | ``` 126 | 127 | ## 更多高级使用,推荐使用示例模板 [create-vpu](https://www.npmjs.com/package/create-vpu) 128 | 129 | - create-vpu [源码](packages/create-vpu/package.json) 130 | - 创建你的第一个 vanilla-pudding 用户脚本项目。 `npm create vpu@latest` 131 | - 示例模板使用 vite 构建你的用户脚本, 132 | - build 后可以直接复制到用户脚本管理器中。也可以打包发布到 npm [就像这个工具](https://www.npmjs.com/package/dpms-tools)。 133 | - [香草布丁-通信包](packages/message/package.json) 提供了轻松使用插件的高级API。 134 | - [香草布丁-vite-plugin](packages/vite-plugin/package.json) 135 | - 自动按照配置生成注释,轻松设置MataData。 136 | - 支持 esm,减少打包体积。 137 | 138 | ## 技术分享 139 | 140 | - [wxt](https://wxt.dev/) 用于快速构建浏览器扩展。 141 | - [vite](https://vitejs.dev/) 我常用的前端构建工具 142 | - [@webext-core/proxy-service](https://webext-core.aklinker1.io/guide/proxy-service/) 用于 popup、content 和 background 143 | 之间的服务调用。 144 | - [message](packages/message) 为了用户脚本或浏览器页面与插件进行通信,我创建了这个库,它提供了调用插件的高级API。 145 | - 通过 `@webext-core/proxy-service` 使 `content.js` 具有 `background.js` 服务调用能力, `message` 将会与 `content.js` 146 | 建立连接, 进行服务调用。 147 | - 借鉴 `@webext-core/proxy-service` 的 Proxy, 为用户提供友好的 148 | 简单、类型安全的调用方案。[测试用例](project/vpu-test/src/main.js)、 [ts类型](packages/message/src/type.ts) 149 | 150 | ## 致谢 151 | 152 | - 受到 [eternity](https://github.com/BlackGlory/eternity) 的启发,创建了香草布丁。 153 | 154 | ## 许可证 155 | 156 | - 取自开源, 回馈开源, 本项目使用 [MIT License](LICENSE) 157 | - 本项目包含了 [eternity](https://github.com/BlackGlory/eternity) 158 | 的部分代码 [MIT License](https://github.com/BlackGlory/eternity/blob/master/LICENSE) 159 | - [第三方许可证](THIRD-PARTY-LICENSE) 160 | -------------------------------------------------------------------------------- /packages/message/src/type.ts: -------------------------------------------------------------------------------- 1 | import type { BooleanOptional, IStringifyOptions } from 'qs' 2 | 3 | export declare class NamedStorageInstance { 4 | private store 5 | 6 | constructor() 7 | 8 | _get(np: string, keys?: string | string[] | { 9 | [key: string]: any 10 | } | null): Promise 11 | 12 | set(np: string, key: string, value: any, expireTime?: number): Promise 13 | 14 | setByKeyArr(np: string, keyArr: string[], value: any, { expired, joinStr }?: { 15 | expired?: number 16 | joinStr?: string 17 | }): Promise 18 | 19 | get(np: string, key?: string): Promise 20 | 21 | getByStrict(np: string, key?: string): Promise 22 | 23 | findByReg(np: string, pattern: RegExp, mode?: 'keys' | 'values' | 'entries' | 'one'): Promise 24 | 25 | remove(np: string, key: string): Promise 26 | 27 | removeByKeys(np: string, keys: string[]): Promise 28 | 29 | removeAll(np: string): Promise 30 | 31 | removeByReg(np: string, pattern: RegExp): Promise 32 | 33 | cleanAllExpireData(np: string): Promise 34 | } 35 | 36 | export declare interface TSetByKeyArrOpt { 37 | expired?: number 38 | joinStr?: string 39 | } 40 | 41 | export declare type StorageType = 'sync' | 'local' | 'session' 42 | 43 | export declare class StorageInstance { 44 | private store 45 | 46 | constructor(name: StorageType) 47 | 48 | _get(keys?: string | string[] | { 49 | [key: string]: any 50 | } | null): Promise 51 | 52 | set(key: string, value: any, expireTime?: number): Promise 53 | 54 | setByKeyArr(keyArr: string[], value: any, { expired, joinStr }?: TSetByKeyArrOpt): Promise 55 | 56 | get(key?: string): Promise 57 | 58 | getByStrict(key?: string): Promise 59 | 60 | findByReg(pattern: RegExp, mode?: 'keys' | 'values' | 'entries' | 'one'): Promise 61 | 62 | remove(key: string): Promise 63 | 64 | removeByKeys(keys: string[]): Promise 65 | 66 | removeAll(): Promise 67 | 68 | removeByReg(pattern: RegExp): Promise 69 | 70 | cleanAllExpireData(): Promise 71 | } 72 | 73 | export declare interface TWdeCors { 74 | originValue: string 75 | refererValue: string 76 | monitorUrl: string 77 | monitorDomain?: string 78 | diyHeaders?: Array<{ 79 | operation: 'append' | 'set' | 'remove' 80 | header: string 81 | value: string 82 | }> 83 | } 84 | 85 | export declare class RuleDNRTool { 86 | update(opt: any): Promise 87 | 88 | rm(ids: number[] | undefined): Promise 89 | 90 | clear(): Promise 91 | 92 | get(id: number | undefined): Promise 93 | 94 | addByHeader(opt: TWdeCors): Promise 95 | 96 | rmByHeader(opt: TWdeCors): Promise 97 | } 98 | 99 | type ResponseType = 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream' | 'formData' | 'base64' | 'charset_encode' 100 | type ContentType = 'json' | 'form' | 'formData' 101 | type Arg = Record | string 102 | type MethodType = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'PATCH' 103 | interface Meta { 104 | cors: TWdeCors | string 105 | content_type: ContentType 106 | set_content_type: boolean 107 | response_type: ResponseType 108 | qs_options: IStringifyOptions 109 | params?: Arg 110 | [k: string]: any 111 | } 112 | type Config = { 113 | meta?: Partial 114 | params?: Arg 115 | } & Omit 116 | /** 117 | * 兼容旧版的请求 118 | * @deprecated 后续请使用 extRequestFy 代替 119 | */ 120 | export declare function extRequest(type: MethodType, url: string, config?: Config, data?: BodyInit): Promise 121 | export declare function extRequestFy(type: MethodType, url: string, config?: RequestInit, meta?: Partial): Promise 122 | 123 | export declare class BackgroundToolService { 124 | extSessionStore: StorageInstance 125 | extSyncStore: StorageInstance 126 | extLocalStore: StorageInstance 127 | extNamedStore: NamedStorageInstance 128 | ruleDNRTool: RuleDNRTool 129 | doRequest: typeof extRequest 130 | doRequestFy: typeof extRequestFy 131 | constructor() 132 | private bindMethods 133 | hello(msg?: string): Promise 134 | download(option: chrome.downloads.DownloadOptions): Promise 135 | cookieGet(option: chrome.cookies.CookieDetails): Promise 136 | cookieGetAll(option: chrome.cookies.GetAllDetails): Promise 137 | cookieSet(option: chrome.cookies.SetDetails): Promise 138 | cookieRemove(option: chrome.cookies.CookieDetails): Promise 139 | cookieGetAllStores(): Promise 140 | getURL(path: string): Promise 141 | windowsGet(windowId: number, queryOptions?: chrome.windows.QueryOptions): Promise 142 | windowsGetAll(queryOptions?: chrome.windows.QueryOptions): Promise 143 | windowsGetCurrent(queryOptions?: chrome.windows.QueryOptions): Promise 144 | windowsGetLastFocused(queryOptions?: chrome.windows.QueryOptions): Promise 145 | windowsCreate(createData: chrome.windows.CreateData): Promise 146 | windowsUpdate(windowId: number, updateInfo: chrome.windows.UpdateInfo): Promise 147 | windowsRemove(windowId: number): Promise 148 | tabsRemove(tabId: number): Promise 149 | tabsQuery(queryInfo: chrome.tabs.QueryInfo): Promise 150 | tabsOpenPageByURL(url: string): Promise 151 | tabsCreate(createProperties: chrome.tabs.CreateProperties): Promise 152 | tabsGetActive(currentWin?: boolean): Promise 153 | tabsGetActiveWindowId(): Promise 154 | tabsCaptureActiveTab(opt: chrome.extensionTypes.ImageDetails): Promise 155 | tabsUpdate(tabId: number, updateProperties: chrome.tabs.UpdateProperties): Promise 156 | } 157 | 158 | export {} 159 | -------------------------------------------------------------------------------- /project/ext/src/lib/storage/NamedStorage.ts: -------------------------------------------------------------------------------- 1 | import Dexie from 'dexie' 2 | import { first } from 'lodash-es' 3 | import { check, joinToStr, parseToReg } from '@/lib/tool.ts' 4 | 5 | interface StorageItem { 6 | namespace: string 7 | key: string 8 | value: any 9 | } 10 | 11 | export class NamespaceStorage extends Dexie { 12 | private data: Dexie.Table 13 | 14 | constructor() { 15 | super('NamespaceStorage') 16 | this.version(1).stores({ 17 | data: '[namespace+key], namespace, key', 18 | }) 19 | this.data = this.table('data') 20 | } 21 | 22 | async getAll(namespace: string): Promise<{ [key: string]: any }> { 23 | if (!namespace) { 24 | return null 25 | } 26 | const items = await this.data.where('namespace').equals(namespace).toArray() 27 | return items.reduce((acc, cur) => { 28 | acc[cur.key] = cur.value 29 | return acc 30 | }, {}) 31 | } 32 | 33 | async get(namespace: string, keys?: string | string[] | { [key: string]: any } | null): Promise<{ 34 | [key: string]: any 35 | }> { 36 | if (!keys) { 37 | return this.getAll(namespace) 38 | } 39 | 40 | if (check(keys, 'String')) { 41 | keys = [keys] 42 | } 43 | 44 | if (check(keys, 'Object')) { 45 | keys = Object.keys(keys) 46 | } 47 | 48 | if (Array.isArray(keys)) { 49 | const items = await this.data.where('[namespace+key]').anyOf(keys.map(key => [namespace, key])).toArray() 50 | return items.reduce((result, item) => { 51 | result[item.key] = item.value 52 | return result 53 | }, {}) 54 | } 55 | 56 | return null 57 | } 58 | 59 | async set(namespace: string, items: { [key: string]: any }): Promise { 60 | const dataToPut = Object.keys(items).map(key => ({ namespace, key, value: items[key] })) 61 | await this.data.bulkPut(dataToPut) 62 | } 63 | 64 | async remove(namespace: string, keys: string | string[]): Promise { 65 | if (typeof keys === 'string') { 66 | keys = [keys] 67 | } 68 | await this.data.bulkDelete(keys.map(key => [namespace, key])) 69 | } 70 | 71 | async clear(namespace: string): Promise { 72 | await this.data.where('namespace').equals(namespace).delete() 73 | } 74 | 75 | async keys(namespace: string): Promise { 76 | return (await this.data.where('namespace').equals(namespace).primaryKeys()).map(([namespace, key]) => key) 77 | } 78 | 79 | async length(namespace: string): Promise { 80 | return this.data.where('namespace').equals(namespace).count() 81 | } 82 | } 83 | 84 | export class NamedStorageInstance { 85 | private store: NamespaceStorage 86 | 87 | constructor() { 88 | this.store = new NamespaceStorage() 89 | } 90 | 91 | async _get(np: string, keys?: string | string[] | { [key: string]: any } | null): Promise { 92 | return this.store.get(np, keys || null) 93 | } 94 | 95 | set(np: string, key: string, value: any, expireTime?: number): Promise { 96 | const data: { [key: string]: any } = { [key]: value } 97 | if (expireTime) { 98 | data[`${key}_expire`] = Date.now() + expireTime * 864e5 99 | } 100 | return this.store.set(np, data) 101 | } 102 | 103 | setByKeyArr(np: string, keyArr: string[], value: any, { expired, joinStr = '_' }: { 104 | expired?: number 105 | joinStr?: string 106 | } = {}): Promise { 107 | return this.set(np, joinToStr(keyArr, joinStr), value, expired) 108 | } 109 | 110 | async get(np: string, key?: string): Promise { 111 | if (!key) { 112 | return this._get(np) 113 | } 114 | const item = await this._get(np, [key, `${key}_expire`]) 115 | return item[key] || null 116 | } 117 | 118 | async getByStrict(np: string, key?: string): Promise { 119 | if (!key) { 120 | await this.cleanAllExpireData(np) 121 | return this._get(np) 122 | } 123 | 124 | const item = await this._get(np, [key, `${key}_expire`]) 125 | const expireTime = item[`${key}_expire`] 126 | if (expireTime && Date.now() >= Number(expireTime)) { 127 | await this.remove(np, key) 128 | return null 129 | } 130 | return item[key] || null 131 | } 132 | 133 | async findByReg(np: string, pattern: RegExp, mode: 'keys' | 'values' | 'entries' | 'one' = 'keys'): Promise { 134 | const data = await this._get(np) 135 | const new_pattern = parseToReg(pattern) 136 | const keys = Object.keys(data).filter(key => new_pattern.test(key)) 137 | 138 | switch (mode) { 139 | case 'values': 140 | return keys.map(key => data[key]) 141 | case 'entries': 142 | return keys.reduce((acc, key) => ({ ...acc, [key]: data[key] }), {}) 143 | case 'one':{ 144 | const key = first(keys) 145 | return key ? data[key] : null 146 | } 147 | default: 148 | return keys 149 | } 150 | } 151 | 152 | remove(np: string, key: string): Promise { 153 | return this.store.remove(np, [key, `${key}_expire`]) 154 | } 155 | 156 | removeByKeys(np: string, keys: string[]): Promise { 157 | if (!keys.length) 158 | return Promise.resolve() 159 | const newKeys = keys.flatMap(key => [key, `${key}_expire`]) 160 | return this.store.remove(np, newKeys) 161 | } 162 | 163 | removeAll(np: string): Promise { 164 | return this.store.clear(np) 165 | } 166 | 167 | async removeByReg(np: string, pattern: RegExp): Promise { 168 | const keys: string[] = await this.findByReg(np, pattern) 169 | await this.removeByKeys(np, keys) 170 | } 171 | 172 | async cleanAllExpireData(np: string): Promise { 173 | const expiredObject = await this.findByReg(np, /.*_expire/, 'entries') 174 | const needRemoveKeys = Object.entries(expiredObject) 175 | .filter(([, expired]) => Date.now() >= Number(expired)) 176 | .map(([k]) => k.replace('_expire', '')) 177 | 178 | await this.removeByKeys(np, needRemoveKeys) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 | # Vanilla Pudding 🌿🍮 User Script Manager 2 | 3 | ![logo](/project/ext/public/icon/128.png) 4 | 5 | ## English | [简体中文](./README.md) 6 | 7 | ## Origin of the Name 8 | 9 | - JavaScript is commonly referred to as "vanilla-js". 10 | - And a script manager is akin to a "patch" for JavaScript. 11 | - Thus, this project is named **Vanilla Pudding**. 12 | 13 | ## Plugin Installation and Environment Requirements 14 | 15 | - from Chrome Web Store [Install](https://chrome.google.com/webstore/detail/fencadnndhdeggodopebjgdfdlhcimfk). 16 | 17 | - A minimalist JavaScript user script loader and manager for modern browsers. 18 | 19 | - To use this extension, you will need Chrome 120 or higher and 20 | have [Developer Mode enabled](https://www.tampermonkey.net/faq.php#Q209). 21 | 22 | ## When Do You Need This? 23 | 24 | - A modern script manager with default support for ESM script loaders. 25 | 26 | - A script manager similar to [Tampermonkey](https://www.tampermonkey.net/), but without support for GM_* APIs. 27 | 28 | - Built-in MonacoEditor. 29 | 30 | - A powerful manager that supports advanced API capabilities from the plugin. 31 | - [x] Cookie `chrome.cookies` 32 | - [x] Tabs `chrome.tabs` 33 | - [x] Storage (`chrome.storage.sync`、`chrome.storage.local`、`chrome.storage.session`) Provides plugin-level storage 34 | capabilities. 35 | - [x] NamespaceStorage Based on indexDB, driven by Dexie, provides plugin-level storage capabilities with 36 | namespaces. 37 | - [x] RuleDNRTool `chrome.declarativeNetRequest` 38 | - [x] Plugin requests, based on [quick-fy](https://github.com/Xdy1579883916/quick-fy) 39 | - Cross-domain requests 40 | - Modify request headers 41 | - Use example templates to build user scripts with vite (detailed introduction below). 42 | 43 | ## Metadata 44 | 45 | You need to write metadata in the form of comments at the beginning of the script, refer to the example for the format. 46 | 47 | ```ts 48 | type StrOrStrArr = string | string[] 49 | // 1. Supports camelCase and hyphen style 50 | // 2. Although the ts type here is mainly for type hints provided by the vite plugin, the naming still conforms to the comment syntax, which is slightly different from the actual values of Google plugins 51 | interface ScriptMeta { 52 | // The name of the user script 53 | 'name': string 54 | // The update URL of the user script, this is optional, when you click the update button, the plugin will access this URL to keep the user script up to date. 55 | // You can specify multiple update URLs, the plugin will check them in order until a usable user script is found. 56 | 'update-url'?: StrOrStrArr 57 | 'updateUrl'?: StrOrStrArr 58 | // The following properties come from chrome.userScripts.RegisteredUserScrip, with only slight differences in the property names that support values as StrOrStrArr 59 | 'world'?: chrome.userScripts.ExecutionWorld 60 | 'runAt'?: chrome.userScripts.RunAt 61 | 'run-at'?: chrome.userScripts.RunAt 62 | 'allFrames'?: boolean 63 | 'all-frames'?: boolean 64 | 'match'?: StrOrStrArr 65 | 'exclude-match'?: StrOrStrArr 66 | 'excludeMatch'?: StrOrStrArr 67 | 'exclude-glob'?: StrOrStrArr 68 | 'excludeGlob'?: StrOrStrArr 69 | 'include-glob'?: StrOrStrArr 70 | 'includeGlob'?: StrOrStrArr 71 | // The method of user script injection, default: esm 72 | 'run-with'?: 'esm' | 'raw' 73 | 'runWith'?: 'esm' | 'raw' 74 | } 75 | ``` 76 | 77 | ## Example One 78 | 79 | ```js 80 | // @name allow-temu-translate 81 | // @match https://www.temu.com/* 82 | // @runAt document_start 83 | (function () { 84 | document.documentElement.setAttribute('translate', '') 85 | })() 86 | ``` 87 | 88 | ## Example Two, ESM Support from [eternity](https://github.com/BlackGlory/eternity?tab=readme-ov-file#example) 89 | 90 | ```js 91 | // @name Hello World 92 | // @match 93 | import { addStyleSheet } from 'https://esm.sh/userstyle@0.2.1' 94 | 95 | addStyleSheet(` 96 | *:before { 97 | content: 'Hello' 98 | } 99 | 100 | *:after { 101 | content: 'World' 102 | } 103 | `) 104 | ``` 105 | 106 | ## Example Three, Migration from greasyfork [google-translate-auto-languages](https://greasyfork.org/en/scripts/378166-google-translate-auto-languages/code) 107 | ```js 108 | // @name Google Translate Auto Languages 109 | // @runAt document_idle 110 | // @allFrames false 111 | // @world USER_SCRIPT 112 | // @runWith esm 113 | // @match *://translate.google.com/* 114 | // @match *://translate.google.cn/* 115 | 116 | "use strict"; 117 | const firstLangRule = /English|英语/; 118 | const firstLang = "en"; 119 | const secondLang = "zh-CN"; 120 | const detectTab = document.querySelector("[role=tab]"); 121 | new MutationObserver(() => { 122 | const isFirstLang = firstLangRule.test(detectTab.textContent); 123 | const lang = isFirstLang ? secondLang : firstLang; 124 | const selector = `[data-popup-corner]~* [data-language-code=${lang}]`; 125 | const tab = document.querySelector(selector); 126 | if (tab.getAttribute("aria-selected") !== "true") tab.click(); 127 | }).observe(detectTab, { characterData: true, subtree: true }); 128 | if (detectTab.getAttribute("aria-selected") !== "true") detectTab.click(); 129 | 130 | ``` 131 | 132 | ## For more advanced usage, it is recommended to use the example template [create-vpu](https://www.npmjs.com/package/create-vpu) 133 | 134 | - create-vpu [Source Code](packages/create-vpu/package.json) 135 | - Create your first vanilla-pudding user script project. `npm create vpu@latest` 136 | - The example template uses vite to build your user script, 137 | - After build, you can directly copy it into the user script manager. It can also be packaged and published to npm like 138 | [this tool](https://www.npmjs.com/package/dpms-tools). 139 | - [vanilla-pudding-message](packages/message/package.json) Package provides easy-to-use plugins with advanced APIs. 140 | - [vanilla-pudding-vite-plugin](packages/vite-plugin/package.json) 141 | - Automatically generates annotations according to configuration, easily sets Metadata. 142 | - Supports esm, reducing packaging volume. 143 | 144 | ## Technical Sharing 145 | 146 | - [wxt](https://wxt.dev/) is used for quickly building browser extensions. 147 | - [vite](https://vitejs.dev/) is my commonly used front-end build tool. 148 | - [@webext-core/proxy-service](https://webext-core.aklinker1.io/guide/proxy-service/) is used for service calls between 149 | popup, content, and background. 150 | - [message](packages/message) To facilitate communication between user scripts or browser pages and the plugin, I 151 | created this library, which provides advanced API calls to the plugin. 152 | - Through `@webext-core/proxy-service`, `content.js` is endowed with the capability to call services 153 | from `background.js`, `message` will establish a connection with `content.js` to make service calls. 154 | - Drawing on the Proxy from `@webext-core/proxy-service`, it provides a user-friendly, simple, and type-safe calling 155 | solution. [Test cases](project/vpu-test/src/main.js), [ts types](packages/message/src/type.ts). 156 | 157 | ## Credits 158 | 159 | - Inspired by [eternity](https://github.com/BlackGlory/eternity), Vanilla Pudding was created. 160 | 161 | ## Licenses 162 | 163 | - From open source, giving back to open source, this project uses the [MIT License](LICENSE) 164 | - This project contains some code from [eternity](https://github.com/BlackGlory/eternity) under 165 | the [MIT License](https://github.com/BlackGlory/eternity/blob/master/LICENSE) 166 | - [THIRD-PARTY-LICENSE](THIRD-PARTY-LICENSE) 167 | -------------------------------------------------------------------------------- /project/ext/src/lib/idb/index.ts: -------------------------------------------------------------------------------- 1 | import type { IndexableType, Table } from 'dexie' 2 | import { Dexie } from 'dexie' 3 | import { trim, uniq } from 'lodash-es' 4 | import { isUserScriptsAPIAvailable } from '@/lib/user-script.ts' 5 | import { guid } from '@/util/guid.ts' 6 | 7 | class Database extends Dexie { 8 | userScripts: Table 9 | 10 | constructor() { 11 | super('Database') 12 | this.version(1).stores({ userScripts: '++id, enabled' }) 13 | this.version(2).stores({ userScripts: 'id, enabled' }).upgrade(async () => { 14 | await this.table('userScripts').toCollection().modify((userScript) => { 15 | userScript.id = guid() 16 | delete userScript.name 17 | delete userScript.urlPatterns 18 | }) 19 | }) 20 | this.version(3).stores({ userScripts: 'id, enabled' }).upgrade(async () => { 21 | await this.table('userScripts').toCollection().modify((userScript) => { 22 | userScript.id = guid() 23 | }) 24 | }) 25 | this.userScripts = this.table('userScripts') 26 | } 27 | } 28 | 29 | // 单复数映射 30 | const metaNamesMap = { 31 | matches: 'match', 32 | excludeMatches: 'excludeMatch', 33 | excludeGlobs: 'excludeGlob', 34 | includeGlobs: 'includeGlob', 35 | updateURLs: 'updateUrl', 36 | } 37 | 38 | export class ScriptDAO { 39 | private db: Database 40 | 41 | constructor() { 42 | this.db = new Database() 43 | } 44 | 45 | async getAllUserScripts() { 46 | const objects = await this.db.userScripts.toArray() 47 | return objects.map(convertUserScriptObjectToUserScript) 48 | } 49 | 50 | isSupportAPI() { 51 | return isUserScriptsAPIAvailable() 52 | } 53 | 54 | async getAllEnabledUserScripts(): Promise { 55 | const objects = await this.db.userScripts.where('enabled').equals( 56 | 1, 57 | /* True */ 58 | ).toArray() 59 | return objects.map(convertUserScriptObjectToUserScript) 60 | } 61 | 62 | async getUserScript(id) { 63 | const object2 = await this.db.userScripts.get(id) 64 | if (object2) { 65 | return convertUserScriptObjectToUserScript(object2) 66 | } 67 | else { 68 | return null 69 | } 70 | } 71 | 72 | async deleteUserScript(id) { 73 | await this.db.userScripts.delete(id) 74 | } 75 | 76 | async updateUserScriptEnabled(id, enabled) { 77 | await this.db.userScripts.update(id, { 78 | enabled: enabled ? 1 : 0, 79 | /* False */ 80 | }) 81 | } 82 | 83 | async upsertUserScript(id, code) { 84 | const newCode = fmtCodeByMeta(code) 85 | 86 | await this.db.transaction('rw', this.db.userScripts, async () => { 87 | const object2 = await this.db.userScripts.get(id) 88 | if (object2) { 89 | await this.db.userScripts.update(id, { 90 | code: newCode, 91 | }) 92 | } 93 | else { 94 | await this.db.userScripts.add({ 95 | id, 96 | code: newCode, 97 | enabled: 1, 98 | /* True */ 99 | }) 100 | } 101 | }) 102 | } 103 | } 104 | 105 | function convertUserScriptObjectToUserScript(obj): any { 106 | const metadata = parseMetadata(obj.code) 107 | return { 108 | ...metadata, 109 | id: obj.id, 110 | code: obj.code, 111 | enabled: obj.enabled === 1, 112 | } 113 | } 114 | 115 | function fmtCodeByMeta(code) { 116 | const meta = parseMetadata(code) 117 | 118 | for (const [metaNamePlural, metaNameSingular] of Object.entries(metaNamesMap)) { 119 | meta[metaNameSingular] = meta[metaNamePlural] 120 | delete meta[metaNamePlural] 121 | } 122 | return generateMetaStr(meta) + removeMetaInCode(code) 123 | } 124 | 125 | function removeMetaInCode(code) { 126 | return code.replace(/^\/\/\s*@(?\S+)(?.+?$|$)/gm, '').replace(/[\r\n]+/gi, '\n') 127 | } 128 | 129 | function generateMetaStr(scriptMeta) { 130 | return Object.entries(scriptMeta).reduce((pre, [k, v]) => { 131 | if (Array.isArray(v)) { 132 | v.forEach((item) => { 133 | pre += `// @${k} ${item}\n` 134 | }) 135 | } 136 | else { 137 | pre += `// @${k} ${v}\n` 138 | } 139 | return pre 140 | }, '') 141 | } 142 | 143 | export function parseMetadata(code) { 144 | const opt = { 145 | name: 'new-script', 146 | runAt: 'document_idle', 147 | matches: [], 148 | excludeMatches: [], 149 | excludeGlobs: [], 150 | includeGlobs: [], 151 | updateURLs: [], 152 | allFrames: false, // true, false 153 | world: 'USER_SCRIPT', // MAIN, USER_SCRIPT 154 | runWith: 'esm', // esm | raw 155 | } 156 | for (const { key, value } of parseMetadataLines(code)) { 157 | switch (key) { 158 | case 'name': { 159 | opt.name = value || 'new-script' 160 | break 161 | } 162 | case 'run-at': 163 | case 'runAt': { 164 | opt.runAt = checkMetadata(['document_idle', 'document_end', 'document_start'], value) 165 | break 166 | } 167 | case 'all-frames': 168 | case 'allFrames': { 169 | opt.allFrames = value === 'true' 170 | break 171 | } 172 | case 'match': { 173 | const match = parseMatchValue(value) 174 | if (match) 175 | opt.matches.push(match) 176 | break 177 | } 178 | case 'exclude-match': 179 | case 'excludeMatch': { 180 | const match = parseMatchValue(value) 181 | if (match) 182 | opt.excludeMatches.push(match) 183 | break 184 | } 185 | case 'exclude-glob': 186 | case 'excludeGlob': { 187 | const match = parseMatchValue(value) 188 | if (match) 189 | opt.excludeGlobs.push(match) 190 | break 191 | } 192 | case 'include-glob': 193 | case 'includeGlob': { 194 | const match = parseMatchValue(value) 195 | if (match) 196 | opt.includeGlobs.push(match) 197 | break 198 | } 199 | case 'update-url': 200 | case 'updateUrl': { 201 | const updateURL = parseUpdateURLValue(value) 202 | if (updateURL) 203 | opt.updateURLs.push(updateURL) 204 | break 205 | } 206 | case 'world': { 207 | opt.world = checkMetadata(['USER_SCRIPT', 'MAIN'], value) 208 | break 209 | } 210 | case 'run-with': 211 | case 'runWith': { 212 | // only esm | raw 213 | opt.runWith = checkMetadata(['esm', 'raw'], value) 214 | break 215 | } 216 | } 217 | } 218 | 219 | // 对数组项去重 220 | for (const item of Object.keys(metaNamesMap)) { 221 | opt[item] = uniq(opt[item]) 222 | } 223 | 224 | // fix: must specify at least one match 225 | if (!opt.matches.length) { 226 | opt.matches = [''] 227 | } 228 | 229 | return opt 230 | } 231 | 232 | function checkMetadata(rightList, value) { 233 | return rightList.includes(value) ? value : rightList[0] 234 | } 235 | 236 | function parseMatchValue(value) { 237 | const re2 = /^(?\S+)\s*$/ 238 | const matched = value.match(re2) 239 | if (!matched) 240 | return null 241 | const { pattern } = matched.groups 242 | return pattern 243 | } 244 | 245 | function isURLString(text) { 246 | try { 247 | // eslint-disable-next-line no-new 248 | new URL(text) 249 | return true 250 | } 251 | catch { 252 | return false 253 | } 254 | } 255 | 256 | function parseUpdateURLValue(value) { 257 | if (isURLString(value)) { 258 | return value 259 | } 260 | else { 261 | return null 262 | } 263 | } 264 | 265 | function* parseMetadataLines(code) { 266 | const exp = /^\/\/\s*@(?\S+)(?.+?$|$)/gm 267 | for (const { groups } of code.matchAll(exp)) { 268 | const { key, value } = groups 269 | 270 | yield { 271 | key, 272 | value: trim(value), 273 | } 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /packages/message/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { BackgroundToolService, TWdeCors } from './type' 2 | import { guid } from './guid' 3 | 4 | const messagePrefix = 'vanilla-pudding' 5 | 6 | export function createMessageKey(name: string) { 7 | return [messagePrefix, name].join('-') 8 | } 9 | 10 | export const ExtManifestKey = createMessageKey('manifest') 11 | export const Event4ChromeKey = createMessageKey('Event4Chrome') 12 | export const Event4PageKey = createMessageKey('Event4Page') 13 | 14 | export function emit(key: string, data: any, opt: any = {}) { 15 | const customEvent = new CustomEvent(key, { 16 | detail: data, 17 | bubbles: true, 18 | cancelable: true, 19 | ...opt, 20 | }) 21 | document.dispatchEvent(customEvent) 22 | } 23 | 24 | export function listen(key: string, callback: (data: any) => void, options: any = {}) { 25 | document.addEventListener(key, callback, options) 26 | } 27 | 28 | function withResolvers() { 29 | let resolve 30 | let reject 31 | const promise = new Promise((_resolve, _reject) => { 32 | resolve = _resolve 33 | reject = _reject 34 | }) 35 | return { resolve, reject, promise } 36 | } 37 | 38 | export function compareVersion(version1: string, version2: string) { 39 | const arrayA: Array = version1.split('.') 40 | const arrayB: Array = version2.split('.') 41 | 42 | let pointer = 0 43 | while (pointer < arrayA.length && pointer < arrayB.length) { 44 | const res = arrayA[pointer] - arrayB[pointer] 45 | if (res === 0) 46 | pointer++ 47 | else 48 | return res > 0 ? 1 : -1 49 | } 50 | // 若arrayA仍有小版本号 51 | while (pointer < arrayA.length) { 52 | if (+arrayA[pointer] > 0) 53 | return 1 54 | else 55 | pointer++ 56 | } 57 | // 若arrayB仍有小版本号 58 | while (pointer < arrayB.length) { 59 | if (+arrayB[pointer] > 0) 60 | return -1 61 | else 62 | pointer++ 63 | } 64 | // 版本号完全相同 65 | return 0 66 | } 67 | 68 | function __async(__this, __arguments, generator) { 69 | return new Promise((resolve, reject) => { 70 | function step(x) { 71 | return x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected) 72 | } 73 | 74 | function fulfilled(value) { 75 | try { 76 | step(generator.next(value)) 77 | } 78 | catch (e) { 79 | reject(e) 80 | } 81 | } 82 | 83 | function rejected(value) { 84 | try { 85 | step(generator.throw(value)) 86 | } 87 | catch (e) { 88 | reject(e) 89 | } 90 | } 91 | 92 | step((generator = generator.apply(__this, __arguments)).next()) 93 | }) 94 | } 95 | 96 | function createProxy(fun: any, path?: string): T { 97 | const wrapped = () => { 98 | } 99 | const proxy: any = new Proxy(wrapped, { 100 | apply(_target, _thisArg, args) { 101 | return __async(this, null, function* () { 102 | return yield fun(path, ...args) 103 | }) 104 | }, 105 | get(target, propertyName, receiver) { 106 | if (propertyName === '__proxy' || typeof propertyName === 'symbol') { 107 | return Reflect.get(target, propertyName, receiver) 108 | } 109 | return createProxy(fun, path == null ? propertyName : `${path}.${propertyName}`) 110 | }, 111 | }) 112 | proxy.__proxy = true 113 | return proxy 114 | } 115 | 116 | export class Extension { 117 | private callbacks: Map = new Map() 118 | private id: string = guid() 119 | private ext = document.documentElement 120 | private version: any 121 | private active: boolean = false 122 | private install_listeners: any[] = [] 123 | private close_listeners: any[] = [] 124 | 125 | bgt: BackgroundToolService 126 | 127 | constructor() { 128 | // 插件注入很快,此时没有就是没有了 129 | if (this.ext.hasAttribute(ExtManifestKey)) { 130 | this.dispatchOnInstall() 131 | } 132 | 133 | // 需要为 bgt 使用 Proxy 创建虚拟的代理, 转发到 post 函数上 134 | this.bgt = createProxy(this.post.bind(this)) 135 | } 136 | 137 | addOnInstallListener(listener: any, options = { once: true }) { 138 | this.install_listeners.push({ listener, options }) 139 | if (this.ext) { 140 | this.dispatchOnInstall() 141 | } 142 | } 143 | 144 | dispatchOnInstall() { 145 | this.active = true 146 | this.install_listeners.forEach(({ listener, options }) => { 147 | if (options && options.once) { 148 | this.install_listeners = this.install_listeners.filter(v => v !== listener) 149 | } 150 | listener.call(this, options) 151 | }) 152 | } 153 | 154 | addExtCloseListener(listener: any, options = { once: true }) { 155 | this.close_listeners.push({ listener, options }) 156 | if (!this.active) { 157 | this.dispatchExtClose() 158 | } 159 | } 160 | 161 | dispatchExtClose() { 162 | this.active = false 163 | this.close_listeners.forEach(({ listener, options }) => { 164 | if (options && options.once) { 165 | this.close_listeners = this.close_listeners.filter(v => v !== listener) 166 | } 167 | listener.call(this, options) 168 | }) 169 | this.close_listeners = [] 170 | } 171 | 172 | getVersion() { 173 | if (this.version) { 174 | return this.version 175 | } 176 | if (!this.ext) { 177 | return null 178 | } 179 | this.version = this.ext.getAttribute(ExtManifestKey) || '' 180 | return this.version 181 | } 182 | 183 | async check(minVersion = null) { 184 | if (!this.active) { 185 | // eslint-disable-next-line prefer-promise-reject-errors 186 | return Promise.reject({ 187 | code: 502, 188 | message: '插件关闭了!', 189 | }) 190 | } 191 | if (!this.ext) { 192 | // eslint-disable-next-line prefer-promise-reject-errors 193 | return Promise.reject({ 194 | code: 404, 195 | message: `install error`, 196 | }) 197 | } 198 | if (minVersion) { 199 | const version = this.getVersion() 200 | if (compareVersion(minVersion, version || '') > 0) { 201 | // eslint-disable-next-line prefer-promise-reject-errors 202 | return Promise.reject({ 203 | code: 500, 204 | message: `min version error ${minVersion}`, 205 | data: { 206 | minVersion, 207 | version, 208 | }, 209 | }) 210 | } 211 | } 212 | return Promise.resolve(true) 213 | } 214 | 215 | post(funName: string, ...args: any[]) { 216 | return this.postAction({ funName, args }) 217 | } 218 | 219 | postByMinVersion(version: string, funName: string, ...args: any[]) { 220 | return this.postAction({ funName, args }, version) 221 | } 222 | 223 | postAction(params: any, minVersion: any = null) { 224 | const { promise, resolve, reject } = withResolvers() 225 | const runner = async () => { 226 | const callbackId = guid() 227 | this.callbacks.set(callbackId, { resolve, reject }) 228 | 229 | emit(Event4ChromeKey, Object.assign({}, { callbackId }, params)) 230 | const win = window as any 231 | win.wdeListener || (win.wdeListener = new Map()) 232 | if (!win.wdeListener.get(this.id)) { 233 | win.wdeListener.set(this.id, this) 234 | this.handle() 235 | } 236 | return promise 237 | } 238 | return this.check(minVersion).then(runner) 239 | } 240 | 241 | handle() { 242 | const clear = (id: string) => { 243 | this.callbacks.delete(id) 244 | } 245 | listen(Event4PageKey, (event) => { 246 | const { detail } = event 247 | const callback = this.callbacks.get(detail.callbackId) 248 | if (callback) { 249 | // error 250 | if (detail.success) { 251 | callback.resolve(detail.data) 252 | } 253 | else { 254 | if (detail.msg.includes('插件关闭了')) { 255 | this.dispatchExtClose() 256 | callback.reject({ 257 | code: 502, 258 | message: detail.msg, 259 | data: detail.data, 260 | }) 261 | } 262 | else { 263 | callback.reject({ 264 | code: 500, 265 | message: detail.msg, 266 | data: detail.data, 267 | }) 268 | } 269 | } 270 | clear(detail.callbackId) 271 | } 272 | }) 273 | } 274 | } 275 | 276 | let ext: Extension 277 | 278 | export function useExt(): Extension { 279 | if (!ext) 280 | ext = new Extension() 281 | return ext 282 | } 283 | 284 | export function createCors(opt: TWdeCors) { 285 | return JSON.stringify(opt) 286 | } 287 | -------------------------------------------------------------------------------- /packages/create-vpu/src/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import { fileURLToPath } from 'node:url' 4 | import { blue, red, reset, yellow } from 'kolorist' 5 | import minimist from 'minimist' 6 | import prompts from 'prompts' 7 | 8 | const argv = minimist<{ 9 | template?: string 10 | help?: boolean 11 | }>(process.argv.slice(2), { 12 | default: { help: false }, 13 | alias: { h: 'help', t: 'template' }, 14 | string: ['_'], 15 | }) 16 | const cwd = process.cwd() 17 | 18 | function formatTargetDir(targetDir: string | undefined) { 19 | return targetDir?.trim().replace(/\/+$/g, '') 20 | } 21 | 22 | function copy(src: string, dest: string) { 23 | const stat = fs.statSync(src) 24 | if (stat.isDirectory()) { 25 | copyDir(src, dest) 26 | } 27 | else { 28 | fs.copyFileSync(src, dest) 29 | } 30 | } 31 | 32 | function isValidPackageName(projectName: string) { 33 | return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test( 34 | projectName, 35 | ) 36 | } 37 | 38 | function toValidPackageName(projectName: string) { 39 | return projectName 40 | .trim() 41 | .toLowerCase() 42 | .replace(/\s+/g, '-') 43 | .replace(/^[._]/, '') 44 | .replace(/[^a-z\d\-~]+/g, '-') 45 | } 46 | 47 | function copyDir(srcDir: string, destDir: string) { 48 | fs.mkdirSync(destDir, { recursive: true }) 49 | for (const file of fs.readdirSync(srcDir)) { 50 | const srcFile = path.resolve(srcDir, file) 51 | const destFile = path.resolve(destDir, file) 52 | copy(srcFile, destFile) 53 | } 54 | } 55 | 56 | function isEmpty(path: string) { 57 | const files = fs.readdirSync(path) 58 | return files.length === 0 || (files.length === 1 && files[0] === '.git') 59 | } 60 | 61 | function emptyDir(dir: string) { 62 | if (!fs.existsSync(dir)) { 63 | return 64 | } 65 | for (const file of fs.readdirSync(dir)) { 66 | if (file === '.git') { 67 | continue 68 | } 69 | fs.rmSync(path.resolve(dir, file), { recursive: true, force: true }) 70 | } 71 | } 72 | 73 | function pkgFromUserAgent(userAgent: string | undefined) { 74 | if (!userAgent) 75 | return undefined 76 | const pkgSpec = userAgent.split(' ')[0] 77 | const pkgSpecArr = pkgSpec.split('/') 78 | return { 79 | name: pkgSpecArr[0], 80 | version: pkgSpecArr[1], 81 | } 82 | } 83 | 84 | // prettier-ignore 85 | const helpMessage = `\ 86 | Usage: create-vite [OPTION]... [DIRECTORY] 87 | 88 | Create a new Vite project in JavaScript or TypeScript. 89 | With no arguments, start the CLI in interactive mode. 90 | 91 | Options: 92 | -t, --template NAME use a specific template 93 | 94 | Available templates: 95 | ${yellow('vanilla-ts')} 96 | ` 97 | 98 | type ColorFunc = (str: string | number) => string 99 | 100 | interface Framework { 101 | name: string 102 | display: string 103 | color: ColorFunc 104 | variants: FrameworkVariant[] 105 | } 106 | 107 | interface FrameworkVariant { 108 | name: string 109 | display: string 110 | color: ColorFunc 111 | customCommand?: string 112 | } 113 | 114 | const FRAMEWORKS: Framework[] = [ 115 | { 116 | name: 'vanilla', 117 | display: 'Vanilla', 118 | color: yellow, 119 | variants: [ 120 | { 121 | name: 'vanilla', 122 | display: 'JavaScript', 123 | color: yellow, 124 | }, 125 | { 126 | name: 'vue', 127 | display: 'vue', 128 | color: blue, 129 | }, 130 | ], 131 | }, 132 | ] 133 | 134 | const TEMPLATES = FRAMEWORKS.map( 135 | f => (f.variants && f.variants.map(v => v.name)) || [f.name], 136 | ).reduce((a, b) => a.concat(b), []) 137 | 138 | async function init() { 139 | const argTargetDir = formatTargetDir(argv._[0]) 140 | const argTemplate = argv.template || argv.t 141 | 142 | const help = argv.help 143 | if (help) { 144 | console.log(helpMessage) 145 | return 146 | } 147 | 148 | const renameFiles: Record = { 149 | _gitignore: '.gitignore', 150 | } 151 | 152 | const defaultTargetDir = 'vanilla-pudding-userscript' 153 | 154 | let targetDir = argTargetDir || defaultTargetDir 155 | const getProjectName = () => 156 | targetDir === '.' ? path.basename(path.resolve()) : targetDir 157 | 158 | let result: prompts.Answers< 159 | 'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant' 160 | > 161 | 162 | prompts.override({ 163 | overwrite: argv.overwrite, 164 | }) 165 | 166 | try { 167 | result = await prompts( 168 | [ 169 | { 170 | type: argTargetDir ? null : 'text', 171 | name: 'projectName', 172 | message: reset('Project name:'), 173 | initial: defaultTargetDir, 174 | onState: (state) => { 175 | targetDir = formatTargetDir(state.value) || defaultTargetDir 176 | }, 177 | }, 178 | { 179 | type: () => 180 | !fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'select', 181 | name: 'overwrite', 182 | message: () => 183 | `${targetDir === '.' 184 | ? 'Current directory' 185 | : `Target directory "${targetDir}"` 186 | } is not empty. Please choose how to proceed:`, 187 | initial: 0, 188 | choices: [ 189 | { 190 | title: 'Remove existing files and continue', 191 | value: 'yes', 192 | }, 193 | { 194 | title: 'Cancel operation', 195 | value: 'no', 196 | }, 197 | { 198 | title: 'Ignore files and continue', 199 | value: 'ignore', 200 | }, 201 | ], 202 | }, 203 | { 204 | type: (_, { overwrite }: { overwrite?: string }) => { 205 | if (overwrite === 'no') { 206 | throw new Error(`${red('✖')} Operation cancelled`) 207 | } 208 | return null 209 | }, 210 | name: 'overwriteChecker', 211 | }, 212 | { 213 | type: () => (isValidPackageName(getProjectName()) ? null : 'text'), 214 | name: 'packageName', 215 | message: reset('Package name:'), 216 | initial: () => toValidPackageName(getProjectName()), 217 | validate: dir => 218 | isValidPackageName(dir) || 'Invalid package.json name', 219 | }, 220 | { 221 | type: 222 | argTemplate && TEMPLATES.includes(argTemplate) ? null : 'select', 223 | name: 'framework', 224 | message: 225 | typeof argTemplate === 'string' && !TEMPLATES.includes(argTemplate) 226 | ? reset( 227 | `"${argTemplate}" isn't a valid template. Please choose from below: `, 228 | ) 229 | : reset('Select a framework:'), 230 | initial: 0, 231 | choices: FRAMEWORKS.map((framework) => { 232 | const frameworkColor = framework.color 233 | return { 234 | title: frameworkColor(framework.display || framework.name), 235 | value: framework, 236 | } 237 | }), 238 | }, 239 | { 240 | type: (framework: Framework) => 241 | framework && framework.variants ? 'select' : null, 242 | name: 'variant', 243 | message: reset('Select a variant:'), 244 | choices: (framework: Framework) => 245 | framework.variants.map((variant) => { 246 | const variantColor = variant.color 247 | return { 248 | title: variantColor(variant.display || variant.name), 249 | value: variant.name, 250 | } 251 | }), 252 | }, 253 | ], 254 | { 255 | onCancel: () => { 256 | throw new Error(`${red('✖')} Operation cancelled`) 257 | }, 258 | }, 259 | ) 260 | } 261 | catch (cancelled: any) { 262 | console.log(cancelled.message) 263 | return 264 | } 265 | 266 | // user choice associated with prompts 267 | const { overwrite, packageName, variant, framework } = result 268 | 269 | const root = path.join(cwd, targetDir) 270 | 271 | if (overwrite === 'yes') { 272 | emptyDir(root) 273 | } 274 | else if (!fs.existsSync(root)) { 275 | fs.mkdirSync(root, { recursive: true }) 276 | } 277 | // determine template 278 | const template: string = variant || framework?.name || argTemplate 279 | 280 | const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent) 281 | const pkgManager = pkgInfo ? pkgInfo.name : 'npm' 282 | const templateDir = path.resolve( 283 | fileURLToPath(import.meta.url), 284 | '../..', 285 | `template-${template}`, 286 | ) 287 | 288 | const write = (file: string, content?: string) => { 289 | const targetPath = path.join(root, renameFiles[file] ?? file) 290 | if (content) { 291 | fs.writeFileSync(targetPath, content) 292 | } 293 | else { 294 | copy(path.join(templateDir, file), targetPath) 295 | } 296 | } 297 | 298 | const files = fs.readdirSync(templateDir) 299 | for (const file of files.filter(f => f !== 'package.json')) { 300 | write(file) 301 | } 302 | 303 | const pkg = JSON.parse( 304 | fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8'), 305 | ) 306 | 307 | pkg.name = packageName || getProjectName() 308 | 309 | write('package.json', `${JSON.stringify(pkg, null, 2)}\n`) 310 | 311 | const cdProjectName = path.relative(cwd, root) 312 | console.log(`\nDone. Now run:\n`) 313 | if (root !== cwd) { 314 | console.log( 315 | ` cd ${ 316 | cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName 317 | }`, 318 | ) 319 | } 320 | switch (pkgManager) { 321 | case 'yarn': 322 | console.log(' yarn') 323 | console.log(' yarn dev') 324 | break 325 | default: 326 | console.log(` ${pkgManager} install`) 327 | console.log(` ${pkgManager} run dev`) 328 | break 329 | } 330 | console.log() 331 | } 332 | 333 | init().catch((e) => { 334 | console.error(e) 335 | }) 336 | -------------------------------------------------------------------------------- /project/ext/src/lib/request/index.ts: -------------------------------------------------------------------------------- 1 | import type { BooleanOptional, IStringifyOptions } from 'qs' 2 | import type { Method } from 'quick-fy' 3 | import type { TWdeCors } from '@/lib/rules' 4 | import { upperCase } from 'lodash-es' 5 | import qs from 'qs' 6 | import { createFy, getContentType } from 'quick-fy' 7 | import { ruleDNRTool } from '@/lib/rules' 8 | import { check, getRow, parseJson } from '@/lib/tool.ts' 9 | 10 | type ResponseType = 11 | | 'arraybuffer' 12 | | 'blob' 13 | | 'document' 14 | | 'json' 15 | | 'text' 16 | | 'stream' 17 | | 'formData' 18 | | 'base64' 19 | | 'charset_encode' 20 | 21 | type ContentType = 'json' | 'form' | 'formData' 22 | 23 | const ContentTypeMap: Record = { 24 | json: 'application/json;charset=UTF-8', 25 | form: 'application/x-www-form-urlencoded;charset=UTF-8', 26 | formData: 'multipart/form-data', 27 | } 28 | 29 | type Arg = Record | string 30 | type MethodType = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'PATCH' 31 | 32 | const fy = createFy({ 33 | async beforeRequest(method: Method) { 34 | let url = getRow(method, 'url', '') 35 | const config = getRow(method, 'config', {}) 36 | const meta = getRow(method, 'meta', {}) 37 | 38 | const cors = getRow(meta, 'cors', null) 39 | if (cors) { 40 | await onBeforeSetCors(cors) 41 | } 42 | const content_type: ContentType = getRow(meta, 'content_type', 'json') 43 | const qs_options = getRow(meta, 'qs_options', {}) 44 | const set_content_type: ContentType = getRow(meta, 'set_content_type', true) 45 | const params = getRow(meta, 'params', null) 46 | 47 | const ContentType = ContentTypeMap[content_type] 48 | 49 | let data = config?.body 50 | 51 | // 设置 ContentType 52 | function setContentType() { 53 | if (!set_content_type) 54 | return {} 55 | if (content_type === 'formData' && check(data, 'Object')) { 56 | const field = ['blobFields', 'fileFields'] 57 | // 检查formData是否包含文件字段 58 | const hasFile = !!Object.keys(data).filter(v => field.includes(v)).length 59 | if (hasFile) 60 | return {} 61 | } 62 | return ContentType ? { 'content-type': ContentType } : {} 63 | } 64 | 65 | // 处理请求头 66 | config.headers = { 67 | ...(setContentType()), 68 | ...(config.headers || {}), 69 | } 70 | 71 | if (config.method !== 'GET') { 72 | // 处理流字段 73 | await doBlobFields(data) 74 | } 75 | 76 | // 处理body数据 77 | data = parseDataByContentType(content_type, data, qs_options) 78 | 79 | // 处理 url params 80 | if (params) { 81 | const textSearchParams = typeof params === 'string' 82 | ? params.replace(/^\?/, '') 83 | : qs.stringify(params, { encode: false }) 84 | const searchParams = `?${textSearchParams}` 85 | url = url.replace(/(?:\?.*?)?(?=#|$)/, searchParams) 86 | } 87 | 88 | return { 89 | url, 90 | config: { 91 | ...config, 92 | body: data, 93 | }, 94 | meta, 95 | } 96 | }, 97 | responded: { 98 | // 请求成功的拦截器 99 | // 当使用GlobalFetch请求适配器时,第一个参数接收Response对象 100 | // 第二个参数为当前请求的method实例,你可以用它同步请求前后的配置信息 101 | onSuccess: async (response, method: Method) => { 102 | const methodType = getRow(method, 'config.method', 'GET') 103 | const def_res_type = methodType === 'POST' ? 'json' : 'text' 104 | const response_type: ResponseType = getRow(method, 'meta.response_type', def_res_type) 105 | if (response.status >= 200 && response.status !== 204) { 106 | const contentType = getContentType(response) 107 | switch (response_type) { 108 | case 'arraybuffer': 109 | return response.arrayBuffer() 110 | case 'blob': 111 | return response.blob() 112 | case 'json': 113 | return response.json() 114 | case 'formData': 115 | return response.formData() 116 | case 'base64': { 117 | const blob = await response.blob() 118 | return blobToBase64(blob) 119 | } 120 | case 'charset_encode': { 121 | if (/(^|;)application\/json($|;)/i.test(contentType)) 122 | return charsetMatches(contentType, response, 'json') 123 | else if (/(^|;)text\/(.*)($|;)/i.test(contentType)) 124 | return charsetMatches(contentType, response, 'text') 125 | else 126 | return response.text() 127 | } 128 | default: 129 | return response.text() 130 | } 131 | } 132 | return response.text() 133 | }, 134 | 135 | // 请求失败的拦截器 136 | // 请求错误时将会进入该拦截器。 137 | // 第二个参数为当前请求的method实例,你可以用它同步请求前后的配置信息 138 | onError: (error, method) => { 139 | throw error 140 | }, 141 | 142 | // 请求完成的拦截器 143 | // 当你需要在请求不论是成功、失败、还是命中缓存都需要执行的逻辑时,可以在创建alova实例时指定全局的`onComplete`拦截器,例如关闭请求 loading 状态。 144 | // 接收当前请求的method实例 145 | onComplete: async (method) => { 146 | const cors = getRow(method, 'meta.cors', null) 147 | if (cors) { 148 | await onEndSetCors(cors) 149 | } 150 | }, 151 | }, 152 | }) 153 | 154 | /** 155 | * 根据设置的 ContentType 格式化当前data 对象的格式 156 | * @param content_type 157 | * @param data 158 | * @param qs_options 159 | */ 160 | function parseDataByContentType(content_type: ContentType, data: any, qs_options?: any) { 161 | switch (content_type) { 162 | case 'form': { 163 | if (typeof data === 'string') 164 | return data 165 | 166 | return qs.stringify(data, qs_options) 167 | } 168 | case 'formData': { 169 | const formData: FormData = new FormData() 170 | for (const [key, value] of Object.entries(data)) { 171 | formData.append(key, value as any) 172 | } 173 | return formData 174 | } 175 | default: { 176 | if (check(data, 'Object') || check(data, 'Array')) { 177 | return JSON.stringify(data) 178 | } 179 | return data 180 | } 181 | } 182 | } 183 | 184 | async function doBlobFields(data: any) { 185 | if (!check(data, 'Object')) { 186 | return 187 | } 188 | 189 | const { blobFields = [], fileFields = [] } = data as any 190 | for (const field of blobFields) { 191 | const value = data[field] 192 | data[field] = await fetch(value).then(res => res.blob()) 193 | } 194 | for (const field of fileFields) { 195 | const value = data[field] 196 | const result = await fetch(value.uri).then(res => res.blob()) 197 | data[field] = new File([result], value.filename) 198 | } 199 | delete data.blobFields 200 | delete data.fileFields 201 | } 202 | 203 | async function onBeforeSetCors(cors_value: string) { 204 | if (!cors_value) 205 | return 206 | const cors = parseJson(cors_value) 207 | await ruleDNRTool.addByHeader(cors) 208 | } 209 | 210 | async function onEndSetCors(cors_value: string) { 211 | if (!cors_value) 212 | return 213 | const cors = parseJson(cors_value) 214 | await ruleDNRTool.rmByHeader(cors) 215 | } 216 | 217 | interface Meta { 218 | // 跨域设置、请求头修改等 219 | cors: TWdeCors | string 220 | // 设置请求的 ContentType, 会根据 ContentType 进行数据格式化 221 | content_type: ContentType 222 | // 控制是否设置 ContentType 223 | set_content_type: boolean 224 | // 控制响应数据类型 225 | response_type: ResponseType 226 | // 设置 qs 的配置 227 | qs_options: IStringifyOptions 228 | // 设置请求URL参数 229 | params?: Arg 230 | [k: string]: any 231 | } 232 | 233 | type Config = { 234 | meta?: Partial 235 | params?: Arg 236 | } & Omit 237 | 238 | /** 239 | * 兼容旧版的请求 240 | * @deprecated 后续请使用 extRequestFy 代替 241 | */ 242 | export function extRequest( 243 | type: MethodType, 244 | url: string, 245 | config?: Config, 246 | data?: BodyInit, 247 | ) { 248 | const method = upperCase(type) as MethodType 249 | const { meta, params, ...other } = config || {} 250 | return fy.request( 251 | url, 252 | { 253 | ...(other || {}), 254 | method, 255 | body: data, 256 | }, 257 | { 258 | ...(meta || {}), 259 | params, 260 | }, 261 | ) 262 | } 263 | 264 | export function extRequestFy( 265 | type: MethodType, 266 | url: string, 267 | config?: RequestInit, 268 | meta?: Partial, 269 | ) { 270 | const method = upperCase(type || 'GET') as MethodType 271 | return fy.request( 272 | url, 273 | { 274 | ...(config || {}), 275 | method, 276 | }, 277 | meta, 278 | ) 279 | } 280 | 281 | function charsetMatches(contentType: string, stageOne: Response, dataType) { 282 | const matches = contentType.match(/.*charset=(.*)($|;)/i) 283 | if (matches && matches.length && matches[1]) { 284 | const charset = matches[1] 285 | return stageOne 286 | .blob() 287 | .then(blob => blobToText(blob, charset)) 288 | .then((res: string) => { 289 | try { 290 | if (dataType === 'json') 291 | return JSON.parse(res) 292 | else 293 | return res 294 | } 295 | catch (e) { 296 | return res 297 | } 298 | }) 299 | } 300 | else { 301 | if (dataType === 'json') 302 | return stageOne.json() 303 | 304 | return stageOne.text() 305 | } 306 | } 307 | 308 | function blobToBase64(blob: Blob) { 309 | return new Promise((resolve, _) => { 310 | const reader = new FileReader() 311 | reader.onloadend = () => resolve(reader.result) 312 | reader.readAsDataURL(blob) 313 | }) 314 | } 315 | 316 | function blobToText(blob: Blob, encoding?: string | undefined) { 317 | return new Promise((resolve, _) => { 318 | const reader = new FileReader() 319 | reader.onloadend = () => resolve(reader.result) 320 | reader.readAsText(blob, encoding) 321 | }) 322 | } 323 | 324 | function base64ToBlob(code: string) { 325 | const parts = code.split(';base64,') 326 | let rawLength, contentType, raw 327 | if (parts.length > 1) { 328 | contentType = parts[0].split(':')[1] 329 | raw = window.atob(parts[1]) 330 | rawLength = raw.length 331 | } 332 | else { 333 | raw = window.atob(parts[0]) 334 | rawLength = raw.length 335 | } 336 | 337 | const uInt8Array = new Uint8Array(rawLength) 338 | for (let i = 0; i < rawLength; ++i) 339 | uInt8Array[i] = raw.charCodeAt(i) 340 | 341 | return new Blob([uInt8Array], { 342 | type: contentType, 343 | }) 344 | } 345 | -------------------------------------------------------------------------------- /project/ext/src/entrypoints/popup/components/container.vue: -------------------------------------------------------------------------------- 1 | 97 | 98 | 439 | --------------------------------------------------------------------------------