├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── .husky ├── pre-commit └── pre-push ├── LICENSE ├── README.md ├── eslint.config.js ├── gulpfile.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── src ├── _locales │ └── en │ │ └── messages.json ├── common │ ├── components │ │ ├── list-item.vue │ │ └── toggle.vue │ ├── constants.ts │ ├── index.ts │ ├── keyboard.ts │ ├── list.ts │ ├── style │ │ ├── index.ts │ │ └── style.css │ ├── util.test.ts │ └── util.ts ├── connector │ ├── index.ts │ ├── package.json │ └── tsconfig.json ├── content │ └── index.ts ├── handler │ ├── cookie.ts │ ├── data.ts │ ├── index.ts │ ├── list.ts │ ├── request.ts │ └── util.ts ├── manifest.yml ├── options │ ├── actions.ts │ ├── components │ │ ├── app.vue │ │ ├── code-editor.vue │ │ ├── cookie-item.vue │ │ ├── dropdown.vue │ │ ├── edit-cookie-item.vue │ │ ├── edit-request-item.vue │ │ ├── list-section.vue │ │ ├── menu-bar.vue │ │ ├── modal-edit.vue │ │ ├── modal-list.vue │ │ ├── modal.vue │ │ ├── request-item.vue │ │ ├── rule-body.vue │ │ ├── rule-hint.vue │ │ ├── rule-item-view.vue │ │ └── rule-nav.vue │ ├── index.html │ ├── index.ts │ ├── shortcut.ts │ ├── store.ts │ └── util.ts ├── popup │ ├── app.vue │ ├── index.html │ ├── index.ts │ └── store.ts ├── resources │ ├── x.png │ └── x.svg ├── types.ts └── vite-env.d.ts ├── tsconfig.json ├── uno.config.ts └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | quote_type = single 12 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: gera2ld 2 | custom: https://gera2ld.space/donate/ 3 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - v* 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: '22' 17 | - uses: pnpm/action-setup@v3 18 | with: 19 | version: 9 20 | - name: Prepare 21 | run: | 22 | VERSION=`node -e 'console.log(require("./package.json").version)'` 23 | echo "VERSION=$VERSION" >> $GITHUB_ENV 24 | echo "ASSETS_DIR=dist-assets" >> $GITHUB_ENV 25 | echo "ASSET_ZIP=request-x-v$VERSION.zip" >> $GITHUB_ENV 26 | pnpm i 27 | - name: Build 28 | run: | 29 | pnpm build 30 | mkdir -p $ASSETS_DIR 31 | cd dist && zip -r ../$ASSETS_DIR/$ASSET_ZIP . && cd .. 32 | - name: Publish to CWS 33 | continue-on-error: true 34 | run: | 35 | set -x 36 | npx chrome-webstore-upload-cli@1 upload --extension-id $EXTENSION_ID --source $ASSETS_DIR/$ASSET_ZIP --auto-publish 37 | env: 38 | EXTENSION_ID: cblonkdlnemhdeefhmaoiijjaedcphbf 39 | CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }} 40 | CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }} 41 | REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | /.idea 4 | /dist 5 | /lib 6 | /.nyc_output 7 | /coverage 8 | /types 9 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm run lint:ts 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Gerald 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Request X 2 | 3 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/v/cblonkdlnemhdeefhmaoiijjaedcphbf.svg)](https://chrome.google.com/webstore/detail/request-x/cblonkdlnemhdeefhmaoiijjaedcphbf) 4 | 5 | ![Request X](./src/resources/x.png) 6 | 7 | This is a web extension to block or redirect undesired requests. 8 | 9 | Supported browsers: 10 | 11 | - Chrome 12 | - Brave 13 | - Edge 14 | - Firefox (not officially released) 15 | - Kiwi Browser (not all features) 16 | - Other Chromium-based browsers 17 | 18 | ## Installation 19 | 20 | - [Chrome web store](https://chrome.google.com/webstore/detail/request-x/cblonkdlnemhdeefhmaoiijjaedcphbf) 21 | 22 | ## Features 23 | 24 | - Block requests by methods and URL patterns 25 | - Maintainable lists 26 | - Easy to share your lists with others 27 | - Redirect requests 28 | - Modify headers 29 | - Modify cookie properties like `SameSite` 30 | 31 | ## Use cases 32 | 33 | - Debug APIs but avoid unexpected mutations 34 | - Block unwanted contents in an extremely flexible way 35 | - Set authorization header to avoid inputing username/password again and again 36 | - Set CORS header to allow certain cross-site requests without server changes 37 | - Change `SameSite` for old services to work on latest browsers 38 | - ... 39 | 40 | ## Click and Subscribe 41 | 42 | Click on URLs with a magic hash `#:request-x:` to subscribe it: 43 | 44 | ``` 45 | Subscribe 46 | ``` 47 | 48 | Here is an example: [Subscribe](https://gist.github.com/gera2ld/5730305dc9081ec93ccab7a1c7ece5b3/raw/power.json#:request-x:) 49 | 50 | ## Screenshots 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import pluginJs from '@eslint/js'; 2 | import pluginVue from 'eslint-plugin-vue'; 3 | import globals from 'globals'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | /** @type {import('eslint').Linter.Config[]} */ 7 | export default [ 8 | { files: ['src/**/*.{js,mjs,cjs,ts,vue}'] }, 9 | { languageOptions: { globals: globals.browser } }, 10 | pluginJs.configs.recommended, 11 | ...tseslint.configs.recommended, 12 | ...pluginVue.configs['flat/essential'], 13 | { 14 | files: ['src/**/*.vue'], 15 | languageOptions: { parserOptions: { parser: tseslint.parser } }, 16 | }, 17 | { 18 | rules: { 19 | '@typescript-eslint/no-explicit-any': ['warn'], 20 | '@typescript-eslint/no-unused-vars': [ 21 | 'error', 22 | { 23 | args: 'all', 24 | argsIgnorePattern: '^_', 25 | caughtErrors: 'all', 26 | caughtErrorsIgnorePattern: '^_', 27 | destructuredArrayIgnorePattern: '^_', 28 | varsIgnorePattern: '^_', 29 | ignoreRestSiblings: true, 30 | }, 31 | ], 32 | 'vue/multi-word-component-names': ['off'], 33 | }, 34 | }, 35 | ]; 36 | 37 | -------------------------------------------------------------------------------- /gulpfile.mjs: -------------------------------------------------------------------------------- 1 | import { deleteAsync } from 'del'; 2 | import { mkdir, readFile, writeFile } from 'fs/promises'; 3 | import gulp from 'gulp'; 4 | import { Jimp } from 'jimp'; 5 | import yaml from 'js-yaml'; 6 | import pkg from './package.json' with { type: 'json' }; 7 | 8 | export function clean() { 9 | return deleteAsync('dist/**'); 10 | } 11 | 12 | function copyFiles() { 13 | return gulp.src('src/_locales/**', { base: 'src' }).pipe(gulp.dest('dist')); 14 | } 15 | 16 | function copyConnectorFiles() { 17 | return gulp.src('src/connector/package.json').pipe(gulp.dest('lib')); 18 | } 19 | 20 | async function createIcons() { 21 | const dist = `dist/public/images`; 22 | await mkdir(dist, { recursive: true }); 23 | const icon = await Jimp.read('src/resources/x.png'); 24 | return Promise.all( 25 | [16, 19, 38, 48, 128].map((w) => 26 | icon.clone().resize({ w }).write(`${dist}/icon_${w}.png`), 27 | ), 28 | ); 29 | } 30 | 31 | async function manifest() { 32 | const data = yaml.load(await readFile('src/manifest.yml', 'utf8')); 33 | // Strip alphabetic suffix 34 | data.version = pkg.version.replace(/-[^.]*/, ''); 35 | await writeFile(`dist/manifest.json`, JSON.stringify(data)); 36 | } 37 | 38 | export const copyAssets = gulp.parallel( 39 | copyFiles, 40 | copyConnectorFiles, 41 | createIcons, 42 | manifest, 43 | ); 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "request-x", 3 | "version": "3.0.5", 4 | "private": true, 5 | "description": "Request X", 6 | "author": "Gerald ", 7 | "license": "MIT", 8 | "type": "module", 9 | "scripts": { 10 | "prepare": "husky", 11 | "lint:ts": "vue-tsc", 12 | "lint:js": "eslint src", 13 | "lint": "pnpm /^lint:/", 14 | "format": "prettier -w src", 15 | "dev:background": "NODE_ENV=development vite build --watch", 16 | "dev:content": "NODE_ENV=development ENTRY=content vite build --watch", 17 | "dev": "gulp copyAssets && pnpm run /^dev:/", 18 | "build:background": "vite build", 19 | "build:content": "ENTRY=content vite build", 20 | "build:connector": "ENTRY=connector vite build && tsc -p src/connector/tsconfig.json", 21 | "build": "pnpm lint && pnpm test && gulp clean && pnpm /^build:/ && gulp copyAssets", 22 | "test": "vitest --run" 23 | }, 24 | "engines": { 25 | "node": ">=20" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/gera2ld/request-x.git" 30 | }, 31 | "bugs": { 32 | "url": "https://github.com/gera2ld/request-x/issues" 33 | }, 34 | "homepage": "https://github.com/gera2ld/request-x#readme", 35 | "devDependencies": { 36 | "@eslint/js": "^9.23.0", 37 | "@iconify/json": "^2.2.322", 38 | "@types/webextension-polyfill": "^0.12.3", 39 | "@unocss/postcss": "66.1.0-beta.8", 40 | "@vitejs/plugin-vue": "^5.2.3", 41 | "del": "^8.0.0", 42 | "eslint": "^9.23.0", 43 | "eslint-plugin-vue": "^10.0.0", 44 | "globals": "^16.0.0", 45 | "gulp": "^5.0.0", 46 | "husky": "^9.1.7", 47 | "jimp": "^1.6.0", 48 | "js-yaml": "^4.1.0", 49 | "lint-staged": "^15.5.0", 50 | "postcss-nesting": "^13.0.1", 51 | "prettier": "^3.5.3", 52 | "typescript": "~5.8.2", 53 | "typescript-eslint": "^8.29.0", 54 | "unocss": "66.1.0-beta.8", 55 | "unplugin-icons": "^22.1.0", 56 | "vite": "^6.2.4", 57 | "vitest": "^3.1.1", 58 | "vue-eslint-parser": "^10.1.1", 59 | "vue-tsc": "^2.2.8" 60 | }, 61 | "dependencies": { 62 | "@codemirror/commands": "^6.8.1", 63 | "@codemirror/lang-html": "^6.4.9", 64 | "@codemirror/lang-json": "^6.0.1", 65 | "@codemirror/lint": "^6.8.5", 66 | "@codemirror/state": "^6.5.2", 67 | "@codemirror/theme-one-dark": "^6.1.2", 68 | "@codemirror/view": "^6.36.5", 69 | "@unocss/reset": "66.1.0-beta.8", 70 | "@violentmonkey/shortcut": "^1.4.4", 71 | "codemirror": "^6.0.1", 72 | "es-toolkit": "^1.34.1", 73 | "vue": "^3.5.13", 74 | "webextension-polyfill": "^0.12.0" 75 | }, 76 | "lint-staged": { 77 | "*.{ts,vue}": [ 78 | "eslint --fix", 79 | "prettier --write" 80 | ], 81 | "*.html": [ 82 | "prettier --write" 83 | ] 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-nesting': {}, 4 | '@unocss/postcss': {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/common/components/list-item.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 51 | -------------------------------------------------------------------------------- /src/common/components/toggle.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | -------------------------------------------------------------------------------- /src/common/constants.ts: -------------------------------------------------------------------------------- 1 | export const SECTION_TITLE_MAP: { [key: string]: string } = { 2 | request: 'Request Interception', 3 | cookie: 'Cookie Interception', 4 | }; 5 | 6 | export const URL_TRANSFORM_KEYS = [ 7 | 'host', 8 | 'port', 9 | 'username', 10 | 'password', 11 | 'path', 12 | ] as const; 13 | 14 | export const EVENT_MAIN = 'RequestXMainEvent'; 15 | export const EVENT_CONTENT = 'RequestXContentEvent'; 16 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | 3 | export async function sendMessage(cmd: string, payload?: any) { 4 | const res = (await browser.runtime.sendMessage({ cmd, payload })) as { 5 | data: T; 6 | error?: string; 7 | }; 8 | if (import.meta.env.DEV) console.log('sendMessage', { cmd, payload }, res); 9 | if (res.error) throw new Error(res.error); 10 | return res.data; 11 | } 12 | 13 | type IHandler = (payload: any, sender: browser.Runtime.MessageSender) => any; 14 | 15 | export function handleMessages(handlers: Record) { 16 | const handleAsync = async ( 17 | handle: IHandler, 18 | message: any, 19 | sender: browser.Runtime.MessageSender, 20 | ) => { 21 | if (import.meta.env.DEV) console.log('onMessage', message); 22 | try { 23 | const data = (await handle(message.payload, sender)) ?? null; 24 | return { data: data }; 25 | } catch (error) { 26 | return { error: `${error || 'Unknown error'}` }; 27 | } 28 | }; 29 | browser.runtime.onMessage.addListener( 30 | (message: unknown, sender: browser.Runtime.MessageSender) => { 31 | const cmd = (message as { cmd: string })?.cmd; 32 | const handle = handlers[cmd]; 33 | if (handle) return handleAsync(handle, message, sender); 34 | }, 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/common/keyboard.ts: -------------------------------------------------------------------------------- 1 | import { KeyboardService } from '@violentmonkey/shortcut'; 2 | 3 | export * from '@violentmonkey/shortcut'; 4 | 5 | export const isMacintosh = navigator.userAgent.includes('Macintosh'); 6 | 7 | export const keyboardService = new KeyboardService(); 8 | 9 | bindKeys(); 10 | 11 | let hasSelection = false; 12 | let hasInput = false; 13 | 14 | export function isInput(el: Element) { 15 | return ['input', 'textarea'].includes(el?.tagName?.toLowerCase()); 16 | } 17 | 18 | function handleFocus(e: FocusEvent) { 19 | if (isInput(e.target as Element)) { 20 | hasInput = true; 21 | updateInputFocus(); 22 | } 23 | } 24 | 25 | function handleBlur(e: FocusEvent) { 26 | if (isInput(e.target as Element)) { 27 | hasInput = false; 28 | updateInputFocus(); 29 | } 30 | } 31 | 32 | function updateInputFocus() { 33 | keyboardService.setContext('inputFocus', hasSelection || hasInput); 34 | } 35 | 36 | function bindKeys() { 37 | document.addEventListener('focus', handleFocus, true); 38 | document.addEventListener('blur', handleBlur, true); 39 | document.addEventListener( 40 | 'selectionchange', 41 | () => { 42 | hasSelection = document.getSelection()?.type === 'Range'; 43 | updateInputFocus(); 44 | }, 45 | false, 46 | ); 47 | keyboardService.register( 48 | 'enter', 49 | () => { 50 | (document.activeElement as HTMLElement).click(); 51 | }, 52 | { 53 | condition: '!inputFocus', 54 | }, 55 | ); 56 | keyboardService.enable(); 57 | } 58 | -------------------------------------------------------------------------------- /src/common/list.ts: -------------------------------------------------------------------------------- 1 | import type { CookieData, KeyValueItem, ListData, RequestData } from '@/types'; 2 | 3 | export function getName(list: Partial) { 4 | return list.name || 'No name'; 5 | } 6 | 7 | export function normalizeRequestRule(rule: any): RequestData[] { 8 | const result: RequestData[] = []; 9 | const requestHeaders = Array.from( 10 | Array.isArray(rule.requestHeaders) ? rule.requestHeaders : [], 11 | ).filter(Boolean); 12 | const responseHeaders = Array.from( 13 | Array.isArray(rule.responseHeaders) ? rule.responseHeaders : [], 14 | ).filter(Boolean); 15 | if (!rule.type) { 16 | // old data 17 | const common: RequestData = { 18 | enabled: true, 19 | type: 'block', 20 | methods: [], 21 | url: rule.url, 22 | target: '', 23 | comment: '', 24 | }; 25 | const { target, method } = rule; 26 | if (method && method !== '*') common.methods = [method.toLowerCase()]; 27 | if (target !== '=') { 28 | const normalized = { ...common }; 29 | if (!target || target === '-') { 30 | // noop 31 | } else if (target[0] === '<') { 32 | normalized.type = 'replace'; 33 | const i = target.indexOf('\n'); 34 | normalized.contentType = target.slice(1, i); 35 | normalized.target = target.slice(i + 1); 36 | } else { 37 | normalized.type = 'redirect'; 38 | normalized.target = target.replace(/\$(\d)/g, '\\$1'); 39 | } 40 | result.push(normalized); 41 | } 42 | if (requestHeaders.length || responseHeaders.length) { 43 | result.push({ 44 | ...common, 45 | type: 'headers', 46 | requestHeaders, 47 | responseHeaders, 48 | }); 49 | } 50 | } else { 51 | result.push({ 52 | enabled: !!(rule.enabled ?? true), 53 | type: rule.type || 'block', 54 | url: rule.url || '', 55 | target: rule.target || '', 56 | comment: rule.comment || '', 57 | contentType: rule.contentType, 58 | methods: Array.from( 59 | Array.isArray(rule.methods) ? rule.methods : [], 60 | ).filter(Boolean), 61 | requestHeaders, 62 | responseHeaders, 63 | transform: rule.transform, 64 | }); 65 | } 66 | return result; 67 | } 68 | 69 | export function normalizeCookieRule(rule: any): CookieData { 70 | return { 71 | enabled: true, 72 | name: rule.name || '', 73 | url: rule.url || '', 74 | comment: rule.comment || '', 75 | sameSite: rule.sameSite, 76 | httpOnly: rule.httpOnly, 77 | secure: rule.secure, 78 | ttl: rule.ttl, 79 | }; 80 | } 81 | 82 | export async function fetchListData(url: string) { 83 | const res = await fetch(url, { credentials: 'include' }); 84 | const data = await res.json(); 85 | if (!res.ok) throw { status: res.status, data }; 86 | return { 87 | type: data.type ?? 'request', 88 | name: data.name || '', 89 | rules: data.rules, 90 | lastUpdated: Date.now(), 91 | } as Partial; 92 | } 93 | -------------------------------------------------------------------------------- /src/common/style/index.ts: -------------------------------------------------------------------------------- 1 | import '@unocss/reset/tailwind.css'; 2 | import './style.css'; 3 | -------------------------------------------------------------------------------- /src/common/style/style.css: -------------------------------------------------------------------------------- 1 | @unocss; 2 | 3 | :root { 4 | --bg-primary: white; 5 | --bg-secondary: #e4e4e7; 6 | --bg-input: white; 7 | --border-input: #a1a1aa; 8 | --border-input-focus: #71717a; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --bg-primary: #18181b; 14 | --bg-secondary: #3f3f46; 15 | --bg-input: #27272a; 16 | --border-input: #71717a; 17 | --border-input-focus: #a1a1aa; 18 | } 19 | } 20 | 21 | html { 22 | font-size: 12px; 23 | } 24 | 25 | body { 26 | @apply text-zinc-800 dark:text-zinc-300 text-base; 27 | } 28 | 29 | body, 30 | .bg-primary { 31 | background: var(--bg-primary); 32 | } 33 | 34 | .bg-secondary { 35 | background: var(--bg-secondary); 36 | } 37 | 38 | select, 39 | input, 40 | textarea { 41 | background: var(--bg-input); 42 | } 43 | 44 | .request-x-popup { 45 | @apply w-[24rem]; 46 | } 47 | 48 | .active-area { 49 | @apply relative; 50 | &::before { 51 | content: ''; 52 | @apply absolute top-0 left-0 right-0 h-[1px] bg-green-600; 53 | } 54 | } 55 | 56 | a { 57 | @apply text-blue-500 cursor-pointer dark:text-blue-400 hover:text-blue-600 hover:underline hover:dark:text-blue-300; 58 | } 59 | 60 | svg { 61 | fill: currentColor; 62 | } 63 | 64 | button { 65 | @apply p-1 rounded border border-zinc-400 bg-zinc-100 cursor-pointer dark:bg-zinc-800 dark:border-zinc-600; 66 | &:not(:disabled):hover { 67 | @apply border-zinc-500 bg-zinc-200 dark:bg-zinc-600 dark:border-zinc-400; 68 | } 69 | &:disabled { 70 | @apply bg-zinc-200 text-zinc-400 dark:bg-zinc-700 dark:text-zinc-500 cursor-not-allowed; 71 | } 72 | } 73 | 74 | kbd { 75 | @apply rounded px-1 py-[2px] bg-zinc-200 dark:bg-zinc-800; 76 | } 77 | 78 | .footer { 79 | @apply flex justify-between p-2 border-t border-zinc-200 dark:border-zinc-600; 80 | } 81 | 82 | select, 83 | input[type='text'], 84 | input[type='search'], 85 | input[type='number'], 86 | textarea { 87 | @apply block w-full outline-none rounded border px-2 py-1; 88 | border-color: var(--border-input); 89 | &:focus { 90 | border-color: var(--border-input-focus); 91 | } 92 | &[readonly] { 93 | color: text-zinc-600; 94 | } 95 | } 96 | .cm-editor { 97 | @apply h-full border rounded; 98 | border-color: var(--border-input); 99 | &.cm-focused { 100 | @apply outline-none; 101 | border-color: var(--border-input-focus); 102 | } 103 | } 104 | .input-error, 105 | .child-error > * { 106 | @apply bg-red-200 border-red-600 dark:bg-red-600 dark:bg-opacity-20 dark:border-red-700; 107 | --border-input: #b91c1c; 108 | --border-input-focus: #b91c1c; 109 | } 110 | 111 | ul { 112 | @apply list-disc pl-6; 113 | } 114 | 115 | .subtle { 116 | @apply text-zinc-400 dark:text-zinc-500; 117 | } 118 | 119 | .input { 120 | @apply relative; 121 | > svg + input { 122 | @apply pl-8; 123 | } 124 | > svg { 125 | @apply absolute w-8 top-0 left-0 h-full text-zinc-500; 126 | } 127 | } 128 | 129 | .fade-enter-active, 130 | .fade-leave-active { 131 | transition: opacity 0.3s; 132 | } 133 | .fade-enter-from, 134 | .fade-leave-to { 135 | opacity: 0; 136 | } 137 | 138 | .modal { 139 | @apply min-w-64 max-w-screen-lg mx-auto p-2 border border-t-none rounded; 140 | background: var(--bg-primary); 141 | border-color: var(--border-input); 142 | } 143 | .vl-modal { 144 | @apply fixed inset-0 z-10; 145 | } 146 | .vl-modal-backdrop { 147 | @apply fixed inset-0 bg-black/40 dark:bg-black/70 -z-1; 148 | } 149 | 150 | .modal-group { 151 | @apply text-left; 152 | ~ .modal-group { 153 | @apply mt-1; 154 | } 155 | } 156 | 157 | .form-hint { 158 | @apply p-1; 159 | background: var(--bg-secondary); 160 | code { 161 | @apply px-1 bg-yellow-200 border border-zinc-400 rounded whitespace-nowrap dark:bg-yellow-800; 162 | } 163 | } 164 | 165 | .label-unsupported { 166 | @apply text-yellow-600 dark:text-yellow-600; 167 | } 168 | 169 | .rule-list-header { 170 | @apply flex items-center p-1 border-b border-zinc-200 dark:border-zinc-600; 171 | } 172 | 173 | .rule-item { 174 | @apply px-2 py-1 rounded border border-transparent; 175 | &.active, 176 | &:hover { 177 | @apply border-zinc-300 dark:border-zinc-600; 178 | } 179 | &.selected { 180 | @apply bg-zinc-200 dark:bg-zinc-700; 181 | } 182 | } 183 | .rule-item-badge { 184 | @apply ml-1 text-xs leading-8 uppercase text-zinc-500 dark:text-zinc-400; 185 | } 186 | .rule-item-content { 187 | @apply flex-1 min-w-0 py-1 break-words; 188 | } 189 | .rule-item-comment { 190 | @apply mt-1 text-sm text-zinc-500 dark:text-zinc-400; 191 | } 192 | 193 | .request-method-item { 194 | @apply flex items-center cursor-pointer; 195 | &:hover, 196 | &.active { 197 | @apply font-bold; 198 | } 199 | &:not(.active) { 200 | svg { 201 | @apply hidden; 202 | } 203 | } 204 | } 205 | 206 | .rule-label { 207 | @apply inline-block mr-1 mb-2 px-1 rounded border border-blue-400 text-blue-400 uppercase; 208 | &.disabled { 209 | @apply bg-zinc-500 border-zinc-500 text-white; 210 | } 211 | } 212 | 213 | .nav { 214 | @apply flex flex-col w-1/4 max-w-[16rem] p-2 border-r border-zinc-200 dark:border-zinc-600; 215 | } 216 | 217 | .toggle { 218 | @apply text-zinc-400 opacity-80 hover:opacity-100; 219 | &.active { 220 | @apply text-green-600; 221 | } 222 | &.disabled { 223 | @apply opacity-50 hover:opacity-80; 224 | } 225 | } 226 | .list-section { 227 | @apply mb-2; 228 | } 229 | .list-section-title { 230 | @apply sticky top-0 z-1 py-2 text-zinc-600 dark:text-zinc-400; 231 | background: var(--bg-primary); 232 | } 233 | .list-section-empty { 234 | @apply py-1 px-8 italic text-zinc-400 dark:text-zinc-600; 235 | } 236 | .list-section-unsupported { 237 | @apply py-1 px-8; 238 | } 239 | .list-section-badge { 240 | @apply text-xs rounded border border-current text-blue-400 ml-1 px-1 uppercase text-center min-w-[1.5em]; 241 | :hover > & { 242 | @apply no-underline; 243 | } 244 | } 245 | 246 | .list-item { 247 | @apply flex items-center px-2 py-1 rounded cursor-pointer border border-transparent select-none; 248 | &.selected { 249 | @apply bg-zinc-200 dark:bg-zinc-700; 250 | } 251 | &:hover, 252 | &.active { 253 | @apply border-zinc-300 dark:border-zinc-600; 254 | } 255 | svg:not(:hover) { 256 | @apply opacity-90; 257 | } 258 | } 259 | 260 | .menu-bar { 261 | @apply flex flex-1; 262 | } 263 | .menu-group { 264 | @apply uppercase text-xs px-4 py-1 text-zinc-500 dark:text-zinc-400 border-t border-zinc-300 dark:border-zinc-600 text-center; 265 | background: var(--bg-secondary); 266 | } 267 | .menu-toggle { 268 | @apply px-2 py-1 cursor-pointer rounded; 269 | &:hover, 270 | &.active { 271 | @apply bg-zinc-200 dark:bg-zinc-700; 272 | } 273 | } 274 | .menu-sep { 275 | @apply p-0 border-b border-zinc-200 dark:border-zinc-800; 276 | } 277 | .menu-item { 278 | @apply flex px-2 py-1 cursor-pointer whitespace-nowrap; 279 | &.disabled { 280 | @apply cursor-not-allowed text-zinc-400 dark:text-zinc-600; 281 | } 282 | &:not(.disabled):hover { 283 | @apply bg-zinc-200 dark:bg-zinc-700; 284 | } 285 | } 286 | .menu-shortcut { 287 | @apply ml-4 text-zinc-400 dark:text-zinc-600; 288 | } 289 | .menu-dropdown { 290 | @apply select-none rounded border border-zinc-400 dark:border-zinc-600; 291 | background: var(--bg-primary); 292 | } 293 | 294 | .dragging { 295 | @apply opacity-60; 296 | &-over { 297 | @apply relative; 298 | &::before { 299 | content: ''; 300 | @apply absolute top-0 left-0 right-0 h-0.5 bg-yellow-300 dark:bg-yellow-700; 301 | } 302 | } 303 | &-below { 304 | &::before { 305 | @apply top-auto bottom-0; 306 | } 307 | } 308 | } 309 | 310 | .header { 311 | @apply flex items-center px-2 py-1 border-b border-zinc-200 dark:border-zinc-600; 312 | } 313 | 314 | .text-error { 315 | @apply text-red-500 dark:text-red-700; 316 | } 317 | 318 | .popup-enabled-lists { 319 | @apply px-2 overflow-y-auto max-h-[24rem]; 320 | } 321 | -------------------------------------------------------------------------------- /src/common/util.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { reorderList } from './util'; 3 | 4 | describe('reorderList', () => { 5 | test('noop if param invalid', () => { 6 | expect(reorderList([], [0], 1, false)).toBeUndefined(); 7 | expect(reorderList([1, 2, 3, 4], [1], -1, false)).toBeUndefined(); 8 | expect(reorderList([1, 2, 3, 4], [1], 4, false)).toBeUndefined(); 9 | }); 10 | 11 | test('move single item', () => { 12 | expect(reorderList([1, 2, 3, 4], [1], 0, false)).toEqual([2, 1, 3, 4]); 13 | expect(reorderList([1, 2, 3, 4], [2], 1, false)).toEqual([1, 3, 2, 4]); 14 | expect(reorderList([1, 2, 3, 4], [1], 3, true)).toEqual([1, 3, 4, 2]); 15 | }); 16 | 17 | test('move multiple items', () => { 18 | expect(reorderList([1, 2, 3, 4], [1, 2], 0, false)).toEqual([2, 3, 1, 4]); 19 | expect(reorderList([1, 2, 3, 4], [1, 2], 3, true)).toEqual([1, 4, 2, 3]); 20 | expect(reorderList([1, 2, 3, 4], [1, 3], 0, false)).toEqual([2, 4, 1, 3]); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/common/util.ts: -------------------------------------------------------------------------------- 1 | export function reorderList( 2 | array: T[], 3 | selection: number[], 4 | target: number, 5 | downward: boolean, 6 | ) { 7 | if (!selection.length || target < 0 || target >= array.length) return; 8 | const selectedItems = selection.map((i) => array[i]); 9 | const clone: Array = [...array]; 10 | selection.forEach((i) => { 11 | clone[i] = undefined; 12 | }); 13 | clone.splice(target + +downward, 0, ...selectedItems); 14 | return clone.filter((item) => item != null) as T[]; 15 | } 16 | 17 | const noop = () => {}; 18 | 19 | export function defer() { 20 | let resolve: (value: T) => void = noop; 21 | let reject: (err: any) => void = noop; 22 | const promise = new Promise((res, rej) => { 23 | resolve = res; 24 | reject = rej; 25 | }); 26 | return { resolve, reject, promise }; 27 | } 28 | 29 | export function b64encode(bytes: Uint8Array) { 30 | const binString = Array.from(bytes, (x) => String.fromCodePoint(x)).join(''); 31 | return btoa(binString); 32 | } 33 | 34 | export function b64encodeText(text: string) { 35 | const bytes = new TextEncoder().encode(text); 36 | return b64encode(bytes); 37 | } 38 | 39 | export function loadRegExp(rule: string) { 40 | if (rule.startsWith('/') && rule.endsWith('/')) { 41 | try { 42 | return new RegExp(rule.slice(1, -1)).source; 43 | } catch { 44 | // ignore 45 | } 46 | } 47 | } 48 | 49 | function str2re(str: string) { 50 | return str.replace(/([.?/])/g, '\\$1').replace(/\*/g, '.*?'); 51 | } 52 | 53 | const RE_MATCH_ALL = /.*/; 54 | const RE_MATCH_PATTERN = /^([^:]+):\/\/([^/]*)(\/.*)$/; 55 | 56 | export function buildUrlRe(pattern: string) { 57 | if (pattern === '') return ''; 58 | const re = loadRegExp(pattern); 59 | if (re) return re; 60 | let [, scheme, host, path] = pattern.match(RE_MATCH_PATTERN) || []; 61 | if (!scheme) return '^:NEVER_MATCH'; 62 | if (scheme === '*') scheme = '[^:]+'; 63 | if (host === '*') host = '[^/]+'; 64 | else if (host.startsWith('*.')) 65 | host = `(?:[^/]*?\\.)?${str2re(host.slice(2))}`; 66 | else host = str2re(host); 67 | path = str2re(path); 68 | return `^${scheme}:\\/\\/${host}${path}$`; 69 | } 70 | 71 | export function urlTester(pattern: string) { 72 | const re = buildUrlRe(pattern); 73 | return re ? new RegExp(re) : RE_MATCH_ALL; 74 | } 75 | 76 | export function textTester(pattern: string) { 77 | if (pattern.startsWith('/') && pattern.endsWith('/')) { 78 | return new RegExp(pattern.slice(1, -1)); 79 | } 80 | return pattern ? new RegExp(`^${str2re(pattern)}$`) : RE_MATCH_ALL; 81 | } 82 | -------------------------------------------------------------------------------- /src/connector/index.ts: -------------------------------------------------------------------------------- 1 | declare const __INJECT__EVENT_CONTENT: string; 2 | declare const __INJECT__EVENT_MAIN: string; 3 | 4 | const EVENT_CONTENT = __INJECT__EVENT_CONTENT; 5 | const EVENT_MAIN = __INJECT__EVENT_MAIN; 6 | 7 | interface RequestXMainEventPayload { 8 | id: number; 9 | payload?: unknown; 10 | } 11 | 12 | interface IDeferred { 13 | resolve: (value: T) => void; 14 | reject: (reason?: unknown) => void; 15 | promise: Promise; 16 | } 17 | 18 | let gid = 0; 19 | const deferredMap = new Map(); 20 | const noop = () => {}; 21 | 22 | document.addEventListener(EVENT_MAIN, (e) => { 23 | const { id, payload } = (e as CustomEvent).detail; 24 | const deferred = deferredMap.get(id); 25 | deferred?.resolve(payload); 26 | }); 27 | 28 | function defer(): IDeferred { 29 | let resolve_: IDeferred['resolve'] = noop; 30 | let reject_: IDeferred['reject'] = noop; 31 | const promise = new Promise((resolve, reject) => { 32 | resolve_ = resolve; 33 | reject_ = reject; 34 | }); 35 | return { promise, resolve: resolve_, reject: reject_ }; 36 | } 37 | 38 | export function sendMessage(cmd: string, payload: U) { 39 | gid += 1; 40 | const id = gid; 41 | const deferred = defer(); 42 | deferred.promise.finally(() => { 43 | deferredMap.delete(id); 44 | }); 45 | setTimeout(deferred.reject, 10000); 46 | deferredMap.set(id, deferred as IDeferred); 47 | const event = new CustomEvent(EVENT_CONTENT, { 48 | detail: { 49 | id, 50 | cmd, 51 | payload, 52 | }, 53 | }); 54 | document.dispatchEvent(event); 55 | return deferred.promise; 56 | } 57 | -------------------------------------------------------------------------------- /src/connector/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gera2ld/request-x", 3 | "version": "0.1.0", 4 | "description": "Request X Connector", 5 | "author": "Gerald ", 6 | "license": "ISC", 7 | "type": "module", 8 | "module": "index.js", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/gera2ld/request-x.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/gera2ld/request-x/issues" 15 | }, 16 | "homepage": "https://github.com/gera2ld/request-x#readme", 17 | "publishConfig": { 18 | "access": "public", 19 | "registry": "https://registry.npmjs.org/" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/connector/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationDir": "../../lib", 6 | "emitDeclarationOnly": true, 7 | "noEmit": false 8 | }, 9 | "include": ["./index.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/content/index.ts: -------------------------------------------------------------------------------- 1 | import { sendMessage } from '@/common'; 2 | import { EVENT_CONTENT, EVENT_MAIN } from '@/common/constants'; 3 | 4 | interface RequestXContentEventPayload { 5 | id: number; 6 | cmd: string; 7 | payload?: any; 8 | } 9 | 10 | document.addEventListener( 11 | EVENT_CONTENT, 12 | (e) => { 13 | handleMessage((e as CustomEvent).detail); 14 | }, 15 | false, 16 | ); 17 | 18 | const handlers: Record Promise> = { 19 | async SetReplaceResponse(payload: { enabled: boolean }) { 20 | await sendMessage('SetReplaceResponse', payload); 21 | }, 22 | async QueryReplaceResponse(payload: { method: string; url: string }) { 23 | return sendMessage('QueryReplaceResponse', payload); 24 | }, 25 | }; 26 | 27 | // Reset on page reload 28 | handlers.SetReplaceResponse({ enabled: true }); 29 | 30 | async function handleMessage(detail: RequestXContentEventPayload) { 31 | const payload = await handlers[detail.cmd]?.(detail?.payload); 32 | dispatchEvent({ id: detail.id, payload }); 33 | } 34 | 35 | function dispatchEvent(detail: any) { 36 | const event = new CustomEvent(EVENT_MAIN, { detail }); 37 | document.dispatchEvent(event); 38 | } 39 | -------------------------------------------------------------------------------- /src/handler/cookie.ts: -------------------------------------------------------------------------------- 1 | import { textTester, urlTester } from '@/common/util'; 2 | import { CookieData, CookieMatchResult } from '@/types'; 3 | import { debounce, pick } from 'es-toolkit'; 4 | import browser from 'webextension-polyfill'; 5 | import { dataLoaded } from './data'; 6 | import { getUrl } from './util'; 7 | 8 | let cookieRules: CookieData[] = []; 9 | let processing = false; 10 | const updates = new Map(); 11 | let dataReady: Promise; 12 | 13 | const debouncedReload = debounce(reloadRules, 500); 14 | const updateCookiesLater = debounce(updateCookies, 100); 15 | 16 | export const cookieActions = { 17 | reload: debouncedReload, 18 | }; 19 | 20 | browser.cookies.onChanged.addListener(handleCookieChange); 21 | 22 | async function reloadRules() { 23 | const lists = await dataLoaded; 24 | cookieRules = lists.cookie 25 | .filter((list) => list.enabled) 26 | .flatMap((list) => list.rules.filter((rule) => rule.enabled)); 27 | console.info(`[cookie] rules reloaded (${cookieRules.length})`); 28 | } 29 | 30 | async function handleCookieChange( 31 | changeInfo: browser.Cookies.OnChangedChangeInfoType, 32 | ) { 33 | // if (['expired', 'evicted'].includes(changeInfo.cause)) return; 34 | if (changeInfo.cause !== 'explicit') return; 35 | if (processing) return; 36 | await (dataReady ||= reloadRules()); 37 | let update: CookieMatchResult | undefined; 38 | for (const rule of cookieRules) { 39 | const url = getUrl(changeInfo.cookie); 40 | const matches = url.match(urlTester(rule.url)); 41 | if (!matches) continue; 42 | if (rule.name && !changeInfo.cookie.name.match(textTester(rule.name))) 43 | continue; 44 | const { ttl } = rule; 45 | if (changeInfo.removed && !(ttl && ttl > 0)) { 46 | // If cookie is removed and no positive ttl, ignore since change will not persist 47 | continue; 48 | } 49 | update = pick(rule, ['sameSite', 'httpOnly', 'secure']); 50 | if (ttl != null) { 51 | // If ttl is 0, set to undefined to mark the cookie as a session cookie 52 | update.expirationDate = ttl 53 | ? Math.floor(Date.now() / 1000 + ttl) 54 | : undefined; 55 | } 56 | if (update.sameSite === 'no_restriction') update.secure = true; 57 | break; 58 | } 59 | if (update) { 60 | const { cookie } = changeInfo; 61 | const hasUpdate = Object.entries(update).some(([key, value]) => { 62 | return cookie[key as keyof browser.Cookies.Cookie] !== value; 63 | }); 64 | if (!hasUpdate) { 65 | console.info(`[cookie] no update: ${cookie.name} ${getUrl(cookie)}`); 66 | return; 67 | } 68 | const details: browser.Cookies.SetDetailsType = { 69 | url: getUrl(pick(cookie, ['domain', 'path', 'secure'])), 70 | domain: cookie.hostOnly ? undefined : cookie.domain, 71 | expirationDate: cookie.session ? undefined : cookie.expirationDate, 72 | ...pick(cookie, [ 73 | 'name', 74 | 'path', 75 | 'httpOnly', 76 | 'sameSite', 77 | 'secure', 78 | 'storeId', 79 | 'value', 80 | ]), 81 | ...update, 82 | }; 83 | console.info(`[cookie] matched: ${details.name} ${details.url}`, details); 84 | updates.set( 85 | [details.storeId, details.url, details.name].join('\n'), 86 | details, 87 | ); 88 | updateCookiesLater(); 89 | } 90 | } 91 | 92 | async function updateCookies() { 93 | if (processing) return; 94 | processing = true; 95 | const items = Array.from(updates.values()); 96 | updates.clear(); 97 | for (const item of items) { 98 | console.info(`[cookie] set: ${item.name} ${item.url}`, item); 99 | try { 100 | await browser.cookies.set(item); 101 | } catch (err) { 102 | console.error(err); 103 | } 104 | } 105 | processing = false; 106 | } 107 | -------------------------------------------------------------------------------- /src/handler/data.ts: -------------------------------------------------------------------------------- 1 | import { normalizeCookieRule, normalizeRequestRule } from '@/common/list'; 2 | import type { ListData, ListGroups } from '@/types'; 3 | import { flatMap, groupBy } from 'es-toolkit'; 4 | import browser from 'webextension-polyfill'; 5 | import { dumpExactData, getExactData } from './util'; 6 | 7 | const LIST_PREFIX = 'list:'; 8 | const KEY_LISTS = 'lists'; 9 | 10 | let lastId = -1; 11 | 12 | export const dataLoaded = loadData(); 13 | 14 | export async function loadData() { 15 | let ids = await getExactData(KEY_LISTS); 16 | const lists: ListGroups = { request: [], cookie: [] }; 17 | if (Array.isArray(ids)) { 18 | const allData = await browser.storage.local.get( 19 | ids.map((id) => `${LIST_PREFIX}${id}`), 20 | ); 21 | const allLists = ids 22 | .map((id) => allData[`${LIST_PREFIX}${id}`]) 23 | .filter(Boolean) as ListData[]; 24 | const groups = groupBy(allLists, (item) => item.type); 25 | Object.assign(lists, groups); 26 | } else { 27 | const allData = await browser.storage.local.get(); 28 | const allLists = Object.keys(allData) 29 | .filter((key) => key.startsWith(LIST_PREFIX)) 30 | .map((key) => allData[key]) as ListData[]; 31 | const groups = groupBy(allLists, (item) => item.type); 32 | Object.assign(lists, groups); 33 | } 34 | if (import.meta.env.DEV) console.log('loadData:raw', lists); 35 | ids = Object.values(lists).flatMap((group: ListData[]) => 36 | group.map((item) => item.id), 37 | ); 38 | lastId = Math.max(0, ...ids); 39 | lists.request.forEach((list) => { 40 | list.enabled ??= true; 41 | list.rules = flatMap(list.rules, normalizeRequestRule); 42 | }); 43 | lists.cookie.forEach((list) => { 44 | list.enabled ??= true; 45 | list.rules = flatMap(list.rules, normalizeCookieRule); 46 | }); 47 | if (import.meta.env.DEV) console.log('loadData', lists); 48 | return lists; 49 | } 50 | 51 | export function getKey(id: number) { 52 | return `${LIST_PREFIX}${id}`; 53 | } 54 | 55 | export async function dumpLists(lists: ListGroups) { 56 | await dumpExactData( 57 | KEY_LISTS, 58 | Object.values(lists).flatMap((group: ListData[]) => 59 | group.map((group) => group.id), 60 | ), 61 | ); 62 | } 63 | 64 | export async function saveList(data: Partial) { 65 | const list: ListData = { 66 | id: 0, 67 | name: 'No name', 68 | subscribeUrl: '', 69 | lastUpdated: 0, 70 | enabled: true, 71 | type: 'request', 72 | rules: [], 73 | ...data, 74 | }; 75 | if (!list.rules) throw new Error('Invalid list data'); 76 | list.name ||= 'No name'; 77 | if (!list.id) { 78 | if (lastId < 0) throw new Error('Data is not loaded yet'); 79 | list.id = ++lastId; 80 | } 81 | if (import.meta.env.DEV) console.log('saveList', list); 82 | await dumpExactData(getKey(list.id), list); 83 | return list; 84 | } 85 | -------------------------------------------------------------------------------- /src/handler/index.ts: -------------------------------------------------------------------------------- 1 | import { handleMessages, sendMessage } from '@/common'; 2 | import { reorderList } from '@/common/util'; 3 | import { ListData } from '@/types'; 4 | import browser from 'webextension-polyfill'; 5 | import { dataLoaded, dumpLists } from './data'; 6 | import { broadcastUpdates, fetchList, reloadRules, updateLists } from './list'; 7 | import { requestActions } from './request'; 8 | 9 | const actions: Array<{ 10 | name: string; 11 | payload: any; 12 | }> = []; 13 | 14 | browser.tabs.onCreated.addListener((tab) => { 15 | if (!tab.id) return; 16 | const url = getSubsribeUrl(tab.url || tab.pendingUrl); 17 | if (url) { 18 | initiateSubscription(url); 19 | browser.tabs.remove(tab.id); 20 | } 21 | }); 22 | browser.tabs.onUpdated.addListener((tabId, changeInfo) => { 23 | const url = getSubsribeUrl(changeInfo.url); 24 | if (url) { 25 | initiateSubscription(url); 26 | browser.tabs.goBack(tabId); 27 | } 28 | }); 29 | browser.tabs.onRemoved.addListener((tabId) => { 30 | requestActions.setReplaceResponse(tabId, true); 31 | }); 32 | 33 | // Show Release Notes on update to v3 34 | browser.runtime.onInstalled.addListener((details) => { 35 | if (details.reason === 'update') { 36 | const major = details.previousVersion?.split('.')[0]; 37 | if (major && +major < 3) { 38 | browser.tabs.create({ 39 | url: 'https://github.com/gera2ld/request-x/releases/tag/v3.0.0', 40 | }); 41 | } 42 | } 43 | }); 44 | 45 | handleMessages({ 46 | async GetLists() { 47 | return dataLoaded; 48 | }, 49 | async GetErrors() { 50 | return requestActions.getRuleErrors(); 51 | }, 52 | async SaveLists(payload: Partial[]) { 53 | const updatedLists = await updateLists(payload); 54 | broadcastUpdates(); 55 | return updatedLists; 56 | }, 57 | async MoveLists(payload: { 58 | type: 'request' | 'cookie'; 59 | selection: number[]; 60 | target: number; 61 | downward: boolean; 62 | }) { 63 | const lists = await dataLoaded; 64 | const { type, selection, target, downward } = payload; 65 | const reordered = reorderList( 66 | lists[type] as ListData[], 67 | selection, 68 | target, 69 | downward, 70 | ); 71 | if (reordered) { 72 | lists[type] = reordered as any; 73 | dumpLists(lists); 74 | } 75 | }, 76 | FetchLists: fetchLists, 77 | async FetchList(payload: { id: number }) { 78 | const lists = await dataLoaded; 79 | const list = ([...lists.request, ...lists.cookie] as ListData[]).find( 80 | (item) => item.id === payload.id, 81 | ); 82 | await fetchList(list); 83 | broadcastUpdates(); 84 | }, 85 | async RemoveList(payload: { id: number }) { 86 | const lists = await dataLoaded; 87 | const removedLists: ListData[] = []; 88 | Object.values(lists).forEach((group: ListData[]) => { 89 | const i = group.findIndex(({ id }) => id === payload.id); 90 | if (i >= 0) removedLists.push(...group.splice(i, 1)); 91 | }); 92 | dumpLists(lists); 93 | reloadRules({ removed: removedLists }); 94 | broadcastUpdates(); 95 | }, 96 | GetAction() { 97 | return actions.shift(); 98 | }, 99 | CreateAction(payload: { name: string; payload: any }) { 100 | createAction(payload); 101 | }, 102 | SetReplaceResponse(payload: { enabled: boolean }, sender) { 103 | const tabId = sender.tab?.id; 104 | if (tabId) { 105 | requestActions.setReplaceResponse(tabId, payload.enabled); 106 | } 107 | }, 108 | QueryReplaceResponse(payload: { method: string; url: string }) { 109 | return requestActions.queryReplaceResponse(payload.method, payload.url); 110 | }, 111 | }); 112 | 113 | browser.alarms.create({ 114 | delayInMinutes: 1, 115 | periodInMinutes: 120, 116 | }); 117 | browser.alarms.onAlarm.addListener(() => { 118 | console.info(new Date().toISOString(), 'Fetching lists...'); 119 | fetchLists(); 120 | }); 121 | 122 | main(); 123 | 124 | function main() { 125 | requestActions.reload(); 126 | } 127 | 128 | async function fetchLists() { 129 | const lists = await dataLoaded; 130 | await Promise.all( 131 | ([...lists.request, ...lists.cookie] as ListData[]) 132 | .filter((list) => list.subscribeUrl) 133 | .map(async (list) => { 134 | await fetchList(list); 135 | broadcastUpdates(); 136 | }), 137 | ); 138 | } 139 | 140 | function initiateSubscription(url: string) { 141 | createAction({ 142 | name: 'SubscribeUrl', 143 | payload: url, 144 | }); 145 | } 146 | 147 | async function createAction(action: { name: string; payload: any }) { 148 | actions.push(action); 149 | await browser.runtime.openOptionsPage(); 150 | sendMessage('CheckAction'); 151 | } 152 | 153 | function getSubsribeUrl(url: string | undefined) { 154 | if (!url) return; 155 | if (url.endsWith('#:request-x:')) { 156 | return url.split('#')[0]; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/handler/list.ts: -------------------------------------------------------------------------------- 1 | import { sendMessage } from '@/common'; 2 | import { 3 | fetchListData, 4 | normalizeCookieRule, 5 | normalizeRequestRule, 6 | } from '@/common/list'; 7 | import type { ListData, RuleData } from '@/types'; 8 | import { cookieActions } from './cookie'; 9 | import { dataLoaded, dumpLists, saveList } from './data'; 10 | import { requestActions } from './request'; 11 | 12 | export function reloadRules(data: { 13 | updated?: ListData[]; 14 | removed?: ListData[]; 15 | }) { 16 | const updatedLists = [...(data.updated || []), ...(data.removed || [])]; 17 | if (updatedLists.some((list) => list.type === 'request')) 18 | requestActions.reload(); 19 | if (updatedLists.some((list) => list.type === 'cookie')) 20 | cookieActions.reload(); 21 | } 22 | 23 | export async function updateList(data: Partial) { 24 | const list = await saveList(data); 25 | reloadRules({ 26 | updated: [list], 27 | }); 28 | return list; 29 | } 30 | 31 | export async function updateLists(payload: Partial[]) { 32 | const lists = await dataLoaded; 33 | const updatedLists = await Promise.all( 34 | payload.map(async (data) => { 35 | if (data.id) { 36 | data = { 37 | ...Object.values(lists) 38 | .flat() 39 | .find((list) => list.id === data.id), 40 | ...data, 41 | }; 42 | } 43 | const list = await saveList(data); 44 | const group = lists[list.type] as ListData[]; 45 | const i = group.findIndex((item) => item.id === list.id); 46 | if (i < 0) { 47 | group.push(list); 48 | } else { 49 | group[i] = list; 50 | } 51 | return list; 52 | }), 53 | ); 54 | if (updatedLists.length) { 55 | dumpLists(lists); 56 | reloadRules({ updated: updatedLists }); 57 | } 58 | return updatedLists; 59 | } 60 | 61 | const cache: Record> = {}; 62 | 63 | async function doFetchList(list: ListData) { 64 | const url = list.subscribeUrl; 65 | if (!url) return; 66 | const data = await fetchListData(url); 67 | if (data.type !== list.type) throw new Error('Type mismatch'); 68 | if (!data.rules) throw new Error('Invalid data'); 69 | list.lastUpdated = Date.now(); 70 | list.rules = data.rules.flatMap( 71 | (list.type === 'cookie' ? normalizeCookieRule : normalizeRequestRule) as ( 72 | data: any, 73 | ) => RuleData[], 74 | ); 75 | await updateList(list); 76 | } 77 | 78 | export function fetchList(list: ListData | undefined) { 79 | if (!list?.subscribeUrl) return; 80 | let promise = cache[list.id]; 81 | if (!promise) { 82 | promise = doFetchList(list); 83 | cache[list.id] = promise; 84 | promise.finally(() => { 85 | delete cache[list.id]; 86 | }); 87 | } 88 | return promise; 89 | } 90 | 91 | export function broadcastUpdates() { 92 | sendMessage('UpdateLists'); 93 | } 94 | -------------------------------------------------------------------------------- /src/handler/request.ts: -------------------------------------------------------------------------------- 1 | import { URL_TRANSFORM_KEYS } from '@/common/constants'; 2 | import { b64encodeText, loadRegExp } from '@/common/util'; 3 | import type { RequestData, RequestListData } from '@/types'; 4 | import { debounce, isEqual } from 'es-toolkit'; 5 | import browser from 'webextension-polyfill'; 6 | import { dataLoaded } from './data'; 7 | 8 | const MAX_RULES_PER_LIST = 100; 9 | const resourceTypes: browser.DeclarativeNetRequest.ResourceType[] = [ 10 | 'csp_report', 11 | 'font', 12 | 'image', 13 | 'main_frame', 14 | 'media', 15 | 'object', 16 | 'other', 17 | 'ping', 18 | 'script', 19 | 'stylesheet', 20 | 'sub_frame', 21 | 'websocket', 22 | 'xmlhttprequest', 23 | ]; 24 | const requestRuleTypeMap: Record< 25 | RequestData['type'], 26 | browser.DeclarativeNetRequest.RuleActionTypeEnum 27 | > = { 28 | block: 'block', 29 | redirect: 'redirect', 30 | transform: 'redirect', 31 | replace: 'redirect', 32 | headers: 'modifyHeaders', 33 | }; 34 | 35 | const ruleErrors: Record> = {}; 36 | const excludedReplaceTabIds: number[] = []; 37 | let replaceRules: RequestData[] = []; 38 | 39 | const debouncedReload = debounce(reloadRules, 500); 40 | 41 | export const requestActions = { 42 | getRuleErrors() { 43 | return ruleErrors; 44 | }, 45 | reload: debouncedReload, 46 | setReplaceResponse, 47 | queryReplaceResponse, 48 | }; 49 | 50 | async function reloadRules() { 51 | const data = await dataLoaded; 52 | let lists = data.request; 53 | lists = lists.filter((list) => list.enabled); 54 | const listIds = new Set(lists.map((list) => list.id)); 55 | Object.keys(ruleErrors).forEach((key) => { 56 | const id = +key; 57 | if (!listIds.has(id)) delete ruleErrors[id]; 58 | }); 59 | const allRules = await browser.declarativeNetRequest.getSessionRules(); 60 | const toRemove = allRules.filter( 61 | (rule) => !listIds.has(Math.floor(rule.id / MAX_RULES_PER_LIST)), 62 | ); 63 | if (toRemove.length) { 64 | if (import.meta.env.DEV) console.log('removeRules', toRemove); 65 | await browser.declarativeNetRequest.updateSessionRules({ 66 | removeRuleIds: toRemove.map((rule) => rule.id), 67 | }); 68 | } 69 | await Promise.all(lists.map((list) => reloadRulesForList(list, allRules))); 70 | replaceRules = lists.flatMap((list) => 71 | list.rules.filter((rule) => rule.enabled && rule.type === 'replace'), 72 | ); 73 | if (import.meta.env.DEV) console.log('[request] rules reloaded', ruleErrors); 74 | } 75 | 76 | async function reloadRulesForList( 77 | list: RequestListData, 78 | allRules: browser.DeclarativeNetRequest.Rule[], 79 | ) { 80 | const currentRules = allRules.filter( 81 | (rule) => Math.floor(rule.id / MAX_RULES_PER_LIST) === list.id, 82 | ); 83 | const rules = buildListRules(list); 84 | const updates: Record< 85 | string, 86 | { 87 | old?: browser.DeclarativeNetRequest.Rule; 88 | new?: browser.DeclarativeNetRequest.Rule; 89 | } 90 | > = {}; 91 | currentRules.forEach((rule) => { 92 | updates[rule.id] = { old: rule }; 93 | }); 94 | rules.forEach((rule) => { 95 | const update = updates[rule.id]; 96 | if (update && isEqual(update.old, rule)) delete updates[rule.id]; 97 | else updates[rule.id] = { ...update, new: rule }; 98 | }); 99 | const toRemove = Object.keys(updates) 100 | .filter((key) => !updates[key].new) 101 | .map((id) => +id); 102 | if (toRemove.length) { 103 | if (import.meta.env.DEV) 104 | console.log( 105 | 'removeRules', 106 | toRemove.map((id) => updates[id].old), 107 | ); 108 | await browser.declarativeNetRequest.updateSessionRules({ 109 | removeRuleIds: toRemove, 110 | }); 111 | } 112 | delete ruleErrors[list.id]; 113 | const errors: Record = {}; 114 | for (const update of Object.values(updates)) { 115 | if (!update.new) continue; 116 | try { 117 | if (import.meta.env.DEV) console.log('updateRule', update); 118 | await browser.declarativeNetRequest.updateSessionRules({ 119 | addRules: [update.new], 120 | removeRuleIds: update.old ? [update.old.id] : [], 121 | }); 122 | } catch (err) { 123 | console.error(err); 124 | errors[update.new.id % MAX_RULES_PER_LIST] = `${err}`; 125 | ruleErrors[list.id] = errors; 126 | } 127 | } 128 | } 129 | 130 | function setReplaceResponse(tabId: number, enabled: boolean) { 131 | const i = excludedReplaceTabIds.indexOf(tabId); 132 | if (!enabled && i < 0) { 133 | excludedReplaceTabIds.push(tabId); 134 | } else if (enabled && i >= 0) { 135 | excludedReplaceTabIds.splice(i, 1); 136 | } else { 137 | return; 138 | } 139 | reloadRules(); 140 | } 141 | 142 | function queryReplaceResponse(method: string, url: string) { 143 | const rule = replaceRules.find((item) => { 144 | if (item.methods.length && !item.methods.includes(method)) return false; 145 | const re = loadRegExp(item.url); 146 | if (re) { 147 | if (!new RegExp(re).test(url)) return false; 148 | } else { 149 | // TODO: support URL filters 150 | if (!url.includes(item.url)) return false; 151 | } 152 | return true; 153 | }); 154 | if (rule) 155 | return { 156 | contentType: rule.contentType, 157 | target: rule.target, 158 | }; 159 | } 160 | 161 | function buildListRules(list: RequestListData, base = MAX_RULES_PER_LIST) { 162 | const rules: browser.DeclarativeNetRequest.Rule[] = list.rules.flatMap( 163 | (item, i) => { 164 | const type = requestRuleTypeMap[item.type]; 165 | if (!item.enabled) return []; 166 | const rule: browser.DeclarativeNetRequest.Rule = { 167 | id: list.id * base + i + 1, 168 | action: { 169 | type, 170 | }, 171 | condition: { 172 | resourceTypes, 173 | }, 174 | }; 175 | // Do not support match patterns here as they may exceed the 2KB memory limit after conversion to RegExp 176 | const re = loadRegExp(item.url); 177 | if (re) { 178 | rule.condition.regexFilter = re; 179 | } else { 180 | rule.condition.urlFilter = item.url; 181 | } 182 | if (item.methods.length) rule.condition.requestMethods = item.methods; 183 | switch (item.type) { 184 | case 'redirect': { 185 | rule.action.redirect = { 186 | [re ? 'regexSubstitution' : 'url']: item.target, 187 | }; 188 | break; 189 | } 190 | case 'transform': { 191 | rule.action.redirect = { 192 | transform: buildUrlTransform(item.transform), 193 | }; 194 | break; 195 | } 196 | case 'replace': { 197 | rule.action.redirect = { 198 | url: `data:${item.contentType || ''};base64,${b64encodeText( 199 | item.target, 200 | )}`, 201 | }; 202 | rule.condition.excludedTabIds = excludedReplaceTabIds; 203 | break; 204 | } 205 | case 'headers': { 206 | const validKeys = ( 207 | ['requestHeaders', 'responseHeaders'] as const 208 | ).filter((key) => { 209 | const headerItems = item[key]; 210 | const updates: browser.DeclarativeNetRequest.RuleActionRequestHeadersItemType[] = 211 | []; 212 | headerItems?.forEach((headerItem) => { 213 | const { name, value } = headerItem; 214 | if (name[0] === '#') return; 215 | if (name[0] === '!') { 216 | updates.push({ 217 | header: name.slice(1), 218 | operation: 'remove', 219 | }); 220 | } else { 221 | updates.push({ 222 | header: name, 223 | operation: 'set', 224 | value, 225 | }); 226 | } 227 | }); 228 | if (updates.length) { 229 | rule.action[key] = updates; 230 | return true; 231 | } 232 | }); 233 | if (!validKeys.length) return []; 234 | break; 235 | } 236 | } 237 | return rule; 238 | }, 239 | ); 240 | return rules; 241 | } 242 | 243 | function buildUrlTransform(transform: RequestData['transform']) { 244 | if (!transform) return; 245 | const urlTransform: browser.DeclarativeNetRequest.URLTransform = {}; 246 | const query = transform.query; 247 | if (query) { 248 | const firstName = query?.[0]?.name; 249 | if (firstName?.[0] === '?') { 250 | urlTransform.query = firstName; 251 | } else if (firstName === '!') { 252 | urlTransform.query = ''; 253 | } else { 254 | const toSet: browser.DeclarativeNetRequest.URLTransformQueryTransformAddOrReplaceParamsItemType[] = 255 | []; 256 | const toRemove: string[] = []; 257 | query?.forEach((transformItem) => { 258 | const { name, value } = transformItem; 259 | if (name[0] === '#') return; 260 | if (name[0] === '!') { 261 | toRemove.push(name.slice(1)); 262 | } else { 263 | toSet.push({ key: name, value: value || '' }); 264 | } 265 | }); 266 | if (toSet.length) { 267 | urlTransform.queryTransform = { 268 | ...urlTransform, 269 | addOrReplaceParams: toSet, 270 | }; 271 | } 272 | if (toRemove.length) { 273 | urlTransform.queryTransform = { 274 | ...urlTransform, 275 | removeParams: toRemove, 276 | }; 277 | } 278 | } 279 | } 280 | URL_TRANSFORM_KEYS.forEach((key) => { 281 | const value = transform[key]; 282 | if (value) urlTransform[key] = value; 283 | }); 284 | return urlTransform; 285 | } 286 | -------------------------------------------------------------------------------- /src/handler/util.ts: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | 3 | export async function getExactData(key: string): Promise { 4 | const res = await browser.storage.local.get(key); 5 | return res[key] as T; 6 | } 7 | 8 | export async function dumpExactData(key: string, value: any) { 9 | await browser.storage.local.set({ 10 | [key]: value, 11 | }); 12 | } 13 | 14 | export async function getActiveTab() { 15 | const [tab] = await browser.tabs.query({ 16 | active: true, 17 | lastFocusedWindow: true, // also gets incognito windows 18 | }); 19 | return tab; 20 | } 21 | 22 | export class ObjectStorage { 23 | ready: Promise | undefined; 24 | 25 | static async load( 26 | key: string, 27 | defaults: T, 28 | ) { 29 | const data = await getExactData(key); 30 | return new ObjectStorage(key, data || defaults); 31 | } 32 | 33 | constructor( 34 | private key: string, 35 | private data: T, 36 | ) {} 37 | 38 | dump() { 39 | return dumpExactData(this.key, this.data); 40 | } 41 | 42 | get(path: K): Promise { 43 | return this.data[path]; 44 | } 45 | 46 | getAll() { 47 | return this.data; 48 | } 49 | 50 | async set(update: Partial | ((data: T) => Partial)) { 51 | if (typeof update === 'function') { 52 | Object.assign(this.data, update(this.data)); 53 | } else { 54 | Object.assign(this.data, update); 55 | } 56 | await this.dump(); 57 | } 58 | } 59 | 60 | export function getUrl(cookie: { 61 | secure: boolean; 62 | domain: string; 63 | path: string; 64 | }) { 65 | const url = [ 66 | cookie.secure ? 'https:' : 'http:', 67 | '//', 68 | cookie.domain.startsWith('.') ? 'www' : '', 69 | cookie.domain, 70 | cookie.path, 71 | ].join(''); 72 | return url; 73 | } 74 | -------------------------------------------------------------------------------- /src/manifest.yml: -------------------------------------------------------------------------------- 1 | manifest_version: 3 2 | name: Request X 3 | version: __VERSION__ 4 | # Requires Chrome 99+ to support sendMessage with callback / promise 5 | minimum_chrome_version: '99.0' 6 | description: Intercept requests and cookies by flexible rules. 7 | author: 8 | email: liuxc07@gmail.com 9 | icons: 10 | '16': public/images/icon_16.png 11 | '48': public/images/icon_48.png 12 | '128': public/images/icon_128.png 13 | default_locale: en 14 | action: 15 | default_icon: 16 | '19': public/images/icon_19.png 17 | '38': public/images/icon_38.png 18 | default_title: Request X 19 | default_popup: popup/index.html 20 | background: 21 | service_worker: handler/index.js 22 | type: module 23 | content_scripts: 24 | - matches: 25 | - '' 26 | js: 27 | - content/index.js 28 | run_at: document_start 29 | options_page: options/index.html 30 | permissions: 31 | - declarativeNetRequest 32 | - declarativeNetRequestWithHostAccess 33 | - storage 34 | - alarms 35 | - cookies 36 | host_permissions: 37 | - '' 38 | -------------------------------------------------------------------------------- /src/options/actions.ts: -------------------------------------------------------------------------------- 1 | import { sendMessage } from '@/common'; 2 | import { keyboardService } from '@/common/keyboard'; 3 | import { getName, normalizeRequestRule } from '@/common/list'; 4 | import { reorderList } from '@/common/util'; 5 | import type { ListData, ListsDumpData, RuleData, RulesDumpData } from '@/types'; 6 | import { debounce, pick } from 'es-toolkit'; 7 | import { toRaw, watch } from 'vue'; 8 | import { shortcutMap } from './shortcut'; 9 | import { 10 | currentList, 11 | ensureGroupSelection, 12 | listEditable, 13 | listSelection, 14 | listTypes, 15 | ruleSelection, 16 | ruleState, 17 | selectedLists, 18 | store, 19 | } from './store'; 20 | import { 21 | blob2Text, 22 | compareNumberArray, 23 | downloadBlob, 24 | dumpList, 25 | editList, 26 | isRoute, 27 | loadFile, 28 | setRoute, 29 | } from './util'; 30 | 31 | const PROVIDER = 'Request X'; 32 | 33 | export const listActions = { 34 | new() { 35 | store.editList = { 36 | name: '', 37 | type: 'request', 38 | }; 39 | }, 40 | open(id: number) { 41 | window.location.hash = `#lists/${id}`; 42 | }, 43 | async import() { 44 | const blob = await loadFile(); 45 | const text = await blob2Text(blob); 46 | let data = JSON.parse(text); 47 | let importData: ListsDumpData; 48 | if (data.provider === PROVIDER) { 49 | if (data.category !== 'lists') { 50 | throw new Error(`Invalid category: ${data.category}`); 51 | } 52 | importData = data; 53 | } else { 54 | if (!Array.isArray(data)) { 55 | data = [data]; 56 | } 57 | data.forEach((list: Partial) => { 58 | list.type ??= 'request'; 59 | }); 60 | importData = { 61 | provider: PROVIDER, 62 | category: 'lists', 63 | data, 64 | }; 65 | } 66 | listActions.selPaste(importData); 67 | }, 68 | subscribe() { 69 | editList({ 70 | subscribeUrl: '', 71 | isSubscribed: true, 72 | }); 73 | }, 74 | fetchAll() { 75 | sendMessage('FetchLists'); 76 | }, 77 | toggle(item?: ListData) { 78 | item ||= currentList.value; 79 | if (item) { 80 | item.enabled = !item.enabled; 81 | listActions.save([pick(item, ['id', 'enabled'])]); 82 | } 83 | }, 84 | edit(item?: ListData) { 85 | item ||= currentList.value; 86 | editList(item); 87 | }, 88 | cancel() { 89 | store.editList = undefined; 90 | }, 91 | fetch(item?: ListData) { 92 | item ||= currentList.value; 93 | if (item) sendMessage('FetchList', pick(item, ['id'])); 94 | }, 95 | remove(item?: ListData) { 96 | item ||= currentList.value; 97 | if (item) sendMessage('RemoveList', pick(item, ['id'])); 98 | }, 99 | async move( 100 | type: 'request' | 'cookie', 101 | selection: number[], 102 | target: number, 103 | downward: boolean, 104 | ) { 105 | await sendMessage('MoveLists', { 106 | type, 107 | selection, 108 | target, 109 | downward, 110 | }); 111 | const reordered = reorderList( 112 | store.lists[type] as ListData[], 113 | selection, 114 | target, 115 | downward, 116 | ); 117 | if (reordered) { 118 | store.lists[type] = reordered as any; 119 | } 120 | }, 121 | async save(payload: Partial[]) { 122 | return sendMessage('SaveLists', payload); 123 | }, 124 | async fork(item?: ListData) { 125 | item ||= currentList.value; 126 | if (!item) return; 127 | const data = { 128 | type: item.type, 129 | name: `${item.name || 'No name'} (forked)`, 130 | rules: item.rules, 131 | }; 132 | const [{ id }] = await listActions.save([data]); 133 | setRoute(`lists/${id}`); 134 | }, 135 | selClear() { 136 | listSelection.groupIndex = -1; 137 | listSelection.itemIndex = -1; 138 | listSelection.selection = []; 139 | }, 140 | selAll() { 141 | listSelection.selection = []; 142 | listTypes.forEach((type, i) => { 143 | const selection = ensureGroupSelection(i); 144 | const lists = store.lists[type] || []; 145 | lists.forEach((_, j) => { 146 | selection.selected[j] = true; 147 | }); 148 | selection.count = lists.length; 149 | }); 150 | }, 151 | selToggle( 152 | groupIndex: number, 153 | itemIndex: number, 154 | event: { cmdCtrl: boolean; shift: boolean }, 155 | ) { 156 | const lastGroupIndex = listSelection.groupIndex; 157 | const lastItemIndex = listSelection.itemIndex; 158 | listSelection.groupIndex = groupIndex; 159 | 160 | if (event.shift && lastGroupIndex >= 0 && lastItemIndex >= 0) { 161 | listSelection.selection = []; 162 | let start = [lastGroupIndex, lastItemIndex]; 163 | let end = [groupIndex, itemIndex]; 164 | if (compareNumberArray(start, end) > 0) { 165 | [start, end] = [end, start]; 166 | } 167 | for (let i = start[0]; i <= end[0]; i += 1) { 168 | const jStart = i === start[0] ? start[1] : 0; 169 | const jEnd = 170 | i === end[0] ? end[1] : (store.lists[listTypes[i]]?.length ?? -1); 171 | const selection = { 172 | count: jEnd - jStart + 1, 173 | selected: [] as boolean[], 174 | }; 175 | for (let j = jStart; j <= jEnd; j += 1) { 176 | selection.selected[j] = true; 177 | } 178 | listSelection.selection[i] = selection; 179 | } 180 | return; 181 | } 182 | 183 | if (event.cmdCtrl) { 184 | const selection = ensureGroupSelection(groupIndex); 185 | if ((selection.selected[itemIndex] = !selection.selected[itemIndex])) { 186 | selection.count += 1; 187 | } else { 188 | selection.count -= 1; 189 | } 190 | } else { 191 | listSelection.selection = []; 192 | const selection = ensureGroupSelection(groupIndex); 193 | selection.selected[itemIndex] = true; 194 | selection.count = 1; 195 | } 196 | listSelection.itemIndex = itemIndex; 197 | }, 198 | selRemove() { 199 | let count = 0; 200 | listSelection.selection.forEach((selection, i) => { 201 | if (!selection.count) return; 202 | store.lists[listTypes[i]]?.forEach((item, j) => { 203 | if (selection.selected[j]) { 204 | listActions.remove(item); 205 | count += 1; 206 | } 207 | }); 208 | }); 209 | listActions.selClear(); 210 | return count; 211 | }, 212 | selCopy() { 213 | const lists = selectedLists.value.map(dumpList); 214 | if (!lists?.length) return 0; 215 | const data: ListsDumpData = { 216 | provider: PROVIDER, 217 | category: 'lists', 218 | data: lists, 219 | }; 220 | navigator.clipboard.writeText(JSON.stringify(data)); 221 | return lists.length; 222 | }, 223 | selCut() { 224 | const count = listActions.selCopy(); 225 | if (count) listActions.selRemove(); 226 | return count; 227 | }, 228 | async selPaste({ data }: ListsDumpData) { 229 | await listActions.save( 230 | data.map((list) => { 231 | const data = pick(list, [ 232 | 'name', 233 | 'type', 234 | 'enabled', 235 | 'rules', 236 | 'subscribeUrl', 237 | ]); 238 | if (data.type === 'request' && data.rules) 239 | data.rules = data.rules.flatMap(fixRequestRule); 240 | return data; 241 | }), 242 | ); 243 | }, 244 | selExport() { 245 | const lists = selectedLists.value.map(dumpList); 246 | if (!lists.length) return; 247 | const basename = 248 | lists.length === 1 249 | ? getName(lists[0]).replace(/\s+/g, '-').toLowerCase() 250 | : `request-x-export-${Date.now()}`; 251 | const blob = new Blob( 252 | [JSON.stringify(lists.length === 1 ? lists[0] : lists)], 253 | { 254 | type: 'application/json', 255 | }, 256 | ); 257 | downloadBlob(blob, `${basename}.json`); 258 | }, 259 | selToggleStatus() { 260 | const lists = selectedLists.value; 261 | if (!lists.length) return; 262 | const enabled = lists.some((list) => !list.enabled); 263 | const updatedLists = lists.filter((list) => list.enabled !== enabled); 264 | updatedLists.forEach((list) => { 265 | list.enabled = enabled; 266 | }); 267 | listActions.save(updatedLists.map((list) => pick(list, ['id', 'enabled']))); 268 | }, 269 | }; 270 | 271 | export const ruleActions = { 272 | selToggle(index: number, event: { cmdCtrl: boolean; shift: boolean }) { 273 | if (event.shift && ruleSelection.active >= 0) { 274 | ruleSelection.selected = []; 275 | const start = Math.min(ruleSelection.active, index); 276 | const end = Math.max(ruleSelection.active, index); 277 | for (let i = start; i <= end; i += 1) { 278 | ruleSelection.selected[i] = true; 279 | } 280 | ruleSelection.count = end - start + 1; 281 | return; 282 | } 283 | if (event.cmdCtrl) { 284 | if ((ruleSelection.selected[index] = !ruleSelection.selected[index])) { 285 | ruleSelection.count += 1; 286 | } else { 287 | ruleSelection.count -= 1; 288 | } 289 | } else { 290 | ruleSelection.selected = []; 291 | ruleSelection.selected[index] = true; 292 | ruleSelection.count = 1; 293 | } 294 | ruleSelection.active = index; 295 | }, 296 | selClear() { 297 | ruleSelection.active = -1; 298 | ruleSelection.count = 0; 299 | ruleSelection.selected.length = 0; 300 | }, 301 | selAll() { 302 | const current = currentList.value; 303 | if (!current) return; 304 | ruleSelection.selected.length = 0; 305 | (current.rules as RuleData[]).forEach((_, i) => { 306 | ruleSelection.selected[i] = true; 307 | }); 308 | ruleSelection.count = current.rules.length; 309 | }, 310 | save() { 311 | const current = currentList.value; 312 | if (!current) return; 313 | listActions.save([current]); 314 | }, 315 | selRemove() { 316 | const current = currentList.value; 317 | if (!current || !listEditable.value) return; 318 | current.rules = (current.rules as RuleData[]).filter( 319 | (_, index) => !ruleSelection.selected[index], 320 | ) as ListData['rules']; 321 | ruleActions.selClear(); 322 | ruleActions.save(); 323 | }, 324 | selDuplicate() { 325 | const current = currentList.value; 326 | if (!current || !listEditable.value) return; 327 | const rules: RuleData[] = []; 328 | const selected: boolean[] = []; 329 | let offset = 0; 330 | current.rules.forEach((rule: RuleData, index: number) => { 331 | rules.push(rule); 332 | if (ruleSelection.selected[index]) { 333 | rules.push(rule); 334 | offset += 1; 335 | selected[index + offset] = true; 336 | } 337 | }); 338 | current.rules = rules as ListData['rules']; 339 | ruleActions.save(); 340 | ruleSelection.count = offset * 2; 341 | ruleSelection.selected = selected; 342 | ruleActions.update(); 343 | }, 344 | selCopy() { 345 | const current = currentList.value; 346 | if (!current || !ruleSelection.count) return; 347 | const rules = (current.rules as RuleData[])?.filter( 348 | (_, index) => ruleSelection.selected[index], 349 | ); 350 | if (!rules?.length) return; 351 | const data: RulesDumpData = { 352 | provider: PROVIDER, 353 | category: 'rules', 354 | data: { 355 | type: current.type, 356 | rules, 357 | }, 358 | }; 359 | navigator.clipboard.writeText(JSON.stringify(data)); 360 | }, 361 | selCut() { 362 | if (!listEditable.value) return; 363 | ruleActions.selCopy(); 364 | ruleActions.selRemove(); 365 | }, 366 | async selPaste({ data }: RulesDumpData) { 367 | const current = currentList.value; 368 | if (!current || !listEditable.value) return; 369 | if (!data.type || !data.rules?.length) 370 | throw new Error('Invalid clipboard data'); 371 | if (data.type !== current.type) throw new Error('Incompatible rule type'); 372 | const rules = current.rules as RuleData[]; 373 | let newRules = data.rules as RuleData[]; 374 | if (data.type === 'request') newRules = newRules.flatMap(fixRequestRule); 375 | rules.splice( 376 | ruleSelection.active < 0 ? rules.length : ruleSelection.active, 377 | 0, 378 | ...newRules, 379 | ); 380 | ruleActions.save(); 381 | }, 382 | selEdit() { 383 | ruleActions.edit(ruleSelection.active); 384 | }, 385 | new() { 386 | const current = currentList.value; 387 | if (!current) { 388 | store.hintType = 'listRequired'; 389 | return; 390 | } 391 | if (!listEditable.value) return; 392 | ruleState.newRule = { 393 | enabled: true, 394 | }; 395 | ruleState.editing = current.rules.length; 396 | ruleActions.selClear(); 397 | }, 398 | edit(index = -1) { 399 | ruleState.newRule = undefined; 400 | ruleState.editing = index; 401 | ruleActions.selClear(); 402 | }, 403 | cancel() { 404 | const active = ruleState.editing; 405 | ruleActions.edit(); 406 | ruleSelection.active = active; 407 | }, 408 | submit({ rule }: { rule: RuleData }) { 409 | const rules = currentList.value?.rules as RuleData[]; 410 | if (!rules) return; 411 | const index = ruleState.editing; 412 | if (index < 0) return; 413 | rule = toRaw(rule); 414 | rules[index] = rule; 415 | ruleActions.save(); 416 | ruleActions.cancel(); 417 | }, 418 | update() { 419 | const rules = currentList.value?.rules as RuleData[]; 420 | if (!rules) return; 421 | ruleSelection.active = -1; 422 | ruleSelection.count = 0; 423 | ruleSelection.selected.length = rules.length; 424 | ruleState.visible = rules.map((rule, index) => { 425 | const filtered = 426 | !ruleState.filter || rule.url?.includes(ruleState.filter); 427 | if (!filtered) ruleSelection.selected[index] = false; 428 | if (ruleSelection.selected[index]) ruleSelection.count += 1; 429 | return filtered; 430 | }); 431 | }, 432 | toggle(item: RuleData) { 433 | item.enabled = !item.enabled; 434 | ruleActions.save(); 435 | }, 436 | selToggleStatus() { 437 | const current = currentList.value; 438 | if (!current || !ruleSelection.count) return; 439 | const selectedRules = (current.rules as RuleData[]).filter( 440 | (_, index) => ruleSelection.selected[index], 441 | ); 442 | const enabled = selectedRules.every((rule) => rule.enabled) ? false : true; 443 | selectedRules.forEach((rule) => { 444 | rule.enabled = enabled; 445 | }); 446 | ruleActions.save(); 447 | }, 448 | }; 449 | 450 | export const ruleKeys = { 451 | up() { 452 | ruleSelection.active = Math.max(0, ruleSelection.active - 1); 453 | }, 454 | down() { 455 | const current = currentList.value; 456 | if (!current) return; 457 | ruleSelection.active = Math.min( 458 | current.rules.length - 1, 459 | ruleSelection.active + 1, 460 | ); 461 | }, 462 | space() { 463 | ruleActions.selToggle(ruleSelection.active, { 464 | cmdCtrl: true, 465 | shift: false, 466 | }); 467 | }, 468 | }; 469 | 470 | export async function selPaste() { 471 | let data: ListsDumpData | RulesDumpData; 472 | try { 473 | const raw = await navigator.clipboard.readText(); 474 | data = raw && JSON.parse(raw); 475 | if (data.provider !== PROVIDER) throw new Error('Invalid clipboard data'); 476 | } catch { 477 | return; 478 | } 479 | if (data.category === 'lists') { 480 | listActions.selPaste(data as ListsDumpData); 481 | } else if (data.category === 'rules' && listEditable.value) { 482 | ruleActions.selPaste(data as RulesDumpData); 483 | } 484 | } 485 | 486 | export async function selEdit() { 487 | if (store.activeArea === 'lists') { 488 | listActions.edit(); 489 | } else { 490 | ruleActions.selEdit(); 491 | } 492 | } 493 | 494 | export function selectAll() { 495 | if (store.activeArea === 'lists') { 496 | listActions.selAll(); 497 | } else { 498 | ruleActions.selAll(); 499 | } 500 | } 501 | 502 | function fixRequestRule(rule: any) { 503 | if (!rule.type) { 504 | console.warn( 505 | 'The support for the old data structure is deprecated and will be removed soon.', 506 | ); 507 | } 508 | return normalizeRequestRule(rule); 509 | } 510 | 511 | const updateListLater = debounce(ruleActions.update, 200); 512 | 513 | watch(currentList, (list) => { 514 | listSelection.groupIndex = list 515 | ? ['request', 'cookie'].indexOf(list.type) 516 | : -1; 517 | listSelection.itemIndex = list 518 | ? (store.lists[list.type] as ListData[]).findIndex( 519 | (item) => item.id === list.id, 520 | ) 521 | : -1; 522 | if (list && store.hintType === 'listRequired') store.hintType = undefined; 523 | }); 524 | watch(() => ruleState.filter, updateListLater); 525 | watch(currentList, (cur, prev) => { 526 | ruleActions.update(); 527 | if (cur?.id !== prev?.id) ruleActions.cancel(); 528 | }); 529 | watch( 530 | () => ruleState.editing, 531 | (editing) => { 532 | keyboardService.setContext('editRule', editing >= 0); 533 | }, 534 | ); 535 | watch( 536 | () => store.route, 537 | () => { 538 | if (isRoute('lists')) { 539 | const current = currentList.value; 540 | if (!current) return; 541 | const index = 542 | (store.lists[current.type] as ListData[] | undefined)?.indexOf( 543 | current, 544 | ) ?? -1; 545 | if (index >= 0) { 546 | listActions.selToggle(listTypes.indexOf(current.type), index, { 547 | cmdCtrl: false, 548 | shift: false, 549 | }); 550 | } 551 | } else { 552 | listActions.selClear(); 553 | } 554 | }, 555 | ); 556 | watch( 557 | () => store.activeArea, 558 | (activeArea) => { 559 | keyboardService.setContext('listsRealm', activeArea === 'lists'); 560 | }, 561 | ); 562 | watch( 563 | () => store.editList, 564 | (editList) => { 565 | keyboardService.setContext('listModal', !!editList); 566 | }, 567 | ); 568 | 569 | const listsRealm = { 570 | condition: '!inputFocus && listsRealm && !editRule', 571 | }; 572 | const rulesRealm = { 573 | condition: '!inputFocus && !listsRealm && !editRule', 574 | }; 575 | const noEdit = { 576 | condition: '!inputFocus && !editRule', 577 | }; 578 | keyboardService.register(shortcutMap.new, listActions.new, noEdit); 579 | keyboardService.register(shortcutMap.copy, listActions.selCopy, listsRealm); 580 | keyboardService.register(shortcutMap.copy, ruleActions.selCopy, rulesRealm); 581 | keyboardService.register(shortcutMap.cut, listActions.selCut, listsRealm); 582 | keyboardService.register(shortcutMap.cut, ruleActions.selCut, rulesRealm); 583 | keyboardService.register(shortcutMap.paste, selPaste, noEdit); 584 | keyboardService.register( 585 | shortcutMap.duplicate, 586 | ruleActions.selDuplicate, 587 | rulesRealm, 588 | ); 589 | keyboardService.register(shortcutMap.remove, listActions.selRemove, listsRealm); 590 | keyboardService.register(shortcutMap.remove, ruleActions.selRemove, rulesRealm); 591 | keyboardService.register(shortcutMap.add, ruleActions.new, noEdit); 592 | keyboardService.register(shortcutMap.selectAll, selectAll, noEdit); 593 | keyboardService.register('up', ruleKeys.up, rulesRealm); 594 | keyboardService.register('down', ruleKeys.down, rulesRealm); 595 | keyboardService.register('space', ruleKeys.space, rulesRealm); 596 | keyboardService.register(shortcutMap.edit, selEdit, noEdit); 597 | keyboardService.register('esc', ruleActions.selClear, rulesRealm); 598 | keyboardService.register('esc', ruleActions.cancel, { condition: 'editRule' }); 599 | keyboardService.register('esc', listActions.cancel, { 600 | condition: 'listModal', 601 | }); 602 | -------------------------------------------------------------------------------- /src/options/components/app.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 51 | -------------------------------------------------------------------------------- /src/options/components/code-editor.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 88 | -------------------------------------------------------------------------------- /src/options/components/cookie-item.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 62 | -------------------------------------------------------------------------------- /src/options/components/dropdown.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 91 | 92 | 116 | -------------------------------------------------------------------------------- /src/options/components/edit-cookie-item.vue: -------------------------------------------------------------------------------- 1 | 180 | 218 |
219 |