├── tests ├── index.ts └── services │ ├── proxy │ ├── auth.test.ts │ ├── pacSimulator.test.ts │ └── profile2config.test.ts │ └── config │ └── local-config.test.ts ├── public ├── mini-logo.png ├── _locales │ ├── zh_TW │ │ └── messages.json │ ├── zh_CN │ │ └── messages.json │ ├── en │ │ └── messages.json │ └── pt_BR │ │ └── messages.json └── full-logo.svg ├── .vscode └── extensions.json ├── .env ├── src ├── style.css ├── vite-env.d.ts ├── services │ ├── indicator.ts │ ├── config │ │ └── schema │ │ │ ├── index.ts │ │ │ └── definition.ts │ ├── preference.ts │ ├── proxy │ │ ├── pacSimulator.ts │ │ ├── index.ts │ │ ├── auth.ts │ │ ├── scriptHelper.ts │ │ └── profile2config.ts │ ├── profile.ts │ └── stats.ts ├── adapters │ ├── index.ts │ ├── chrome.ts │ ├── web.ts │ ├── base.ts │ └── firefox.ts ├── router.ts ├── App.vue ├── main.ts ├── components │ ├── configs │ │ ├── AutoSwitchPacPreview.vue │ │ ├── ScriptInput.vue │ │ ├── ProfileSelector.vue │ │ ├── ProxyServerInput.vue │ │ └── AutoSwitchInput.vue │ ├── controls │ │ └── ThemeSwitcher.vue │ ├── AutoModeActionBar.vue │ ├── PreferencePage.vue │ └── ProfileConfig.vue ├── background.ts └── pages │ ├── ConfigPage.vue │ └── PopupPage.vue ├── auto-imports.d.ts ├── tsconfig.node.json ├── index.html ├── popup.html ├── .gitignore ├── manifest.json ├── tsconfig.json ├── .github └── workflows │ ├── test.yml │ └── build.yml ├── LICENSE ├── package.json ├── README.md ├── components.d.ts └── vite.config.ts /tests/index.ts: -------------------------------------------------------------------------------- 1 | export {} -------------------------------------------------------------------------------- /public/mini-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytevet/proxyverse/HEAD/public/mini-logo.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "vue.volar", 4 | "vitest.explorer" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VITE_APP_TITLE=Proxyverse 2 | VITE_SENTRY_DSN=https://5679a415cbd7b9dfb6ee538a116b7f0c@o4508663181148160.ingest.us.sentry.io/4508663190781952 -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | min-width: 18em; 3 | min-height: 32em; 4 | } 5 | 6 | #app { 7 | min-height: 100vh; 8 | display: flex; 9 | } 10 | -------------------------------------------------------------------------------- /auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // noinspection JSUnusedGlobalSymbols 5 | // Generated by unplugin-auto-import 6 | // biome-ignore lint: disable 7 | export {} 8 | declare global { 9 | 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_APP_TITLE: string; 5 | readonly VITE_SENTRY_DSN: string; 6 | } 7 | 8 | import { RouteLocationNormalizedLoaded, Router } from "vue-router"; 9 | declare module "@vue/runtime-core" { 10 | interface ComponentCustomProperties { 11 | $router: Router; 12 | $route: RouteLocationNormalizedLoaded; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %VITE_APP_TITLE% 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %VITE_APP_TITLE% 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/services/indicator.ts: -------------------------------------------------------------------------------- 1 | import { Host } from "@/adapters"; 2 | import { ProxyProfile, SystemProfile } from "./profile"; 3 | 4 | export async function setIndicator( 5 | profile: ProxyProfile | undefined, 6 | tabID?: number 7 | ) { 8 | if (profile) { 9 | await Host.setBadge(profile.profileName, profile.color, tabID); 10 | } else { 11 | // clear badge 12 | await Host.setBadge("", SystemProfile.SYSTEM.color, tabID); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.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.zip 13 | dist-ssr 14 | coverage 15 | *.local 16 | stats.html 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | 29 | # Sentry Config File 30 | .env.sentry-build-plugin 31 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Proxyverse", 4 | "version": "", 5 | "version_name": "", 6 | "description": "__MSG_app_desc__", 7 | "default_locale": "en", 8 | "icons": { 9 | "128": "mini-logo.png" 10 | }, 11 | 12 | "action": { 13 | "default_popup": "index.html#/popup" 14 | }, 15 | "background": { 16 | "service_worker": "service-worker.js", 17 | "type": "module" 18 | }, 19 | 20 | "host_permissions": [""], 21 | "permissions": [ 22 | "storage", 23 | "proxy", 24 | "webRequest", 25 | "webRequestAuthProvider", 26 | "activeTab" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | "jsx": "preserve", 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true, 20 | 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "tests/**/*.ts"], 26 | "references": [ 27 | { 28 | "path": "./tsconfig.node.json" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Test 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | branches: 12 | - main 13 | - develop 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: "latest" 26 | cache: "npm" 27 | cache-dependency-path: package-lock.json 28 | 29 | - name: Install dependencies 30 | run: npm ci --ignore-scripts 31 | 32 | - name: Run test cases 33 | run: npm run coverage 34 | -------------------------------------------------------------------------------- /src/adapters/index.ts: -------------------------------------------------------------------------------- 1 | import { BaseAdapter } from "./base"; 2 | import { Chrome } from "./chrome"; 3 | import { Firefox } from "./firefox"; 4 | import { WebBrowser } from "./web"; 5 | 6 | function chooseAdapter(): BaseAdapter { 7 | // Firefox supports browser.* and chrome.* APIs 8 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities 9 | if (globalThis.browser?.proxy) { 10 | return new Firefox(); 11 | } 12 | 13 | if (globalThis.chrome?.proxy) { 14 | return new Chrome(); 15 | } 16 | 17 | return new WebBrowser(); 18 | } 19 | 20 | export const Host = chooseAdapter(); 21 | export type { 22 | ProxyConfig, 23 | WebAuthenticationChallengeDetails, 24 | BlockingResponse, 25 | WebRequestCompletedDetails as WebResponseDetails, 26 | ProxyErrorDetails, 27 | ProxySettingResultDetails, 28 | SimpleProxyServer, 29 | PacScript, 30 | ProxyRules, 31 | } from "./base"; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ByteVet 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 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type RouteRecordRaw, 3 | createRouter, 4 | createWebHashHistory, 5 | } from "vue-router"; 6 | import ProfileConfig from "./components/ProfileConfig.vue"; 7 | import ConfigPage from "./pages/ConfigPage.vue"; 8 | import PopupPage from "./pages/PopupPage.vue"; 9 | import PreferencePage from "./components/PreferencePage.vue"; 10 | 11 | const routes: RouteRecordRaw[] = [ 12 | { 13 | path: "/", 14 | component: ConfigPage, 15 | name: "page.config", 16 | children: [ 17 | { path: "", name: "profile.home", redirect: "profiles/new" }, 18 | { 19 | path: "profiles/new", 20 | name: "profile.create", 21 | component: ProfileConfig, 22 | props: { profileID: undefined }, 23 | }, 24 | { 25 | path: "profiles/:id", 26 | name: "profile.custom", 27 | component: ProfileConfig, 28 | props: (route) => ({ profileID: route.params.id }), 29 | }, 30 | 31 | { path: "preference", name: "preference", component: PreferencePage }, 32 | { path: "/:pathMatch(.*)*", name: "NotFound", redirect: "/" }, 33 | ], 34 | }, 35 | { 36 | path: "/popup", 37 | name: "page.popup", 38 | component: PopupPage, 39 | }, 40 | ]; 41 | 42 | export const router = createRouter({ 43 | history: createWebHashHistory(), 44 | routes, 45 | }); 46 | -------------------------------------------------------------------------------- /src/services/config/schema/index.ts: -------------------------------------------------------------------------------- 1 | import { isLeft } from "fp-ts/Either"; 2 | import * as inner from "@/services/profile"; 3 | import { ConfigFile, getPaths, ProxyProfile } from "./definition"; 4 | 5 | /** 6 | * Convert the raw profiles to JSON string. 7 | * 8 | * @param rawProfiles - The raw profiles 9 | * @returns JSON string 10 | */ 11 | export const config2json = (rawProfiles: inner.ProfilesStorage): string => { 12 | const encoded = ConfigFile.encode({ 13 | version: "2025-01", 14 | profiles: Object.values(rawProfiles) 15 | .map(exportProfile) 16 | .filter((p) => p !== undefined), 17 | }); 18 | 19 | return JSON.stringify(encoded, null, 2); 20 | }; 21 | 22 | const exportProfile = (p: inner.ProxyProfile): ProxyProfile | undefined => { 23 | const decoded = ProxyProfile.decode(p); 24 | if (isLeft(decoded)) { 25 | console.error("Failed to decode profile", p, decoded.left); 26 | return; 27 | } 28 | 29 | return decoded.right; 30 | }; 31 | 32 | export const json2config = (json: string): inner.ProxyProfile[] => { 33 | // might throw error for malformed JSON 34 | let obj: any; 35 | try { 36 | obj = JSON.parse(json); 37 | } catch { 38 | throw Error("Invalid config data"); 39 | } 40 | const decoded = ConfigFile.decode(obj); 41 | if (isLeft(decoded)) { 42 | throw Error(`Could not validate data: ${getPaths(decoded).join(", ")}`); 43 | } 44 | 45 | return decoded.right.profiles.map((p) => ProxyProfile.encode(p)); 46 | }; 47 | -------------------------------------------------------------------------------- /src/services/preference.ts: -------------------------------------------------------------------------------- 1 | import { Host } from "@/adapters"; 2 | 3 | const keyDarkMode = "theme.darkmode"; 4 | 5 | export enum DarkMode { 6 | Default = 0, 7 | Dark = 1, 8 | Light = 2, 9 | } 10 | 11 | function detectDeviceDarkMode() { 12 | if ( 13 | window.matchMedia && 14 | window.matchMedia("(prefers-color-scheme: dark)").matches 15 | ) { 16 | return DarkMode.Dark; 17 | } 18 | 19 | return DarkMode.Light; 20 | } 21 | 22 | /** 23 | * Get the current DarkMode setting. If not set, then returns `DarkMode.Default` 24 | */ 25 | export async function getDarkModeSetting(): Promise { 26 | return Host.getWithDefault(keyDarkMode, DarkMode.Default); 27 | } 28 | 29 | /** 30 | * Return the current real DarkMode, can only be either Light or Dark. 31 | * @returns {DarkMode.Dark | DarkMode.Light} 32 | */ 33 | export async function currentDarkMode(): Promise< 34 | DarkMode.Dark | DarkMode.Light 35 | > { 36 | const ret = await Host.getWithDefault( 37 | keyDarkMode, 38 | DarkMode.Default 39 | ); 40 | if (ret != DarkMode.Default) { 41 | return ret; 42 | } 43 | 44 | return detectDeviceDarkMode(); 45 | } 46 | 47 | export async function changeDarkMode(newMode: DarkMode) { 48 | await Host.set(keyDarkMode, newMode); 49 | 50 | if (newMode == DarkMode.Default) { 51 | newMode = detectDeviceDarkMode(); 52 | } 53 | 54 | switch (newMode) { 55 | case DarkMode.Dark: 56 | document && document.body.setAttribute("arco-theme", "dark"); 57 | break; 58 | case DarkMode.Light: 59 | document && document.body.removeAttribute("arco-theme"); 60 | break; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "proxyverse", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "test": "vitest", 9 | "coverage": "vitest run --coverage", 10 | "build": "vue-tsc && vite build", 11 | "build:firefox": "vue-tsc && vite build -m firefox", 12 | "build:test": "vue-tsc && vite build -m test", 13 | "dist": "rm -f dist.zip && cd ./dist/ && zip -r ../dist.zip ./", 14 | "preview": "vite preview" 15 | }, 16 | "directories": { 17 | "test": "tests" 18 | }, 19 | "dependencies": { 20 | "@highlightjs/vue-plugin": "^2.1.0", 21 | "@sentry/vue": "^10.29.0", 22 | "@typescript/native-preview": "^7.0.0-dev.20251205.1", 23 | "@vueuse/core": "^14.1.0", 24 | "acorn": "^8.15.0", 25 | "escodegen": "^2.1.0", 26 | "espree": "^11.0.0", 27 | "io-ts": "^2.2.22", 28 | "ipaddr.js": "^2.3.0", 29 | "vue": "^3.5.25", 30 | "vue-router": "^4.6.3" 31 | }, 32 | "devDependencies": { 33 | "@arco-design/web-vue": "^2.57.0", 34 | "@sentry/vite-plugin": "^4.6.1", 35 | "@types/chrome": "^0.1.32", 36 | "@types/escodegen": "^0.0.10", 37 | "@types/espree": "^10.1.0", 38 | "@types/firefox-webext-browser": "^143.0.0", 39 | "@types/node": "^24.10.1", 40 | "@vitejs/plugin-vue": "^6.0.2", 41 | "@vitest/coverage-v8": "^4.0.15", 42 | "rollup-plugin-visualizer": "^6.0.5", 43 | "sass-embedded": "^1.93.3", 44 | "typescript": "^5.9.3", 45 | "unplugin-auto-import": "^20.3.0", 46 | "unplugin-vue-components": "^30.0.0", 47 | "vite": "^7.2.6", 48 | "vitest": "^4.0.15", 49 | "vue-tsc": "^3.1.6" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/services/proxy/pacSimulator.ts: -------------------------------------------------------------------------------- 1 | export const UNKNOWN = undefined; 2 | 3 | // copy from Mozilla's implementation 4 | // https://github.com/mozilla/pjs/blob/cbcb610a8cfb035c37fe3103fc2a2eb3b214921a/netwerk/base/src/nsProxyAutoConfig.js#L4 5 | 6 | export function shExpMatch(url: string, pattern: string) { 7 | pattern = pattern.replace(/\./g, "\\."); 8 | pattern = pattern.replace(/\*/g, ".*"); 9 | pattern = pattern.replace(/\?/g, "."); 10 | var newRe = new RegExp("^" + pattern + "$"); 11 | 12 | return newRe.test(url); 13 | } 14 | 15 | export function isInNet(ipaddr: string, pattern: string, maskstr: string) { 16 | var test = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ipaddr); 17 | if (test == null) { 18 | // ipaddr = dnsResolve(ipaddr); 19 | // if (ipaddr == null) return false; 20 | 21 | // Due to Chrome's limitation, we can't resolve the IP address here. 22 | // We just return false. 23 | return UNKNOWN; 24 | } else if ( 25 | +test[1] > 255 || 26 | +test[2] > 255 || 27 | +test[3] > 255 || 28 | +test[4] > 255 29 | ) { 30 | return UNKNOWN; // not an IP address 31 | } 32 | var host = convert_addr(ipaddr); 33 | var pat = convert_addr(pattern); 34 | var mask = convert_addr(maskstr); 35 | return (host & mask) == (pat & mask); 36 | } 37 | 38 | function convert_addr(ipchars: string) { 39 | var bytes = ipchars.split("."); 40 | var result = 41 | ((+bytes[0] & 0xff) << 24) | 42 | ((+bytes[1] & 0xff) << 16) | 43 | ((+bytes[2] & 0xff) << 8) | 44 | (+bytes[3] & 0xff); 45 | return result; 46 | } 47 | 48 | export function isPlainHostName(host: string) { 49 | return host.search("\\.") == -1; 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Proxyverse](./public/full-logo.svg) 2 | 3 | [![Build Extension](https://github.com/bytevet/proxyverse/actions/workflows/build.yml/badge.svg)](https://github.com/bytevet/proxyverse/actions/workflows/build.yml) 4 | [![Test](https://github.com/bytevet/proxyverse/actions/workflows/test.yml/badge.svg)](https://github.com/bytevet/proxyverse/actions/workflows/test.yml) 5 | 6 | Proxyverse is a simple tool to help you switch between different proxy profiles. Proxyverse is an alternative extension to Proxy SwitchyOmega. 7 | 8 | Proxyverse is built with the latest dev stack that complies with the latest standard of Chrome web extension. 9 | 10 | It's still in the early development stage, and more features are still on the way, including but no limited to 11 | 12 | - [x] Basic profile switch support 13 | - [x] Support proxy authentication 14 | - [x] Support auto switch rules 15 | - [x] Support more languages 16 | - [ ] Support customized preference 17 | - [ ] Support Safari 18 | - [x] Support Firefox 19 | 20 | 21 | # How to download? 22 | 23 | - [Chrome](https://chromewebstore.google.com/detail/proxyverse/igknmaflmijecdmjpcgollghmipkfbho) 24 | - [Microsoft Edge](https://microsoftedge.microsoft.com/addons/detail/bpjcpinklkdfabcncofogcaigmmgjjbj) 25 | - [Firefox](https://addons.mozilla.org/en-US/firefox/addon/proxyverse/) 26 | 27 | 28 | # Making a Contribution 29 | 30 | ## Development 31 | 32 | 1. Fork the repository and make changes. 33 | 2. Write unit tests. If applicable, write unit tests for your changes to ensure they don't break existing functionality. Our project uses [vitest](https://vitest.dev/) for unit testing. 34 | 3. Make sure everything works perfectly before you make any pull request. 35 | 36 | 37 | ## Translation & i18n 38 | 39 | Proxyverse is using [transifex](https://explore.transifex.com/bytevet/proxyverse/) for translations. 40 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 60 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import * as Sentry from "@sentry/vue"; 3 | import "./style.css"; 4 | import "@arco-design/web-vue/es/message/style/css.js"; 5 | import "@arco-design/web-vue/es/notification/style/css.js"; 6 | 7 | import App from "./App.vue"; 8 | import { router } from "./router"; 9 | import { Host } from "./adapters"; 10 | 11 | // Highlight.js 12 | import hljs from "highlight.js/lib/core"; 13 | import javascript from "highlight.js/lib/languages/javascript"; 14 | hljs.registerLanguage("javascript", javascript); 15 | 16 | const app = createApp(App); 17 | 18 | Sentry.init({ 19 | app, 20 | dsn: import.meta.env.VITE_SENTRY_DSN, 21 | environment: import.meta.env.MODE, 22 | integrations: [ 23 | Sentry.browserTracingIntegration({ router }), 24 | Sentry.replayIntegration(), 25 | Sentry.browserSessionIntegration(), 26 | Sentry.captureConsoleIntegration({ levels: ["error"] }), 27 | ], 28 | 29 | // Set tracesSampleRate to 1.0 to capture 100% 30 | // of transactions for tracing. 31 | // We recommend adjusting this value in production 32 | // Learn more at 33 | // https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate 34 | tracesSampleRate: 1.0, 35 | 36 | // Set `tracePropagationTargets` to control for which URLs trace propagation should be enabled 37 | // tracePropagationTargets: ["localhost", /^https:\/\/yourserver\.io\/api/], 38 | 39 | // Capture Replay for 10% of all sessions, 40 | // plus for 100% of sessions with an error 41 | // Learn more at 42 | // https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration 43 | replaysSessionSampleRate: 0.1, 44 | replaysOnErrorSampleRate: 1.0, 45 | }); 46 | 47 | // i18n 48 | declare module "@vue/runtime-core" { 49 | interface ComponentCustomProperties { 50 | $t: (key: string, substitutions?: any) => string; 51 | } 52 | } 53 | app.config.globalProperties.$t = Host.getMessage; 54 | 55 | app.use(router).mount("#app"); 56 | -------------------------------------------------------------------------------- /src/components/configs/AutoSwitchPacPreview.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 60 | 61 | 78 | -------------------------------------------------------------------------------- /src/components/controls/ThemeSwitcher.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 85 | -------------------------------------------------------------------------------- /src/components/configs/ScriptInput.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 36 | 37 | 104 | -------------------------------------------------------------------------------- /src/components/AutoModeActionBar.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 74 | 75 | 82 | -------------------------------------------------------------------------------- /src/services/proxy/index.ts: -------------------------------------------------------------------------------- 1 | import { Host } from "@/adapters"; 2 | import { 3 | ProxyProfile, 4 | ProxyAuthInfo, 5 | SystemProfile, 6 | getProfile, 7 | ProfileAutoSwitch, 8 | } from "../profile"; 9 | import { ProxySettingResultDetails } from "@/adapters"; 10 | import { ProfileConverter } from "./profile2config"; 11 | import { ProfileAuthProvider } from "./auth"; 12 | 13 | export type ProxySetting = { 14 | activeProfile?: ProxyProfile; 15 | setting: ProxySettingResultDetails; 16 | }; 17 | 18 | const keyActiveProfile = "active-profile"; 19 | 20 | async function wrapProxySetting(setting: ProxySettingResultDetails) { 21 | const ret: ProxySetting = { 22 | setting, 23 | }; 24 | 25 | if (setting.levelOfControl == "controlled_by_this_extension") { 26 | ret.activeProfile = 27 | (await Host.get(keyActiveProfile)) || undefined; 28 | } 29 | 30 | switch (setting.value?.mode) { 31 | case "system": 32 | ret.activeProfile = SystemProfile.SYSTEM; 33 | break; 34 | case "direct": 35 | ret.activeProfile = SystemProfile.DIRECT; 36 | break; 37 | } 38 | 39 | return ret; 40 | } 41 | 42 | export async function getCurrentProxySetting() { 43 | const setting = await Host.getProxySettings(); 44 | return await wrapProxySetting(setting); 45 | } 46 | 47 | export async function setProxy(val: ProxyProfile) { 48 | switch (val.proxyType) { 49 | case "system": 50 | await Host.clearProxy(); 51 | break; 52 | 53 | default: 54 | const profile = new ProfileConverter(val, getProfile); 55 | await Host.setProxy(await profile.toProxyConfig()); 56 | break; 57 | } 58 | 59 | await Host.set(keyActiveProfile, val); 60 | } 61 | 62 | /** 63 | * Refresh the current proxy setting. This is useful when the proxy setting is changed by user. 64 | * @returns 65 | */ 66 | export async function refreshProxy() { 67 | const current = await getCurrentProxySetting(); 68 | // if it's not controlled by this extension, then do nothing 69 | if (!current.activeProfile) { 70 | return; 71 | } 72 | 73 | const newProfile = await getProfile(current.activeProfile.profileID); 74 | 75 | // if it's preset profiles, then do nothing 76 | if (!newProfile || current.activeProfile.proxyType in ["system", "direct"]) { 77 | return; 78 | } 79 | 80 | const profile = new ProfileConverter(newProfile, getProfile); 81 | await Host.setProxy(await profile.toProxyConfig()); 82 | } 83 | 84 | export async function previewAutoSwitchPac(val: ProfileAutoSwitch) { 85 | const profile = new ProfileConverter(val, getProfile); 86 | return await profile.toPAC(); 87 | } 88 | 89 | export async function getAuthInfos( 90 | host: string, 91 | port: number 92 | ): Promise { 93 | const profile = await Host.get(keyActiveProfile); 94 | if (!profile) { 95 | return []; 96 | } 97 | 98 | const authProvider = new ProfileAuthProvider(profile, getProfile); 99 | return await authProvider.getAuthInfos(host, port); 100 | } 101 | 102 | export async function findProfile(profile: ProxyProfile, url: URL) { 103 | const converter = new ProfileConverter(profile, getProfile); 104 | return await converter.findProfile(url); 105 | } 106 | -------------------------------------------------------------------------------- /src/services/proxy/auth.ts: -------------------------------------------------------------------------------- 1 | import type { ProxyAuthInfo, ProxyProfile } from "../profile"; 2 | import type { ProfileLoader } from "./profile2config"; 3 | 4 | export class ProfileAuthProvider { 5 | private searchedProfiles: Set = new Set(); 6 | 7 | constructor( 8 | private profile: ProxyProfile, 9 | private profileLoader?: ProfileLoader 10 | ) {} 11 | 12 | async getAuthInfos(host: string, port: number): Promise { 13 | this.searchedProfiles.clear(); 14 | return this.getAuthInfosForProfile(host, port, this.profile); 15 | } 16 | 17 | private async getAuthInfosForProfile( 18 | host: string, 19 | port: number, 20 | profile: ProxyProfile 21 | ): Promise { 22 | // avoid infinite loop 23 | if (this.searchedProfiles.has(profile.profileID)) { 24 | return []; 25 | } 26 | this.searchedProfiles.add(profile.profileID); 27 | 28 | switch (profile.proxyType) { 29 | case "proxy": 30 | return this.getAuthInfosForProxyProfile(host, port, profile); 31 | case "auto": 32 | return this.getAuthInfosForAutoProfile(host, port, profile); 33 | 34 | default: 35 | return []; 36 | } 37 | } 38 | 39 | private async getAuthInfosForProxyProfile( 40 | host: string, 41 | port: number, 42 | profile: ProxyProfile & { proxyType: "proxy" } 43 | ): Promise { 44 | const ret: ProxyAuthInfo[] = []; 45 | const auths = [ 46 | profile.proxyRules.default, 47 | profile.proxyRules.ftp, 48 | profile.proxyRules.http, 49 | profile.proxyRules.https, 50 | ]; 51 | 52 | // check if there's any matching host and port 53 | auths.map((item) => { 54 | if (!item) return; 55 | 56 | if ( 57 | item.host == host && 58 | (item.port === undefined || item.port == port) && 59 | item.auth 60 | ) { 61 | ret.push(item.auth); 62 | } 63 | }); 64 | 65 | return ret; 66 | } 67 | 68 | private async getAuthInfosForAutoProfile( 69 | host: string, 70 | port: number, 71 | profile: ProxyProfile & { proxyType: "auto" } 72 | ): Promise { 73 | const ret: ProxyAuthInfo[] = []; 74 | 75 | for (const rule of profile.rules) { 76 | if (rule.type == "disabled") { 77 | continue; 78 | } 79 | 80 | const profile = await this.loadProfile(rule.profileID); 81 | if (!profile) { 82 | continue; 83 | } 84 | 85 | const authInfos = await this.getAuthInfosForProfile(host, port, profile); 86 | authInfos && ret.push(...authInfos); 87 | } 88 | 89 | // don't forget the default profile 90 | const defaultProfile = await this.loadProfile(profile.defaultProfileID); 91 | if (defaultProfile) { 92 | const authInfos = await this.getAuthInfosForProfile( 93 | host, 94 | port, 95 | defaultProfile 96 | ); 97 | authInfos && ret.push(...authInfos); 98 | } 99 | 100 | return ret; 101 | } 102 | 103 | private async loadProfile( 104 | profileID: string 105 | ): Promise { 106 | if (!this.profileLoader) { 107 | return; 108 | } 109 | 110 | return await this.profileLoader(profileID); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/components/PreferencePage.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 115 | -------------------------------------------------------------------------------- /tests/services/proxy/auth.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from "vitest"; 2 | import type { ProxyProfile } from "@/services/profile"; 3 | import { ProfileAuthProvider } from "@/services/proxy/auth"; 4 | 5 | const profiles: Record = { 6 | simpleProxy: { 7 | profileID: "simpleProxy", 8 | color: "", 9 | profileName: "", 10 | proxyType: "proxy", 11 | proxyRules: { 12 | default: { 13 | scheme: "http", 14 | host: "127.0.0.1", 15 | port: 8080, 16 | }, 17 | bypassList: [], 18 | }, 19 | }, 20 | simpleProxyWithAuth: { 21 | profileID: "simpleProxyWithAuth", 22 | color: "", 23 | profileName: "", 24 | proxyType: "proxy", 25 | proxyRules: { 26 | default: { 27 | scheme: "http", 28 | host: "127.0.0.1", 29 | port: 8080, 30 | auth: { 31 | username: "user", 32 | password: "pass", 33 | }, 34 | }, 35 | bypassList: [], 36 | }, 37 | }, 38 | autoProxy: { 39 | profileID: "autoProxy", 40 | color: "", 41 | profileName: "", 42 | proxyType: "auto", 43 | rules: [ 44 | { 45 | type: "domain", 46 | condition: "*.example.com", 47 | profileID: "simpleProxy", 48 | }, 49 | { 50 | type: "domain", 51 | condition: "*.example2.com", 52 | profileID: "simpleProxyWithAuth", 53 | }, 54 | { 55 | type: "domain", 56 | condition: "*.example3.com", 57 | profileID: "autoProxy", // circular reference 58 | }, 59 | ], 60 | defaultProfileID: "direct", 61 | }, 62 | 63 | autoProxyWithDefault: { 64 | profileID: "autoProxyWithDefault", 65 | color: "", 66 | profileName: "", 67 | proxyType: "auto", 68 | rules: [], 69 | defaultProfileID: "simpleProxyWithAuth", 70 | }, 71 | }; 72 | 73 | describe("testing auth provider", () => { 74 | test("test auth provider for simple proxy", async () => { 75 | let authProvider = new ProfileAuthProvider(profiles.simpleProxy); 76 | let authInfos = await authProvider.getAuthInfos("example.com", 80); 77 | expect(authInfos).toEqual([]); 78 | 79 | authProvider = new ProfileAuthProvider(profiles.simpleProxyWithAuth); 80 | authInfos = await authProvider.getAuthInfos("example.com", 80); 81 | expect(authInfos).toEqual([]); 82 | 83 | authInfos = await authProvider.getAuthInfos("127.0.0.1", 8080); 84 | expect(authInfos).toEqual([ 85 | { 86 | username: "user", 87 | password: "pass", 88 | }, 89 | ]); 90 | }); 91 | 92 | test("test auth provider for auto proxy", async () => { 93 | const authProvider = new ProfileAuthProvider( 94 | profiles.autoProxy, 95 | async (id) => { 96 | return profiles[id]; 97 | } 98 | ); 99 | let authInfos = await authProvider.getAuthInfos("example.com", 80); 100 | expect(authInfos).toEqual([]); 101 | 102 | authInfos = await authProvider.getAuthInfos("127.0.0.1", 8080); 103 | expect(authInfos).toEqual([ 104 | { 105 | username: "user", 106 | password: "pass", 107 | }, 108 | ]); 109 | }); 110 | 111 | test("test auth provider for auto proxy with default", async () => { 112 | const authProvider = new ProfileAuthProvider( 113 | profiles.autoProxyWithDefault, 114 | async (id) => { 115 | return profiles[id]; 116 | } 117 | ); 118 | 119 | let authInfos = await authProvider.getAuthInfos("example.com", 80); 120 | expect(authInfos).toEqual([]); 121 | 122 | authInfos = await authProvider.getAuthInfos("127.0.0.1", 8080); 123 | expect(authInfos).toEqual([ 124 | { 125 | username: "user", 126 | password: "pass", 127 | }, 128 | ]); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/services/config/schema/definition.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import { pipe } from "fp-ts/function"; 3 | import { fold } from "fp-ts/Either"; 4 | 5 | export const getPaths = (v: t.Validation): Array => { 6 | let lastActual: any; 7 | return pipe( 8 | v, 9 | fold( 10 | (errors) => 11 | errors.map((error) => 12 | error.context 13 | .map((ctx) => { 14 | if (ctx.actual === lastActual) { 15 | return; 16 | } 17 | 18 | lastActual = ctx.actual; 19 | return ctx.key; 20 | }) 21 | .filter((x) => x !== undefined) 22 | .join(".") 23 | ), 24 | () => ["no errors"] 25 | ) 26 | ); 27 | }; 28 | 29 | // branded types 30 | export type Port = t.Branded; 31 | export const Port = t.brand( 32 | t.number, 33 | (n): n is Port => n >= 0 && n <= 65535, 34 | "Port" 35 | ); 36 | 37 | export type HexedColor = t.Branded< 38 | string, 39 | { readonly HexedColor: unique symbol } 40 | >; 41 | export const HexedColor = t.brand( 42 | t.string, 43 | (s): s is HexedColor => /^#([0-9a-f]{6}|[0-9a-f]{3,4})$/i.test(s), 44 | "HexedColor" 45 | ); 46 | 47 | // start of the config 48 | const PacScript = t.strict({ 49 | data: t.string, 50 | }); 51 | 52 | export const ProxyServer = t.intersection([ 53 | t.strict({ 54 | scheme: t.keyof({ 55 | direct: null, 56 | http: null, 57 | https: null, 58 | socks4: null, 59 | socks5: null, 60 | }), 61 | host: t.string, 62 | }), 63 | t.partial({ 64 | auth: t.strict({ 65 | username: t.string, 66 | password: t.string, 67 | }), 68 | port: Port, 69 | }), 70 | ]); 71 | 72 | export type ProxyServer = t.TypeOf; 73 | 74 | const ProxyConfigMeta = t.strict({ 75 | profileID: t.string, 76 | color: HexedColor, 77 | profileName: t.string, 78 | }); 79 | 80 | // start of the definition of Simple Proxy 81 | const ProxyConfigSimplePACScript = t.strict({ 82 | proxyType: t.literal("pac"), 83 | pacScript: PacScript, 84 | }); 85 | 86 | const ProxyConfigSimpleProxyRule = t.strict({ 87 | proxyType: t.literal("proxy"), 88 | proxyRules: t.intersection([ 89 | t.strict({ 90 | default: ProxyServer, 91 | bypassList: t.array(t.string), 92 | }), 93 | t.partial({ 94 | http: ProxyServer, 95 | https: ProxyServer, 96 | ftp: ProxyServer, 97 | }), 98 | ]), 99 | }); 100 | 101 | const ProxyConfigSimple = t.union([ 102 | ProxyConfigSimplePACScript, 103 | ProxyConfigSimpleProxyRule, 104 | ]); 105 | 106 | // start of the definition of AutoSwitch 107 | const ProxyConfigAutoSwitch = t.strict({ 108 | proxyType: t.literal("auto"), 109 | rules: t.array( 110 | t.strict({ 111 | type: t.keyof({ 112 | domain: null, 113 | cidr: null, 114 | url: null, 115 | disabled: null, 116 | }), 117 | condition: t.string, 118 | profileID: t.string, 119 | }) 120 | ), 121 | defaultProfileID: t.string, 122 | }); 123 | 124 | export const ProfileSimple = t.intersection([ 125 | ProxyConfigMeta, 126 | ProxyConfigSimple, 127 | ]); 128 | export type ProfileSimple = t.TypeOf; 129 | 130 | export const ProfileAuthSwitch = t.intersection([ 131 | ProxyConfigMeta, 132 | ProxyConfigAutoSwitch, 133 | ]); 134 | export type ProfileAuthSwitch = t.TypeOf; 135 | 136 | // no preset profiles when conducting import & export 137 | export const ProxyProfile = t.union([ProfileSimple, ProfileAuthSwitch]); 138 | export type ProxyProfile = t.TypeOf; 139 | 140 | export const ConfigFile = t.strict({ 141 | version: t.literal("2025-01"), 142 | profiles: t.array(ProxyProfile), 143 | }); 144 | -------------------------------------------------------------------------------- /components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // biome-ignore lint: disable 4 | // oxlint-disable 5 | // ------ 6 | // Generated by unplugin-vue-components 7 | // Read more: https://github.com/vuejs/core/pull/3399 8 | 9 | export {} 10 | 11 | /* prettier-ignore */ 12 | declare module 'vue' { 13 | export interface GlobalComponents { 14 | AAlert: typeof import('@arco-design/web-vue')['Alert'] 15 | AButton: typeof import('@arco-design/web-vue')['Button'] 16 | AButtonGroup: typeof import('@arco-design/web-vue')['ButtonGroup'] 17 | ACheckbox: typeof import('@arco-design/web-vue')['Checkbox'] 18 | ACollapse: typeof import('@arco-design/web-vue')['Collapse'] 19 | ACollapseItem: typeof import('@arco-design/web-vue')['CollapseItem'] 20 | AColorPicker: typeof import('@arco-design/web-vue')['ColorPicker'] 21 | ADgroup: typeof import('@arco-design/web-vue')['Dgroup'] 22 | ADivider: typeof import('@arco-design/web-vue')['Divider'] 23 | ADoption: typeof import('@arco-design/web-vue')['Doption'] 24 | ADropdown: typeof import('@arco-design/web-vue')['Dropdown'] 25 | AForm: typeof import('@arco-design/web-vue')['Form'] 26 | AFormItem: typeof import('@arco-design/web-vue')['FormItem'] 27 | AInput: typeof import('@arco-design/web-vue')['Input'] 28 | AInputGroup: typeof import('@arco-design/web-vue')['InputGroup'] 29 | AInputNumber: typeof import('@arco-design/web-vue')['InputNumber'] 30 | AInputPassword: typeof import('@arco-design/web-vue')['InputPassword'] 31 | ALayout: typeof import('@arco-design/web-vue')['Layout'] 32 | ALayoutContent: typeof import('@arco-design/web-vue')['LayoutContent'] 33 | ALayoutFooter: typeof import('@arco-design/web-vue')['LayoutFooter'] 34 | ALayoutHeader: typeof import('@arco-design/web-vue')['LayoutHeader'] 35 | ALayoutSider: typeof import('@arco-design/web-vue')['LayoutSider'] 36 | ALink: typeof import('@arco-design/web-vue')['Link'] 37 | AMenu: typeof import('@arco-design/web-vue')['Menu'] 38 | AMenuItem: typeof import('@arco-design/web-vue')['MenuItem'] 39 | AModal: typeof import('@arco-design/web-vue')['Modal'] 40 | APageHeader: typeof import('@arco-design/web-vue')['PageHeader'] 41 | APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm'] 42 | ARadio: typeof import('@arco-design/web-vue')['Radio'] 43 | ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup'] 44 | ASelect: typeof import('@arco-design/web-vue')['Select'] 45 | ASpace: typeof import('@arco-design/web-vue')['Space'] 46 | ATable: typeof import('@arco-design/web-vue')['Table'] 47 | ATag: typeof import('@arco-design/web-vue')['Tag'] 48 | ATextarea: typeof import('@arco-design/web-vue')['Textarea'] 49 | ATooltip: typeof import('@arco-design/web-vue')['Tooltip'] 50 | ATypographyParagraph: typeof import('@arco-design/web-vue')['TypographyParagraph'] 51 | ATypographyText: typeof import('@arco-design/web-vue')['TypographyText'] 52 | AutoModeActionBar: typeof import('./src/components/AutoModeActionBar.vue')['default'] 53 | AutoSwitchInput: typeof import('./src/components/configs/AutoSwitchInput.vue')['default'] 54 | AutoSwitchPacPreview: typeof import('./src/components/configs/AutoSwitchPacPreview.vue')['default'] 55 | PreferencePage: typeof import('./src/components/PreferencePage.vue')['default'] 56 | ProfileConfig: typeof import('./src/components/ProfileConfig.vue')['default'] 57 | ProfileSelector: typeof import('./src/components/configs/ProfileSelector.vue')['default'] 58 | ProxyServerInput: typeof import('./src/components/configs/ProxyServerInput.vue')['default'] 59 | RouterLink: typeof import('vue-router')['RouterLink'] 60 | RouterView: typeof import('vue-router')['RouterView'] 61 | ScriptInput: typeof import('./src/components/configs/ScriptInput.vue')['default'] 62 | ThemeSwitcher: typeof import('./src/components/controls/ThemeSwitcher.vue')['default'] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/adapters/chrome.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { 4 | BaseAdapter, 5 | BlockingResponse, 6 | BrowserFlavor, 7 | MessageSender, 8 | ProxyConfig, 9 | ProxyErrorDetails, 10 | ProxySettingResultDetails, 11 | Tab, 12 | WebAuthenticationChallengeDetails, 13 | WebRequestCompletedDetails, 14 | WebRequestErrorOccurredDetails, 15 | WebRequestResponseStartedDetails, 16 | } from "./base"; 17 | 18 | export class Chrome extends BaseAdapter { 19 | get flavor() { 20 | return BrowserFlavor.Chrome; 21 | } 22 | 23 | async set(key: string, val: T): Promise { 24 | return await chrome.storage.local.set({ 25 | [key]: val, 26 | }); 27 | } 28 | 29 | async get(key: string): Promise { 30 | const ret = await chrome.storage.local.get(key); 31 | return ret[key] as T | undefined; 32 | } 33 | 34 | async setProxy(cfg: ProxyConfig): Promise { 35 | await chrome.proxy.settings.set({ 36 | value: cfg, 37 | scope: "regular", 38 | }); 39 | } 40 | 41 | async clearProxy(): Promise { 42 | await chrome.proxy.settings.clear({ scope: "regular" }); 43 | } 44 | 45 | async getProxySettings(): Promise { 46 | return (await chrome.proxy.settings.get({})) as any; 47 | } 48 | 49 | onProxyError(callback: (error: ProxyErrorDetails) => void): void { 50 | chrome.proxy.onProxyError.addListener(callback); 51 | } 52 | onProxyChanged(callback: (setting: ProxySettingResultDetails) => void): void { 53 | chrome.proxy.settings.onChange.addListener(callback); 54 | } 55 | 56 | async setBadge(text: string, color: string, tabID?: number): Promise { 57 | await chrome.action.setBadgeText({ 58 | text: text.trimStart().substring(0, 2), 59 | tabId: tabID, 60 | }); 61 | await chrome.action.setBadgeBackgroundColor({ 62 | color: color, 63 | tabId: tabID, 64 | }); 65 | } 66 | 67 | async getActiveTab(): Promise { 68 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); 69 | return tabs[0]; 70 | } 71 | 72 | onTabRemoved(callback: (tabID: number) => void): void { 73 | chrome.tabs.onRemoved.addListener(callback); 74 | } 75 | 76 | onMessage( 77 | callback: ( 78 | message: any, 79 | sender: MessageSender, 80 | sendResponse: (response: any) => void 81 | ) => void 82 | ): void { 83 | chrome.runtime.onMessage.addListener(callback); 84 | } 85 | 86 | sendMessage(message: any): Promise { 87 | return chrome.runtime.sendMessage(message); 88 | } 89 | 90 | onWebRequestAuthRequired( 91 | callback: ( 92 | details: WebAuthenticationChallengeDetails, 93 | asyncCallback?: (response: BlockingResponse) => void 94 | ) => BlockingResponse | undefined 95 | ): void { 96 | chrome.webRequest.onAuthRequired.addListener( 97 | callback, 98 | { urls: [""] }, 99 | ["asyncBlocking"] 100 | ); 101 | } 102 | 103 | onWebRequestResponseStarted( 104 | callback: (details: WebRequestResponseStartedDetails) => void 105 | ): void { 106 | chrome.webRequest.onResponseStarted.addListener(callback, { 107 | urls: [""], 108 | }); 109 | } 110 | 111 | onWebRequestCompleted( 112 | callback: (details: WebRequestCompletedDetails) => void 113 | ): void { 114 | chrome.webRequest.onCompleted.addListener(callback, { 115 | urls: [""], 116 | }); 117 | } 118 | 119 | onWebRequestErrorOccurred( 120 | callback: (details: WebRequestErrorOccurredDetails) => void 121 | ): void { 122 | chrome.webRequest.onErrorOccurred.addListener(callback, { 123 | urls: [""], 124 | }); 125 | } 126 | currentLocale(): string { 127 | return chrome.i18n.getUILanguage(); 128 | } 129 | getMessage(key: string, substitutions?: string | string[]): string { 130 | return chrome.i18n.getMessage(key, substitutions); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/components/configs/ProfileSelector.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 128 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BlockingResponse, 3 | Host, 4 | WebAuthenticationChallengeDetails, 5 | WebResponseDetails, 6 | } from "./adapters"; 7 | import { 8 | WebRequestErrorOccurredDetails, 9 | WebRequestResponseStartedDetails, 10 | } from "./adapters/base"; 11 | import { setIndicator } from "./services/indicator"; 12 | import { 13 | findProfile, 14 | getAuthInfos, 15 | getCurrentProxySetting, 16 | } from "./services/proxy"; 17 | import { WebRequestStatsService } from "./services/stats"; 18 | 19 | // indicator 20 | async function initIndicator() { 21 | const proxySetting = await getCurrentProxySetting(); 22 | await setIndicator(proxySetting.activeProfile); 23 | Host.onProxyChanged(async () => { 24 | const proxySetting = await getCurrentProxySetting(); 25 | await setIndicator(proxySetting.activeProfile); 26 | }); 27 | } 28 | 29 | initIndicator().catch(console.error); 30 | 31 | // proxy auth provider 32 | class ProxyAuthProvider { 33 | // requests[requestID] = request attempts. 0 means the 1st attempt 34 | static requests: Record = {}; 35 | 36 | static onCompleted( 37 | details: WebResponseDetails | WebRequestErrorOccurredDetails 38 | ) { 39 | if (ProxyAuthProvider.requests[details.requestId]) { 40 | delete ProxyAuthProvider.requests[details.requestId]; 41 | } 42 | } 43 | 44 | static onAuthRequired( 45 | details: WebAuthenticationChallengeDetails, 46 | asyncCallback?: (response: BlockingResponse) => void 47 | ): BlockingResponse | undefined { 48 | if (!details.isProxy) { 49 | asyncCallback && asyncCallback({}); 50 | return; 51 | } 52 | 53 | if (ProxyAuthProvider.requests[details.requestId] === undefined) { 54 | // 0 means the 1st attempt 55 | ProxyAuthProvider.requests[details.requestId] = 0; 56 | } else { 57 | ProxyAuthProvider.requests[details.requestId]++; 58 | } 59 | 60 | getAuthInfos(details.challenger.host, details.challenger.port).then( 61 | (authInfos) => { 62 | const auth = authInfos.at( 63 | ProxyAuthProvider.requests[details.requestId] 64 | ); 65 | if (!auth) { 66 | asyncCallback && asyncCallback({ cancel: true }); 67 | return; 68 | } 69 | 70 | asyncCallback && 71 | asyncCallback({ 72 | authCredentials: { 73 | username: auth.username, 74 | password: auth.password, 75 | }, 76 | }); 77 | return; 78 | } 79 | ); 80 | } 81 | } 82 | 83 | Host.onWebRequestAuthRequired(ProxyAuthProvider.onAuthRequired); 84 | Host.onWebRequestCompleted(ProxyAuthProvider.onCompleted); 85 | Host.onWebRequestErrorOccurred(ProxyAuthProvider.onCompleted); 86 | 87 | // web request stats 88 | class StatsProvider { 89 | private static stats: WebRequestStatsService = new WebRequestStatsService(); 90 | 91 | static async onResponseStarted(details: WebRequestResponseStartedDetails) { 92 | if (details.type !== "main_frame") { 93 | // ignore all non-main-frame requests 94 | return; 95 | } 96 | // this.stats.addFailedRequest(details); 97 | // TODO: update indicator 98 | const proxySetting = await getCurrentProxySetting(); 99 | console.log("onResponseStarted", details); 100 | if (details.tabId > 0 && proxySetting.activeProfile) { 101 | const ret = await findProfile( 102 | proxySetting.activeProfile, 103 | new URL(details.url) 104 | ); 105 | 106 | StatsProvider.stats.setCurrentProfile(details.tabId, ret); 107 | 108 | if (ret.profile && ret.isConfident) { 109 | const profile = ret.profile.profile; 110 | setTimeout(() => setIndicator(profile, details.tabId), 50); // Sometimes the indicator doesn't update properly in Chrome, so we need to wait a bit. 111 | return; 112 | } 113 | 114 | await setIndicator(proxySetting.activeProfile, details.tabId); 115 | } 116 | } 117 | 118 | static onRequestError(details: WebRequestErrorOccurredDetails) { 119 | StatsProvider.stats.addFailedRequest(details); 120 | } 121 | 122 | static onTabRemoved(tabID: number) { 123 | StatsProvider.stats.closeTab(tabID); 124 | } 125 | } 126 | Host.onWebRequestResponseStarted(StatsProvider.onResponseStarted); 127 | Host.onWebRequestErrorOccurred(StatsProvider.onRequestError); 128 | Host.onTabRemoved(StatsProvider.onTabRemoved); 129 | 130 | Host.onProxyError(console.warn); 131 | -------------------------------------------------------------------------------- /src/adapters/web.ts: -------------------------------------------------------------------------------- 1 | // this adapter is for local testing purpose 2 | 3 | import { 4 | BaseAdapter, 5 | BlockingResponse, 6 | BrowserFlavor, 7 | MessageSender, 8 | ProxyConfig, 9 | ProxyErrorDetails, 10 | ProxySettingResultDetails, 11 | Tab, 12 | WebAuthenticationChallengeDetails, 13 | WebRequestCompletedDetails, 14 | WebRequestErrorOccurredDetails, 15 | WebRequestResponseStartedDetails, 16 | } from "./base"; 17 | 18 | import i18nData from "@/../public/_locales/en/messages.json"; 19 | 20 | const _i18n: { 21 | [key: string]: { 22 | message: string; 23 | }; 24 | } = i18nData; 25 | 26 | export class WebBrowser extends BaseAdapter { 27 | get flavor() { 28 | return BrowserFlavor.Web; 29 | } 30 | 31 | async set(key: string, val: T): Promise { 32 | localStorage.setItem(key, JSON.stringify(val)); 33 | } 34 | async get(key: string): Promise { 35 | let s: any; 36 | s = localStorage.getItem(key); 37 | return s && JSON.parse(s); 38 | } 39 | 40 | async setProxy(_: ProxyConfig): Promise { 41 | window.localStorage.setItem("proxy", JSON.stringify(_)); 42 | } 43 | async clearProxy(): Promise { 44 | window.localStorage.removeItem("proxy"); 45 | } 46 | async getProxySettings(): Promise { 47 | const proxy = window.localStorage.getItem("proxy"); 48 | if (proxy) { 49 | return { 50 | levelOfControl: "controlled_by_this_extension", 51 | value: { 52 | mode: "pac_script", 53 | pacScript: { 54 | url: proxy, 55 | }, 56 | }, 57 | }; 58 | } 59 | return { 60 | levelOfControl: "controlled_by_this_extension", 61 | value: { 62 | mode: "system", 63 | }, 64 | }; 65 | } 66 | 67 | onProxyError(_: (error: ProxyErrorDetails) => void): void { 68 | throw new Error("Method not implemented."); 69 | } 70 | onProxyChanged(_: (setting: ProxySettingResultDetails) => void): void { 71 | throw new Error("Method not implemented."); 72 | } 73 | 74 | async setBadge(text: string, color: string, tabID?: number): Promise { 75 | return console.log(`Badge: ${text}, ${color}, ${tabID}`); 76 | } 77 | onTabRemoved(_callback: (tabID: number) => void): void { 78 | throw new Error("Method not implemented."); 79 | } 80 | async getActiveTab(): Promise { 81 | return { 82 | id: 1, 83 | index: 0, 84 | url: "https://www.google.com", 85 | active: true, 86 | pinned: false, 87 | highlighted: true, 88 | incognito: false, 89 | audible: false, 90 | autoDiscardable: false, 91 | discarded: false, 92 | favIconUrl: "", 93 | }; 94 | } 95 | onMessage( 96 | _callback: ( 97 | message: any, 98 | sender: MessageSender, 99 | sendResponse: (response: any) => void 100 | ) => void 101 | ): void { 102 | throw new Error("Method not implemented."); 103 | } 104 | sendMessage(_message: any): Promise { 105 | throw new Error("Method not implemented."); 106 | } 107 | 108 | onWebRequestAuthRequired( 109 | _: ( 110 | details: WebAuthenticationChallengeDetails, 111 | callback?: (response: BlockingResponse) => void 112 | ) => void 113 | ): void { 114 | throw new Error("Method not implemented."); 115 | } 116 | onWebRequestResponseStarted( 117 | _: (details: WebRequestResponseStartedDetails) => void 118 | ): void { 119 | throw new Error("Method not implemented."); 120 | } 121 | onWebRequestCompleted( 122 | _: (details: WebRequestCompletedDetails) => void 123 | ): void { 124 | throw new Error("Method not implemented."); 125 | } 126 | onWebRequestErrorOccurred( 127 | _: (details: WebRequestErrorOccurredDetails) => void 128 | ): void { 129 | throw new Error("Method not implemented."); 130 | } 131 | currentLocale(): string { 132 | return "en-US"; 133 | } 134 | getMessage(key: string, substitutions?: string | string[]): string { 135 | let ret = key; 136 | if (_i18n && _i18n[key]) { 137 | ret = _i18n[key]["message"] || key; 138 | } 139 | 140 | if (!substitutions) { 141 | return ret; 142 | } 143 | 144 | if (typeof substitutions === "string") { 145 | substitutions = [substitutions]; 146 | } 147 | 148 | for (let i = 0; i < substitutions.length; i++) { 149 | ret = ret.replace(`$${i + 1}`, substitutions[i]); 150 | } 151 | return ret; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/services/profile.ts: -------------------------------------------------------------------------------- 1 | import { Host, PacScript, SimpleProxyServer } from "@/adapters"; 2 | 3 | export type ProxyAuthInfo = { 4 | username: string; 5 | password: string; 6 | }; 7 | 8 | export type ProxyServer = SimpleProxyServer & { 9 | auth?: ProxyAuthInfo; 10 | }; 11 | 12 | export function sanitizeProxyServer(v: ProxyServer): SimpleProxyServer { 13 | return { 14 | host: v.host, 15 | port: v.port, 16 | }; 17 | } 18 | 19 | export type ProxyConfigMeta = { 20 | profileID: string; 21 | color: string; 22 | profileName: string; 23 | proxyType: "proxy" | "pac" | "system" | "direct" | "auto"; 24 | }; 25 | 26 | // the basic proxy config, with authentication and pac script support 27 | export type ProxyConfigSimple = 28 | | { 29 | proxyType: "proxy"; 30 | proxyRules: { 31 | default: ProxyServer; 32 | http?: ProxyServer; 33 | https?: ProxyServer; 34 | ftp?: ProxyServer; 35 | bypassList: string[]; 36 | }; 37 | pacScript?: PacScript; 38 | } 39 | | { 40 | proxyType: "pac"; 41 | proxyRules?: { 42 | default: ProxyServer; 43 | http?: ProxyServer; 44 | https?: ProxyServer; 45 | ftp?: ProxyServer; 46 | bypassList: string[]; 47 | }; 48 | pacScript: PacScript; 49 | }; 50 | 51 | // advanced proxy config, with auto switch support 52 | export type AutoSwitchType = "domain" | "cidr" | "url" | "disabled"; 53 | export type AutoSwitchRule = { 54 | type: AutoSwitchType; 55 | condition: string; 56 | profileID: string; 57 | }; 58 | 59 | export type ProxyConfigAutoSwitch = { 60 | rules: AutoSwitchRule[]; 61 | defaultProfileID: string; 62 | }; 63 | 64 | export type ProfileSimple = ProxyConfigMeta & ProxyConfigSimple; 65 | 66 | export type ProfilePreset = ProxyConfigMeta & { 67 | proxyType: "system" | "direct"; 68 | }; 69 | 70 | export type ProfileAutoSwitch = ProxyConfigMeta & { 71 | proxyType: "auto"; 72 | } & ProxyConfigAutoSwitch; 73 | 74 | export type ProxyProfile = ProfileSimple | ProfilePreset | ProfileAutoSwitch; 75 | 76 | export const SystemProfile: Record = { 77 | DIRECT: { 78 | profileID: "direct", 79 | color: "#7ad39e", 80 | profileName: "Direct", 81 | proxyType: "direct", 82 | }, 83 | SYSTEM: { 84 | profileID: "system", 85 | color: "#0000", 86 | profileName: "", // should be empty 87 | proxyType: "system", 88 | }, 89 | }; 90 | 91 | const keyProfileStorage = "profiles"; 92 | export type ProfilesStorage = Record; 93 | const onProfileUpdateListeners: ((p: ProfilesStorage) => void)[] = []; 94 | 95 | // list all user defined profiles. System profiles are not included 96 | export async function listProfiles(): Promise { 97 | const s = await Host.get(keyProfileStorage); 98 | return s || {}; 99 | } 100 | 101 | export function onProfileUpdate(callback: (p: ProfilesStorage) => void) { 102 | onProfileUpdateListeners.push(callback); 103 | } 104 | 105 | async function overwriteProfiles(profiles: ProfilesStorage) { 106 | await Host.set(keyProfileStorage, profiles); 107 | onProfileUpdateListeners.map((cb) => cb(profiles)); 108 | } 109 | 110 | /** 111 | * Save a single profile to the storage. 112 | * Please be noticed that this is not promise-safe. If you want to save multiple profiles, use `saveManyProfiles` instead. 113 | * 114 | * @param profile 115 | */ 116 | export async function saveProfile(profile: ProxyProfile) { 117 | const data = await listProfiles(); 118 | data[profile.profileID] = profile; 119 | await overwriteProfiles(data); 120 | } 121 | 122 | export async function saveManyProfiles(profiles: ProxyProfile[]) { 123 | let data = await listProfiles(); 124 | profiles.forEach((p) => { 125 | data[p.profileID] = p; 126 | }); 127 | await overwriteProfiles(data); 128 | } 129 | 130 | export async function getProfile( 131 | profileID: string, 132 | userProfileOnly?: boolean 133 | ): Promise { 134 | if (!userProfileOnly) { 135 | // check if it's a system profile 136 | for (const p of Object.values(SystemProfile)) { 137 | if (p.profileID === profileID) { 138 | return p; 139 | } 140 | } 141 | } 142 | 143 | const data = await listProfiles(); 144 | return data[profileID]; 145 | } 146 | 147 | export async function deleteProfile(profileID: string) { 148 | const data = await listProfiles(); 149 | delete data[profileID]; 150 | await overwriteProfiles(data); 151 | } 152 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import path, { resolve } from "path"; 4 | import { defineConfig } from "vite"; 5 | import vue from "@vitejs/plugin-vue"; 6 | import AutoImport from "unplugin-auto-import/vite"; 7 | import Components from "unplugin-vue-components/vite"; 8 | import { ArcoResolver } from "unplugin-vue-components/resolvers"; 9 | import manifest from "./manifest.json"; 10 | import { visualizer } from "rollup-plugin-visualizer"; 11 | import { sentryVitePlugin } from "@sentry/vite-plugin"; 12 | 13 | const getCRXVersion = () => { 14 | if (process.env.CRX_VER) { 15 | let ver = process.env.CRX_VER; 16 | if (ver.startsWith("v")) { 17 | ver = ver.slice(1); 18 | } 19 | return ver.slice(0, 14); 20 | } 21 | return "0.0.0-dev"; 22 | }; 23 | 24 | // https://vitejs.dev/config/ 25 | export default defineConfig(({ mode }) => { 26 | const isTest = mode === "test"; 27 | const transformer = TRANSFORMER_CONFIG[mode]; 28 | 29 | return { 30 | plugins: [ 31 | vue(), 32 | AutoImport({ 33 | resolvers: [ArcoResolver()], 34 | }), 35 | Components({ 36 | resolvers: [ 37 | ArcoResolver({ 38 | sideEffect: true, 39 | }), 40 | ], 41 | }), 42 | { 43 | name: "manifest", 44 | generateBundle(outputOption, bundle) { 45 | const entry = Object.values(bundle).find( 46 | (chunk) => 47 | chunk.type == "chunk" && 48 | chunk.isEntry && 49 | chunk.name == "background" 50 | ); 51 | manifest.version = getCRXVersion().split("-", 1)[0]; 52 | manifest.version_name = getCRXVersion(); 53 | // avoid cache issues 54 | manifest.background.service_worker = (entry as any).fileName; 55 | 56 | transformer?.manifest(manifest); 57 | 58 | this.emitFile({ 59 | type: "asset", 60 | fileName: "manifest.json", 61 | source: JSON.stringify(manifest, undefined, 2), 62 | }); 63 | }, 64 | }, 65 | ], 66 | css: { 67 | preprocessorOptions: { 68 | scss: { 69 | api: "modern-compiler", 70 | }, 71 | }, 72 | }, 73 | resolve: { 74 | alias: { 75 | "@": path.resolve(__dirname, "./src"), 76 | }, 77 | }, 78 | build: { 79 | sourcemap: true, 80 | rollupOptions: { 81 | input: { 82 | index: resolve(__dirname, "index.html"), 83 | popup: resolve(__dirname, "popup.html"), 84 | background: "src/background.ts", 85 | }, 86 | output: { 87 | // cannot enable this config cuz https://github.com/vitejs/vite/issues/5189 88 | // manualChunks: { 89 | // framework: [ 90 | // "vue", 91 | // "vue-router", 92 | // "@arco-design/web-vue", 93 | // "@vueuse/core", 94 | // ], 95 | // }, 96 | }, 97 | plugins: [ 98 | isTest 99 | ? undefined 100 | : sentryVitePlugin({ 101 | authToken: process.env.SENTRY_AUTH_TOKEN, 102 | org: "bytevet", 103 | project: "proxyverse", 104 | telemetry: false, 105 | sourcemaps: { 106 | filesToDeleteAfterUpload: "**/*.js.map", 107 | }, 108 | bundleSizeOptimizations: { 109 | excludeDebugStatements: true, 110 | }, 111 | release: { 112 | inject: true, 113 | dist: `v${getCRXVersion()}-${mode ? mode : "crx"}`, 114 | }, 115 | }), 116 | isTest 117 | ? undefined 118 | : visualizer({ 119 | filename: "stats.html", 120 | open: true, 121 | }), 122 | ], 123 | }, 124 | }, 125 | }; 126 | }); 127 | 128 | type Transformer = { 129 | manifest(manifest: any): void; 130 | }; 131 | 132 | const TRANSFORMER_CONFIG: Record = { 133 | firefox: { 134 | manifest: (manifest) => { 135 | // To support firefox 136 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/background#browser_support 137 | manifest.background.scripts = [manifest.background.service_worker]; 138 | delete manifest.background.service_worker; 139 | 140 | delete manifest.version_name; 141 | 142 | manifest.browser_specific_settings = { 143 | gecko: { 144 | id: "proxyverse@byte.vet", 145 | strict_min_version: "109.0", 146 | }, 147 | }; 148 | }, 149 | }, 150 | }; 151 | -------------------------------------------------------------------------------- /src/adapters/base.ts: -------------------------------------------------------------------------------- 1 | export type WebAuthenticationChallengeDetails = 2 | | chrome.webRequest.OnAuthRequiredDetails 3 | | browser.webRequest._OnAuthRequiredDetails; 4 | export type BlockingResponse = chrome.webRequest.BlockingResponse; 5 | 6 | export type WebRequestResponseStartedDetails = 7 | | chrome.webRequest.OnResponseStartedDetails 8 | | browser.webRequest._OnResponseStartedDetails; 9 | 10 | export type WebRequestCompletedDetails = 11 | | chrome.webRequest.OnCompletedDetails 12 | | browser.webRequest._OnCompletedDetails; 13 | 14 | export type WebRequestErrorOccurredDetails = 15 | | chrome.webRequest.OnErrorOccurredDetails 16 | | browser.webRequest._OnErrorOccurredDetails; 17 | 18 | export type MessageSender = 19 | | chrome.runtime.MessageSender 20 | | browser.runtime.MessageSender; 21 | 22 | export type Tab = chrome.tabs.Tab | browser.tabs.Tab; 23 | 24 | export type ProxyConfig = chrome.proxy.ProxyConfig; 25 | 26 | export type ProxyErrorDetails = chrome.proxy.ErrorDetails | Error; 27 | export type ProxySettingResultDetails = 28 | | chrome.types.ChromeSettingGetResult 29 | | { 30 | /** 31 | * One of 32 | * • not_controllable: cannot be controlled by any extension 33 | * • controlled_by_other_extensions: controlled by extensions with higher precedence 34 | * • controllable_by_this_extension: can be controlled by this extension 35 | * • controlled_by_this_extension: controlled by this extension 36 | */ 37 | levelOfControl: 38 | | "not_controllable" 39 | | "controlled_by_other_extensions" 40 | | "controllable_by_this_extension" 41 | | "controlled_by_this_extension"; 42 | /** The value of the setting. */ 43 | value: ProxyConfig; 44 | /** 45 | * Optional. 46 | * Whether the effective value is specific to the incognito session. 47 | * This property will only be present if the incognito property in the details parameter of get() was true. 48 | */ 49 | incognitoSpecific?: boolean | undefined; 50 | }; 51 | 52 | export type SimpleProxyServer = { 53 | host: string; 54 | port?: number; 55 | scheme?: "direct" | "http" | "https" | "socks4" | "socks5"; 56 | }; 57 | export type PacScript = chrome.proxy.PacScript; 58 | export type ProxyRules = chrome.proxy.ProxyRules; 59 | 60 | export enum BrowserFlavor { 61 | Unknown = "unknown", 62 | Web = "web", // now only for local dev purpose 63 | Chrome = "chrome", 64 | } 65 | 66 | export abstract class BaseAdapter { 67 | get flavor(): BrowserFlavor { 68 | return BrowserFlavor.Unknown; 69 | } 70 | 71 | // local storage 72 | abstract set(key: string, val: T): Promise; 73 | abstract get(key: string): Promise; 74 | async getWithDefault(key: string, defaultVal: T): Promise { 75 | const ret = await this.get(key); 76 | if (ret === undefined) { 77 | return defaultVal; 78 | } 79 | 80 | return ret; 81 | } 82 | 83 | // proxy 84 | abstract setProxy(cfg: ProxyConfig): Promise; 85 | abstract clearProxy(): Promise; 86 | abstract onProxyError(callback: (error: ProxyErrorDetails) => void): void; 87 | abstract onProxyChanged( 88 | callback: (setting: ProxySettingResultDetails) => void 89 | ): void; 90 | abstract getProxySettings(): Promise; 91 | 92 | // indicator 93 | abstract setBadge(text: string, color: string, tabID?: number): Promise; 94 | 95 | // webRequest 96 | abstract onWebRequestAuthRequired( 97 | callback: ( 98 | details: WebAuthenticationChallengeDetails, 99 | asyncCallback?: (response: BlockingResponse) => void 100 | ) => BlockingResponse | undefined 101 | ): void; 102 | abstract onWebRequestResponseStarted( 103 | callback: (details: WebRequestResponseStartedDetails) => void 104 | ): void; 105 | abstract onWebRequestCompleted( 106 | callback: (details: WebRequestCompletedDetails) => void 107 | ): void; 108 | abstract onWebRequestErrorOccurred( 109 | callback: (details: WebRequestErrorOccurredDetails) => void 110 | ): void; 111 | 112 | // tabs 113 | abstract getActiveTab(): Promise; 114 | abstract onTabRemoved(callback: (tabID: number) => void): void; 115 | 116 | // messages 117 | abstract onMessage( 118 | callback: ( 119 | message: any, 120 | sender: MessageSender, 121 | sendResponse: (response: any) => void 122 | ) => void 123 | ): void; 124 | abstract sendMessage(message: any): Promise; 125 | 126 | // i18n 127 | abstract currentLocale(): string; 128 | abstract getMessage(key: string, substitutions?: string | string[]): string; 129 | 130 | // compatible issues, return an error message in HTML format 131 | async error(): Promise { 132 | return; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/adapters/firefox.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { 4 | BaseAdapter, 5 | BlockingResponse, 6 | MessageSender, 7 | ProxyConfig, 8 | ProxyErrorDetails, 9 | ProxySettingResultDetails, 10 | Tab, 11 | WebAuthenticationChallengeDetails, 12 | WebRequestCompletedDetails, 13 | WebRequestErrorOccurredDetails, 14 | WebRequestResponseStartedDetails, 15 | } from "./base"; 16 | 17 | export class Firefox extends BaseAdapter { 18 | async set(key: string, val: T): Promise { 19 | return await browser.storage.local.set({ 20 | [key]: val, 21 | }); 22 | } 23 | 24 | async get(key: string): Promise { 25 | const ret = await browser.storage.local.get(key); 26 | return ret[key]; 27 | } 28 | 29 | async setProxy(cfg: ProxyConfig): Promise { 30 | const proxyCfg: browser.proxy.ProxyConfig = {}; 31 | 32 | switch (cfg.mode) { 33 | case "direct": 34 | proxyCfg.proxyType = "none"; 35 | break; 36 | case "auto_detect": 37 | proxyCfg.proxyType = "autoDetect"; 38 | break; 39 | case "pac_script": 40 | proxyCfg.proxyType = "autoConfig"; 41 | proxyCfg.autoConfigUrl = 42 | cfg.pacScript?.url || 43 | "data:text/javascript," + 44 | encodeURIComponent(cfg.pacScript?.data || ""); 45 | break; 46 | case "system": 47 | proxyCfg.proxyType = "system"; 48 | break; 49 | } 50 | 51 | await browser.proxy.settings.set({ 52 | value: proxyCfg, 53 | scope: "regular", 54 | }); 55 | } 56 | 57 | async clearProxy(): Promise { 58 | await browser.proxy.settings.clear({ scope: "regular" }); 59 | } 60 | 61 | async getProxySettings(): Promise { 62 | return (await browser.proxy.settings.get({})) as any; 63 | } 64 | 65 | onProxyError(callback: (error: ProxyErrorDetails) => void): void { 66 | browser.proxy.onError.addListener(callback); 67 | } 68 | onProxyChanged(callback: (setting: ProxySettingResultDetails) => void): void { 69 | browser.proxy.settings.onChange.addListener(callback); 70 | } 71 | 72 | async setBadge(text: string, color: string, tabID?: number): Promise { 73 | await browser.action.setBadgeText({ 74 | text: text.trimStart().substring(0, 2), 75 | tabId: tabID, 76 | }); 77 | await browser.action.setBadgeBackgroundColor({ 78 | color: color, 79 | tabId: tabID, 80 | }); 81 | } 82 | 83 | async getActiveTab(): Promise { 84 | const tabs = await browser.tabs.query({ 85 | active: true, 86 | currentWindow: true, 87 | }); 88 | return tabs[0]; 89 | } 90 | 91 | onTabRemoved(callback: (tabID: number) => void): void { 92 | browser.tabs.onRemoved.addListener(callback); 93 | } 94 | 95 | onMessage( 96 | callback: ( 97 | message: any, 98 | sender: MessageSender, 99 | sendResponse: (response: any) => void 100 | ) => void 101 | ): void { 102 | browser.runtime.onMessage.addListener(callback); 103 | } 104 | 105 | sendMessage(message: any): Promise { 106 | return browser.runtime.sendMessage(message); 107 | } 108 | 109 | onWebRequestAuthRequired( 110 | callback: ( 111 | details: WebAuthenticationChallengeDetails, 112 | asyncCallback?: (response: BlockingResponse) => void 113 | ) => BlockingResponse | undefined 114 | ): void { 115 | browser.webRequest.onAuthRequired.addListener( 116 | callback as any, 117 | { urls: [""] }, 118 | ["asyncBlocking"] 119 | ); 120 | } 121 | 122 | onWebRequestResponseStarted( 123 | callback: (details: WebRequestResponseStartedDetails) => void 124 | ): void { 125 | browser.webRequest.onResponseStarted.addListener(callback, { 126 | urls: [""], 127 | }); 128 | } 129 | 130 | onWebRequestCompleted( 131 | callback: (details: WebRequestCompletedDetails) => void 132 | ): void { 133 | browser.webRequest.onCompleted.addListener(callback, { 134 | urls: [""], 135 | }); 136 | } 137 | 138 | onWebRequestErrorOccurred( 139 | callback: (details: WebRequestErrorOccurredDetails) => void 140 | ): void { 141 | browser.webRequest.onErrorOccurred.addListener(callback, { 142 | urls: [""], 143 | }); 144 | } 145 | currentLocale(): string { 146 | return browser.i18n.getUILanguage(); 147 | } 148 | getMessage(key: string, substitutions?: string | string[]): string { 149 | return browser.i18n.getMessage(key, substitutions); 150 | } 151 | 152 | // compatible issues 153 | async error(): Promise { 154 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/proxy/settings 155 | if (!(await browser.extension.isAllowedIncognitoAccess())) { 156 | return browser.i18n.getMessage("firefox_incognito_access_error_html"); 157 | } 158 | 159 | return; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Build Extension 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | tags: 11 | - "v*.*.*" 12 | pull_request: 13 | branches: 14 | - main 15 | - develop 16 | 17 | env: 18 | CRX_VER: v0.0.0-${{ github.sha }} 19 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 20 | 21 | jobs: 22 | build: 23 | name: Build 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Setup Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: "latest" 33 | cache: "npm" 34 | cache-dependency-path: package-lock.json 35 | 36 | - name: Install dependencies 37 | run: npm ci --ignore-scripts 38 | 39 | - name: Set version 40 | if: ${{ github.ref_type == 'tag' }} 41 | run: echo "CRX_VER=${{ github.ref_name }}" >> $GITHUB_ENV 42 | 43 | - name: Preparing 44 | run: | 45 | echo "Current version ${{ env.CRX_VER }}" 46 | mkdir "${{ github.workspace }}/release/" 47 | git archive --format tar.gz HEAD -o "${{ github.workspace }}/release/source.tar.gz" 48 | 49 | - name: Build Chrome Extension 50 | env: 51 | SENTRY_RELEASE: ${{ env.CRX_VER }} 52 | run: | 53 | npm run build 54 | cd "${{ github.workspace }}/dist/" && zip -r "${{ github.workspace }}/release/crx.zip" ./ 55 | 56 | - name: Build Firefox Add-ons 57 | env: 58 | SENTRY_RELEASE: ${{ env.CRX_VER }} 59 | run: | 60 | npm run build:firefox 61 | cd "${{ github.workspace }}/dist/" && zip -r "${{ github.workspace }}/release/firefox.zip" ./ 62 | 63 | - name: Upload artifact 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: Proxyverse 67 | path: ${{ github.workspace }}/release/* 68 | 69 | release-github: 70 | name: Publish on Github Release 71 | if: ${{ github.ref_type == 'tag' }} 72 | runs-on: ubuntu-latest 73 | needs: 74 | - build 75 | 76 | steps: 77 | - name: Download artifact 78 | uses: actions/download-artifact@v4 79 | with: 80 | name: Proxyverse 81 | path: ${{ github.workspace }}/release/ 82 | 83 | - name: Upload to release page 84 | uses: softprops/action-gh-release@v2 85 | with: 86 | files: ${{ github.workspace }}/release/* 87 | 88 | release-chrome: 89 | name: Publish on Chrome 90 | if: ${{ github.ref_type == 'tag' }} 91 | runs-on: ubuntu-latest 92 | needs: 93 | - build 94 | 95 | steps: 96 | - name: Download artifact 97 | uses: actions/download-artifact@v4 98 | with: 99 | name: Proxyverse 100 | path: ${{ github.workspace }}/release/ 101 | 102 | - name: Upload to Chrome Web Store 103 | uses: wdzeng/chrome-extension@v1 104 | with: 105 | extension-id: igknmaflmijecdmjpcgollghmipkfbho 106 | zip-path: ${{ github.workspace }}/release/crx.zip 107 | client-id: ${{ secrets.CHROME_CLIENT_ID }} 108 | client-secret: ${{ secrets.CHROME_CLIENT_SECRET }} 109 | refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }} 110 | 111 | release-edge: 112 | name: Publish on Edge 113 | if: ${{ github.ref_type == 'tag' }} 114 | runs-on: ubuntu-latest 115 | needs: 116 | - build 117 | 118 | steps: 119 | - name: Download artifact 120 | uses: actions/download-artifact@v4 121 | with: 122 | name: Proxyverse 123 | path: ${{ github.workspace }}/release/ 124 | 125 | - name: Upload to MS Edge 126 | uses: wdzeng/edge-addon@v2 127 | with: 128 | product-id: 6fa97660-6c21-41e4-87e6-06a88509753f 129 | zip-path: ${{ github.workspace }}/release/crx.zip 130 | client-id: ${{ secrets.EDGE_CLIENT_ID }} 131 | api-key: ${{ secrets.EDGE_API_KEY }} 132 | 133 | release-firefox: 134 | name: Publish on Firefox 135 | if: ${{ github.ref_type == 'tag' }} 136 | runs-on: ubuntu-latest 137 | needs: 138 | - build 139 | 140 | steps: 141 | - name: Download artifact 142 | uses: actions/download-artifact@v4 143 | with: 144 | name: Proxyverse 145 | path: ${{ github.workspace }}/release/ 146 | 147 | - name: Upload to Firefox 148 | uses: wdzeng/firefox-addon@v1 149 | with: 150 | addon-guid: proxyverse@byte.vet 151 | xpi-path: ${{ github.workspace }}/release/firefox.zip 152 | source-file-path: ${{ github.workspace }}/release/source.tar.gz 153 | license: MIT 154 | self-hosted: false 155 | jwt-issuer: ${{ secrets.FIREFOX_JWT_ISSUER }} 156 | jwt-secret: ${{ secrets.FIREFOX_JWT_SECRET }} 157 | -------------------------------------------------------------------------------- /src/services/stats.ts: -------------------------------------------------------------------------------- 1 | import { MessageSender, WebRequestErrorOccurredDetails } from "@/adapters/base"; 2 | import { ProfileResult } from "./proxy/profile2config"; 3 | import { Host } from "@/adapters"; 4 | import { ProxyProfile } from "./profile"; 5 | 6 | /** 7 | * Simple LRU cache implementation 8 | */ 9 | class LRUCache { 10 | private cache: Map; 11 | private maxSize: number; 12 | 13 | constructor(maxSize: number = 100) { 14 | this.cache = new Map(); 15 | this.maxSize = maxSize; 16 | } 17 | 18 | get(key: K): V | undefined { 19 | const value = this.cache.get(key); 20 | if (value !== undefined) { 21 | // Move to end (most recently used) 22 | this.cache.delete(key); 23 | this.cache.set(key, value); 24 | } 25 | return value; 26 | } 27 | 28 | set(key: K, value: V): void { 29 | if (this.cache.has(key)) { 30 | // Update existing: remove and re-add to end 31 | this.cache.delete(key); 32 | } else if (this.cache.size >= this.maxSize) { 33 | // Remove least recently used (first item) 34 | const firstKey = this.cache.keys().next().value; 35 | if (firstKey !== undefined) { 36 | this.cache.delete(firstKey); 37 | } 38 | } 39 | this.cache.set(key, value); 40 | } 41 | 42 | delete(key: K): boolean { 43 | return this.cache.delete(key); 44 | } 45 | 46 | has(key: K): boolean { 47 | return this.cache.has(key); 48 | } 49 | 50 | clear(): void { 51 | this.cache.clear(); 52 | } 53 | 54 | get size(): number { 55 | return this.cache.size; 56 | } 57 | } 58 | 59 | type Message = { 60 | type: "stats:get"; 61 | tabID: number; 62 | }; 63 | 64 | export type TabStatsResult = { 65 | failedRequests: WebRequestErrorOccurredDetails[]; 66 | currentProfile: 67 | | { profile: ProxyProfile | undefined; isConfident: boolean } 68 | | undefined; 69 | tabID: number; 70 | }; 71 | 72 | export async function queryStats(tabID: number): Promise { 73 | try { 74 | const ret = await Host.sendMessage({ 75 | type: "stats:get", 76 | tabID, 77 | }); 78 | return ret as TabStatsResult; 79 | } catch (error) { 80 | console.error("Failed to query stats", error); 81 | return { 82 | failedRequests: [], 83 | currentProfile: undefined, 84 | tabID, 85 | }; 86 | } 87 | } 88 | 89 | /** 90 | * Service to collect and manage web request stats 91 | * It can only be initalized in the background script 92 | */ 93 | export class WebRequestStatsService { 94 | private stats: LRUCache; 95 | private maxTabs: number = 100; // Maximum number of tabs to keep in memory 96 | 97 | constructor() { 98 | this.stats = new LRUCache(this.maxTabs); 99 | this.listen(); 100 | } 101 | 102 | private listen() { 103 | Host.onMessage( 104 | ( 105 | message: Message, 106 | sender: MessageSender, 107 | sendResponse: (response: any) => void 108 | ) => { 109 | console.debug("stats:get", message, sender, sendResponse); 110 | if (message.type === "stats:get") { 111 | const tabStats = this.getTabStats(message.tabID); 112 | sendResponse(tabStats.toJSON()); 113 | } 114 | } 115 | ); 116 | } 117 | 118 | addFailedRequest(details: WebRequestErrorOccurredDetails) { 119 | const tabID = details.tabId; 120 | const tabStats = this.getTabStats(tabID); 121 | tabStats.addFailedRequest(details); 122 | } 123 | 124 | setCurrentProfile(tabID: number, profile: ProfileResult) { 125 | const tabStats = this.getTabStats(tabID); 126 | tabStats.setCurrentProfile(profile); 127 | } 128 | 129 | getTabStats(tabID: number) { 130 | let tabStats = this.stats.get(tabID); 131 | if (!tabStats) { 132 | tabStats = new TabStats(tabID); 133 | this.stats.set(tabID, tabStats); 134 | } 135 | return tabStats; 136 | } 137 | 138 | closeTab(tabID: number) { 139 | this.stats.delete(tabID); 140 | } 141 | } 142 | 143 | class TabStats { 144 | private failedRequests: WebRequestErrorOccurredDetails[] = []; 145 | private maxRequests: number = 500; // max number of requests to keep in memory 146 | private _currentProfile: 147 | | { profile: ProxyProfile | undefined; isConfident: boolean } 148 | | undefined; 149 | 150 | constructor(public readonly tabID: number) {} 151 | 152 | toJSON(): TabStatsResult { 153 | return { 154 | failedRequests: this.failedRequests, 155 | currentProfile: this._currentProfile, 156 | tabID: this.tabID, 157 | }; 158 | } 159 | 160 | get failedRequestsCount() { 161 | return this.failedRequests.length; 162 | } 163 | 164 | addFailedRequest(details: WebRequestErrorOccurredDetails) { 165 | this.failedRequests.push(details); 166 | if (this.failedRequests.length > this.maxRequests) { 167 | this.failedRequests.shift(); 168 | } 169 | } 170 | 171 | setCurrentProfile({ profile, isConfident }: ProfileResult) { 172 | this._currentProfile = { 173 | profile: profile?.profile, 174 | isConfident: isConfident, 175 | }; 176 | } 177 | 178 | get currentProfile() { 179 | return this._currentProfile; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/pages/ConfigPage.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 105 | 106 | 179 | -------------------------------------------------------------------------------- /src/components/configs/ProxyServerInput.vue: -------------------------------------------------------------------------------- 1 | 106 | 107 | 205 | -------------------------------------------------------------------------------- /public/_locales/zh_TW/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_desc": { 3 | "message": "一個協助您管理和切換代理設定檔的工具" 4 | }, 5 | 6 | "nav_preference": { 7 | "message": "偏好設定" 8 | }, 9 | "nav_config": { 10 | "message": "設定" 11 | }, 12 | "nav_custom_profiles": { 13 | "message": "自訂檔案" 14 | }, 15 | "nav_system_profiles": { 16 | "message": "預設" 17 | }, 18 | 19 | "theme_light_mode": { 20 | "message": "日間模式" 21 | }, 22 | "theme_dark_mode": { 23 | "message": "黑暗模式" 24 | }, 25 | "theme_auto_mode": { 26 | "message": "遵循系統" 27 | }, 28 | 29 | "mode_auto_switch": { 30 | "message": "自動切換" 31 | }, 32 | "mode_auto_switch_abbr": { 33 | "message": "自動" 34 | }, 35 | 36 | "mode_auto_switch_detection_info": { 37 | "message": "當前頁面使用$1 " 38 | }, 39 | "mode_auto_switch_detection_tips": { 40 | "message": "由於瀏覽器的限制,偵測並不總是準確的。" 41 | }, 42 | 43 | "mode_direct": { 44 | "message": "直通模式" 45 | }, 46 | "mode_system": { 47 | "message": "使用系統代理" 48 | }, 49 | "mode_profile_create": { 50 | "message": "建立新的檔案" 51 | }, 52 | 53 | "config_proxy_type": { 54 | "message": "代理類型" 55 | }, 56 | "config_proxy_type_proxy": { 57 | "message": "代理" 58 | }, 59 | "config_proxy_type_pac": { 60 | "message": "PAC 腳本" 61 | }, 62 | "config_proxy_type_auto": { 63 | "message": "自動切換" 64 | }, 65 | "config_proxy_type_default": { 66 | "message": "與預設值相同" 67 | }, 68 | "config_section_proxy_server": { 69 | "message": "代理伺服器" 70 | }, 71 | "config_section_proxy_server_default": { 72 | "message": "預設伺服器" 73 | }, 74 | "config_section_proxy_server_http": { 75 | "message": "HTTP" 76 | }, 77 | "config_section_proxy_server_https": { 78 | "message": "HTTPS" 79 | }, 80 | "config_section_proxy_server_ftp": { 81 | "message": "FTP" 82 | }, 83 | "config_section_proxy_auth_tips": { 84 | "message": "如果您的代理需要驗證,請設定使用者名稱和密碼" 85 | }, 86 | "config_section_proxy_auth_unsupported": { 87 | "message": "當前的代理類型不支援認證" 88 | }, 89 | "config_section_proxy_auth_title": { 90 | "message": "代理認證" 91 | }, 92 | "config_section_proxy_auth_username": { 93 | "message": "使用者名稱" 94 | }, 95 | "config_section_proxy_auth_password": { 96 | "message": "密碼" 97 | }, 98 | 99 | "config_section_bypass_list": { 100 | "message": "旁路清單" 101 | }, 102 | "config_section_advance": { 103 | "message": "進階設定" 104 | }, 105 | "config_reference_bypass_list": { 106 | "message": "進一步瞭解旁路清單" 107 | }, 108 | 109 | "config_section_auto_switch_rules": { 110 | "message": "自動切換規則" 111 | }, 112 | "config_section_auto_switch_type": { 113 | "message": "條件類型" 114 | }, 115 | "config_section_auto_switch_type_domain": { 116 | "message": "網域名稱" 117 | }, 118 | "config_section_auto_switch_type_url": { 119 | "message": "URL" 120 | }, 121 | "config_section_auto_switch_type_url_malformed": { 122 | "message": "無效 URL" 123 | }, 124 | "config_section_auto_switch_type_url_malformed_chrome": { 125 | "message": "基於资安理由,Chrome 不支援 HTTPS URL 中的路徑匹配。進一步了解 https://issues.chromium.org/40083832" 126 | }, 127 | "config_section_auto_switch_type_cidr": { 128 | "message": "CIDR" 129 | }, 130 | "config_section_auto_switch_type_cidr_malformed": { 131 | "message": "無效 CIDR。請使用`192.168.1.1/24`或`2001:db8::/32`等格式。" 132 | }, 133 | "config_section_auto_switch_type_disabled": { 134 | "message": "暫時略過" 135 | }, 136 | "config_section_auto_switch_condition": { 137 | "message": "條件定義" 138 | }, 139 | "config_section_auto_switch_profile": { 140 | "message": "要路由到的設定檔" 141 | }, 142 | "config_section_auto_switch_actions": { 143 | "message": "動作" 144 | }, 145 | "config_section_auto_switch_add_rule": { 146 | "message": "新增規則" 147 | }, 148 | "config_section_auto_switch_delete_rule": { 149 | "message": "刪除當前規則" 150 | }, 151 | "config_section_auto_switch_duplicate_rule": { 152 | "message": "重複當前規則" 153 | }, 154 | "config_section_auto_switch_default_profile": { 155 | "message": "預設檔案" 156 | }, 157 | "config_section_auto_switch_pac_preview": { 158 | "message": "預覽 PAC 腳本" 159 | }, 160 | 161 | "config_action_edit": { 162 | "message": "編輯" 163 | }, 164 | "config_action_delete": { 165 | "message": "刪除檔案" 166 | }, 167 | "config_action_delete_double_confirm": { 168 | "message": "您確定要刪除目前的設定檔嗎?" 169 | }, 170 | "config_action_save": { 171 | "message": "儲存" 172 | }, 173 | "config_action_cancel": { 174 | "message": "捨棄變更" 175 | }, 176 | "config_action_clear": { 177 | "message": "清除" 178 | }, 179 | 180 | "config_feedback_saved": { 181 | "message": "檔案已儲存" 182 | }, 183 | "config_feedback_copied": { 184 | "message": "已複製" 185 | }, 186 | "config_feedback_deleted": { 187 | "message": "檔案已刪除" 188 | }, 189 | "config_feedback_unknown_profile": { 190 | "message": "不明檔案" 191 | }, 192 | "config_feedback_error_occurred": { 193 | "message": "發生錯誤: $1" 194 | }, 195 | 196 | "preferences_section_import_export": { 197 | "message": "匯入與匯出設定檔" 198 | }, 199 | 200 | "preferences_section_import_export_desc": { 201 | "message": "將您的代理伺服器設定檔匯出至檔案,以便備份或分享,並可匯入以還原。" 202 | }, 203 | 204 | "preferences_action_profile_export": { 205 | "message": "匯出設定檔" 206 | }, 207 | 208 | "preferences_action_profile_import": { 209 | "message": "匯入設定檔" 210 | }, 211 | 212 | "preferences_feedback_no_profile_to_be_exported": { 213 | "message": "沒有可以匯出的設定檔" 214 | }, 215 | 216 | "preferences_tips_profile_overwrite": { 217 | "message": "某些設定檔可能會在匯入後被覆寫。您確定要繼續嗎?" 218 | }, 219 | 220 | "preferences_feedback_n_profiles_being_imported": { 221 | "message": "已匯入 $1 個設定檔" 222 | }, 223 | 224 | "form_is_required": { 225 | "message": "$1 是必需的" 226 | }, 227 | 228 | "feedback_error": { 229 | "message": "出現問題" 230 | }, 231 | 232 | "firefox_incognito_access_error_html": { 233 | "message": "
由於 Firefox 的限制,Proxyverse 無法在私人瀏覽視窗中執行,因此無法正常運作。
\n
請在私人視窗中啟用擴充套件。
\n
" 234 | }, 235 | 236 | "_": { 237 | "message": "" 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /public/_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_desc": { 3 | "message": "一个帮助您管理和切换代理配置文件的工具" 4 | }, 5 | 6 | "nav_preference": { 7 | "message": "偏好设置" 8 | }, 9 | "nav_config": { 10 | "message": "配置" 11 | }, 12 | "nav_custom_profiles": { 13 | "message": "自定义配置文件" 14 | }, 15 | "nav_system_profiles": { 16 | "message": "预设" 17 | }, 18 | 19 | "theme_light_mode": { 20 | "message": "浅色模式" 21 | }, 22 | "theme_dark_mode": { 23 | "message": "暗黑模式" 24 | }, 25 | "theme_auto_mode": { 26 | "message": "跟随系统" 27 | }, 28 | 29 | "mode_auto_switch": { 30 | "message": "自动切换" 31 | }, 32 | "mode_auto_switch_abbr": { 33 | "message": "自动" 34 | }, 35 | 36 | "mode_auto_switch_detection_info": { 37 | "message": "当前页面使用$1 " 38 | }, 39 | "mode_auto_switch_detection_tips": { 40 | "message": "由于浏览器的限制,检测结果可能不准确。" 41 | }, 42 | 43 | "mode_direct": { 44 | "message": "直接连接" 45 | }, 46 | "mode_system": { 47 | "message": "使用系统代理" 48 | }, 49 | "mode_profile_create": { 50 | "message": "新建配置" 51 | }, 52 | 53 | "config_proxy_type": { 54 | "message": "代理类型" 55 | }, 56 | "config_proxy_type_proxy": { 57 | "message": "代理" 58 | }, 59 | "config_proxy_type_pac": { 60 | "message": "PAC 脚本" 61 | }, 62 | "config_proxy_type_auto": { 63 | "message": "自动切换" 64 | }, 65 | "config_proxy_type_default": { 66 | "message": "默认" 67 | }, 68 | "config_section_proxy_server": { 69 | "message": "代理服务器" 70 | }, 71 | "config_section_proxy_server_default": { 72 | "message": "默认服务器" 73 | }, 74 | "config_section_proxy_server_http": { 75 | "message": "HTTP" 76 | }, 77 | "config_section_proxy_server_https": { 78 | "message": "HTTPS" 79 | }, 80 | "config_section_proxy_server_ftp": { 81 | "message": "FTP" 82 | }, 83 | "config_section_proxy_auth_tips": { 84 | "message": "如果您的代理需要身份验证,请设置用户名和密码" 85 | }, 86 | "config_section_proxy_auth_unsupported": { 87 | "message": "当前代理类型暂不支持鉴权" 88 | }, 89 | "config_section_proxy_auth_title": { 90 | "message": "代理认证信息" 91 | }, 92 | "config_section_proxy_auth_username": { 93 | "message": "用户名" 94 | }, 95 | "config_section_proxy_auth_password": { 96 | "message": "密码" 97 | }, 98 | 99 | "config_section_bypass_list": { 100 | "message": "Bypass 列表" 101 | }, 102 | "config_section_advance": { 103 | "message": "高级配置" 104 | }, 105 | "config_reference_bypass_list": { 106 | "message": "详细了解 Bypass 列表" 107 | }, 108 | 109 | "config_section_auto_switch_rules": { 110 | "message": "自动分流规则" 111 | }, 112 | "config_section_auto_switch_type": { 113 | "message": "匹配类型" 114 | }, 115 | "config_section_auto_switch_type_domain": { 116 | "message": "域名" 117 | }, 118 | "config_section_auto_switch_type_url": { 119 | "message": "URL" 120 | }, 121 | "config_section_auto_switch_type_url_malformed": { 122 | "message": "无效 URL" 123 | }, 124 | "config_section_auto_switch_type_url_malformed_chrome": { 125 | "message": "出于安全考虑,Chrome 浏览器不支持 HTTPS URL 中的路径匹配。了解更多信息,请访问 https://issues.chromium.org/40083832" 126 | }, 127 | "config_section_auto_switch_type_cidr": { 128 | "message": "CIDR" 129 | }, 130 | "config_section_auto_switch_type_cidr_malformed": { 131 | "message": "无效 CIDR。请使用 \"192.168.1.1/24\" 或 \"2001:db8::/32\" 等格式。" 132 | }, 133 | "config_section_auto_switch_type_disabled": { 134 | "message": "临时禁用" 135 | }, 136 | "config_section_auto_switch_condition": { 137 | "message": "匹配规则" 138 | }, 139 | "config_section_auto_switch_profile": { 140 | "message": "分流至" 141 | }, 142 | "config_section_auto_switch_actions": { 143 | "message": "动作" 144 | }, 145 | "config_section_auto_switch_add_rule": { 146 | "message": "添加规则" 147 | }, 148 | "config_section_auto_switch_delete_rule": { 149 | "message": "删除当前规则" 150 | }, 151 | "config_section_auto_switch_duplicate_rule": { 152 | "message": "复制当前规则" 153 | }, 154 | "config_section_auto_switch_default_profile": { 155 | "message": "默认配置" 156 | }, 157 | "config_section_auto_switch_pac_preview": { 158 | "message": "预览 PAC 脚本" 159 | }, 160 | 161 | "config_action_edit": { 162 | "message": "编辑" 163 | }, 164 | "config_action_delete": { 165 | "message": "删除配置" 166 | }, 167 | "config_action_delete_double_confirm": { 168 | "message": "您确定要删除当前配置吗?" 169 | }, 170 | "config_action_save": { 171 | "message": "保存" 172 | }, 173 | "config_action_cancel": { 174 | "message": "放弃更改" 175 | }, 176 | "config_action_clear": { 177 | "message": "清除" 178 | }, 179 | 180 | "config_feedback_saved": { 181 | "message": "配置已保存" 182 | }, 183 | "config_feedback_copied": { 184 | "message": "已复制" 185 | }, 186 | "config_feedback_deleted": { 187 | "message": "配置已删除" 188 | }, 189 | "config_feedback_unknown_profile": { 190 | "message": "未知配置" 191 | }, 192 | "config_feedback_error_occurred": { 193 | "message": "发生错误: $1" 194 | }, 195 | 196 | "preferences_section_import_export": { 197 | "message": "导入 / 导出" 198 | }, 199 | 200 | "preferences_section_import_export_desc": { 201 | "message": "将代理配置导出成文件以便进行备份或分享,也可导入文件还原配置。" 202 | }, 203 | 204 | "preferences_action_profile_export": { 205 | "message": "导出配置" 206 | }, 207 | 208 | "preferences_action_profile_import": { 209 | "message": "导入配置" 210 | }, 211 | 212 | "preferences_feedback_no_profile_to_be_exported": { 213 | "message": "没有可以导出的配置" 214 | }, 215 | 216 | "preferences_tips_profile_overwrite": { 217 | "message": "导入后,某些配置文件可能会被覆盖。您确定要继续吗?" 218 | }, 219 | 220 | "preferences_feedback_n_profiles_being_imported": { 221 | "message": "已导入$1 份有效配置" 222 | }, 223 | 224 | "form_is_required": { 225 | "message": "$1是必填项" 226 | }, 227 | 228 | "feedback_error": { 229 | "message": "出现问题" 230 | }, 231 | 232 | "firefox_incognito_access_error_html": { 233 | "message": "
由于 Firefox 的限制,Proxyverse 无法在隐私浏览窗口运行,因而无法正常工作。
\n
请在隐私窗口中开启扩展。
\n" 234 | }, 235 | 236 | "_": { 237 | "message": "" 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /public/full-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/services/proxy/scriptHelper.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "espree"; 2 | import type { 3 | AssignmentExpression, 4 | AssignmentOperator, 5 | CallExpression, 6 | Expression, 7 | ExpressionStatement, 8 | FunctionDeclaration, 9 | FunctionExpression, 10 | Identifier, 11 | IfStatement, 12 | Literal, 13 | LogicalExpression, 14 | MemberExpression, 15 | ObjectExpression, 16 | Pattern, 17 | Property, 18 | ReturnStatement, 19 | SpreadElement, 20 | Statement, 21 | VariableDeclaration, 22 | } from "acorn"; 23 | import { ProxyServer } from "../profile"; 24 | 25 | export function parsePACScript(script: string): Statement[] { 26 | const program = parse(script); 27 | 28 | const ret: Statement[] = []; 29 | for (const stmt of program.body) { 30 | switch (stmt.type) { 31 | case "ImportDeclaration": 32 | case "ExportNamedDeclaration": 33 | case "ExportDefaultDeclaration": 34 | case "ExportAllDeclaration": 35 | throw new Error(`${stmt.type} is not allowed in PAC script"`); 36 | } 37 | ret.push(stmt); 38 | } 39 | return ret; 40 | } 41 | 42 | export const newProxyString = (cfg: ProxyServer) => { 43 | const scheme = cfg.scheme || "http"; 44 | if (scheme == "direct") { 45 | return PACScriptHelper.newSimpleLiteral("DIRECT"); 46 | } 47 | 48 | let host = cfg.host; 49 | if (cfg.port !== undefined) { 50 | host += `:${cfg.port}`; 51 | } 52 | 53 | if (["http", "https"].includes(scheme)) { 54 | return PACScriptHelper.newSimpleLiteral( 55 | `${cfg.scheme == "http" ? "PROXY" : "HTTPS"} ${host}` 56 | ); 57 | } 58 | 59 | return PACScriptHelper.newSimpleLiteral( 60 | `${scheme.toUpperCase()} ${host}; SOCKS ${host}` 61 | ); 62 | }; 63 | 64 | export class PACScriptHelper { 65 | static newAssignmentExpression( 66 | operator: AssignmentOperator, 67 | left: Pattern | MemberExpression, 68 | right: Expression 69 | ): AssignmentExpression { 70 | return { 71 | type: "AssignmentExpression", 72 | operator, 73 | left, 74 | right, 75 | start: 0, // dummy 76 | end: 0, // dummy 77 | }; 78 | } 79 | static newExpressionStatement(expression: Expression): ExpressionStatement { 80 | return { 81 | type: "ExpressionStatement", 82 | expression, 83 | start: 0, // dummy 84 | end: 0, // dummy 85 | }; 86 | } 87 | static newVariableDeclaration( 88 | name: string, 89 | init?: Expression 90 | ): VariableDeclaration { 91 | return { 92 | type: "VariableDeclaration", 93 | kind: "var", 94 | declarations: [ 95 | { 96 | type: "VariableDeclarator", 97 | id: this.newIdentifier(name), 98 | init: init, 99 | start: 0, // dummy 100 | end: 0, // dummy 101 | }, 102 | ], 103 | start: 0, // dummy 104 | end: 0, // dummy 105 | }; 106 | } 107 | 108 | static newLogicalExpression( 109 | operator: "||" | "&&", 110 | left: Expression, 111 | right: Expression 112 | ): LogicalExpression { 113 | return { 114 | type: "LogicalExpression", 115 | operator, 116 | left, 117 | right, 118 | start: 0, // dummy 119 | end: 0, // dummy 120 | }; 121 | } 122 | 123 | static newObjectExpression( 124 | properties: Array 125 | ): ObjectExpression { 126 | return { 127 | type: "ObjectExpression", 128 | properties, 129 | start: 0, // dummy 130 | end: 0, // dummy 131 | }; 132 | } 133 | 134 | static newIdentifier(name: string): Identifier { 135 | return { 136 | type: "Identifier", 137 | name: name, 138 | start: 0, // dummy 139 | end: 0, // dummy 140 | }; 141 | } 142 | 143 | static newSimpleLiteral(value: string | boolean | number | null): Literal { 144 | return { 145 | type: "Literal", 146 | value: value, 147 | start: 0, // dummy 148 | end: 0, // dummy 149 | }; 150 | } 151 | 152 | static newFunctionDeclaration( 153 | name: string, 154 | params: string[], 155 | body: Statement[] 156 | ): FunctionDeclaration { 157 | return { 158 | type: "FunctionDeclaration", 159 | id: this.newIdentifier(name), 160 | params: params.map((v) => this.newIdentifier(v)), 161 | body: { 162 | type: "BlockStatement", 163 | body: body, 164 | start: 0, // dummy 165 | end: 0, // dummy 166 | }, 167 | generator: false, 168 | expression: false, 169 | async: false, 170 | start: 0, // dummy 171 | end: 0, // dummy 172 | }; 173 | } 174 | 175 | static newFunctionExpression( 176 | params: string[], 177 | body: Statement[] 178 | ): FunctionExpression { 179 | return { 180 | type: "FunctionExpression", 181 | params: params.map((v) => this.newIdentifier(v)), 182 | body: { 183 | type: "BlockStatement", 184 | body: body, 185 | start: 0, // dummy 186 | end: 0, // dummy 187 | }, 188 | generator: false, 189 | expression: false, 190 | async: false, 191 | start: 0, // dummy 192 | end: 0, // dummy 193 | }; 194 | } 195 | 196 | static newReturnStatement(argument?: Expression): ReturnStatement { 197 | return { 198 | type: "ReturnStatement", 199 | argument, 200 | start: 0, // dummy 201 | end: 0, // dummy 202 | }; 203 | } 204 | 205 | static newMemberExpression( 206 | object: Expression, 207 | property: Expression, 208 | computed: boolean = false 209 | ): MemberExpression { 210 | return { 211 | type: "MemberExpression", 212 | object, 213 | property, 214 | computed, 215 | optional: false, 216 | start: 0, // dummy 217 | end: 0, // dummy 218 | }; 219 | } 220 | 221 | static newCallExpression( 222 | callee: Expression, 223 | _arguments: Expression[] 224 | ): CallExpression { 225 | return { 226 | type: "CallExpression", 227 | optional: false, 228 | callee, 229 | arguments: _arguments, 230 | start: 0, // dummy 231 | end: 0, // dummy 232 | }; 233 | } 234 | 235 | static newIfStatement( 236 | test: Expression, 237 | consequent: Statement[], 238 | alternate?: Statement | null 239 | ): IfStatement { 240 | return { 241 | type: "IfStatement", 242 | test: test, 243 | consequent: { 244 | type: "BlockStatement", 245 | body: consequent, 246 | start: 0, // dummy 247 | end: 0, // dummy 248 | }, 249 | alternate, 250 | start: 0, // dummy 251 | end: 0, // dummy 252 | }; 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/pages/PopupPage.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 149 | 150 | 212 | -------------------------------------------------------------------------------- /public/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_desc": { 3 | "message": "A tool to help you manage and switch your proxy profiles" 4 | }, 5 | 6 | "nav_preference": { 7 | "message": "Preference" 8 | }, 9 | "nav_config": { 10 | "message": "Config" 11 | }, 12 | "nav_custom_profiles": { 13 | "message": "Custom Profiles" 14 | }, 15 | "nav_system_profiles": { 16 | "message": "Presets" 17 | }, 18 | 19 | "theme_light_mode": { 20 | "message": "Light mode" 21 | }, 22 | "theme_dark_mode": { 23 | "message": "Dark mode" 24 | }, 25 | "theme_auto_mode": { 26 | "message": "Follow system" 27 | }, 28 | 29 | "mode_auto_switch": { 30 | "message": "Auto Switch" 31 | }, 32 | "mode_auto_switch_abbr": { 33 | "message": "auto" 34 | }, 35 | 36 | "mode_auto_switch_detection_info": { 37 | "message": "$1 is used for this tab" 38 | }, 39 | "mode_auto_switch_detection_tips": { 40 | "message": "Due to the limitation of the browser, the detection is not always accurate." 41 | }, 42 | 43 | "mode_direct": { 44 | "message": "Direct" 45 | }, 46 | "mode_system": { 47 | "message": "Use System Proxy" 48 | }, 49 | "mode_profile_create": { 50 | "message": "Create New Profile" 51 | }, 52 | 53 | "config_proxy_type": { 54 | "message": "Proxy Type" 55 | }, 56 | "config_proxy_type_proxy": { 57 | "message": "Proxy" 58 | }, 59 | "config_proxy_type_pac": { 60 | "message": "PAC Script" 61 | }, 62 | "config_proxy_type_auto": { 63 | "message": "Auto Switch" 64 | }, 65 | "config_proxy_type_default": { 66 | "message": "Same as Default" 67 | }, 68 | "config_section_proxy_server": { 69 | "message": "Proxy Server" 70 | }, 71 | "config_section_proxy_server_default": { 72 | "message": "Default Server" 73 | }, 74 | "config_section_proxy_server_http": { 75 | "message": "HTTP" 76 | }, 77 | "config_section_proxy_server_https": { 78 | "message": "HTTPS" 79 | }, 80 | "config_section_proxy_server_ftp": { 81 | "message": "FTP" 82 | }, 83 | "config_section_proxy_auth_tips": { 84 | "message": "Set username and password if your proxy requires authentication" 85 | }, 86 | "config_section_proxy_auth_unsupported": { 87 | "message": "The current proxy type does not support authentication" 88 | }, 89 | "config_section_proxy_auth_title": { 90 | "message": "Proxy Authentication" 91 | }, 92 | "config_section_proxy_auth_username": { 93 | "message": "Username" 94 | }, 95 | "config_section_proxy_auth_password": { 96 | "message": "Password" 97 | }, 98 | 99 | "config_section_bypass_list": { 100 | "message": "Bypass List" 101 | }, 102 | "config_section_advance": { 103 | "message": "Advance Config" 104 | }, 105 | "config_reference_bypass_list": { 106 | "message": "Learn more about bypass list" 107 | }, 108 | 109 | "config_section_auto_switch_rules": { 110 | "message": "Auto Switch Rules" 111 | }, 112 | "config_section_auto_switch_type": { 113 | "message": "Condition Type" 114 | }, 115 | "config_section_auto_switch_type_domain": { 116 | "message": "Domain" 117 | }, 118 | "config_section_auto_switch_type_url": { 119 | "message": "URL" 120 | }, 121 | "config_section_auto_switch_type_url_malformed": { 122 | "message": "Invalid URL" 123 | }, 124 | "config_section_auto_switch_type_url_malformed_chrome": { 125 | "message": "For security reasons, Chrome does not support path matching in HTTPS URLs. Learn more at https://issues.chromium.org/40083832" 126 | }, 127 | "config_section_auto_switch_type_cidr": { 128 | "message": "CIDR" 129 | }, 130 | "config_section_auto_switch_type_cidr_malformed": { 131 | "message": "Invalid CIDR. Please use the format like `192.168.1.1/24` or `2001:db8::/32`" 132 | }, 133 | "config_section_auto_switch_type_disabled": { 134 | "message": "Temporarily Skip" 135 | }, 136 | "config_section_auto_switch_condition": { 137 | "message": "Condition" 138 | }, 139 | "config_section_auto_switch_profile": { 140 | "message": "Route to" 141 | }, 142 | "config_section_auto_switch_actions": { 143 | "message": "Actions" 144 | }, 145 | "config_section_auto_switch_add_rule": { 146 | "message": "Add Rule" 147 | }, 148 | "config_section_auto_switch_delete_rule": { 149 | "message": "Delete Current Rule" 150 | }, 151 | "config_section_auto_switch_duplicate_rule": { 152 | "message": "Duplicate Current Rule" 153 | }, 154 | "config_section_auto_switch_default_profile": { 155 | "message": "Default Profile" 156 | }, 157 | "config_section_auto_switch_pac_preview": { 158 | "message": "Preview PAC Script" 159 | }, 160 | 161 | "config_action_edit": { 162 | "message": "Edit" 163 | }, 164 | "config_action_delete": { 165 | "message": "Delete Profile" 166 | }, 167 | "config_action_delete_double_confirm": { 168 | "message": "Are you sure to delete the current profile" 169 | }, 170 | "config_action_save": { 171 | "message": "Save" 172 | }, 173 | "config_action_cancel": { 174 | "message": "Discard Change" 175 | }, 176 | "config_action_clear": { 177 | "message": "Clear" 178 | }, 179 | 180 | "config_feedback_saved": { 181 | "message": "The profile had been saved" 182 | }, 183 | "config_feedback_copied": { 184 | "message": "Copied" 185 | }, 186 | "config_feedback_deleted": { 187 | "message": "The profile had been deleted" 188 | }, 189 | "config_feedback_unknown_profile": { 190 | "message": "Unknown profile" 191 | }, 192 | "config_feedback_error_occurred": { 193 | "message": "Error occurred: $1" 194 | }, 195 | 196 | "preferences_section_import_export": { 197 | "message": "Import & Export" 198 | }, 199 | 200 | "preferences_section_import_export_desc": { 201 | "message": "Export your proxy profiles to a file for backup or sharing, or import them to restore profiles when needed." 202 | }, 203 | 204 | "preferences_action_profile_export": { 205 | "message": "Export Profiles" 206 | }, 207 | 208 | "preferences_action_profile_import": { 209 | "message": "Import Profiles" 210 | }, 211 | 212 | "preferences_feedback_no_profile_to_be_exported": { 213 | "message": "No valid profile to be exported" 214 | }, 215 | 216 | "preferences_tips_profile_overwrite": { 217 | "message": "Some profile(s) might be overwritten after the import. Are you sure you want to continue?" 218 | }, 219 | 220 | "preferences_feedback_n_profiles_being_imported": { 221 | "message": "$1 profiles have been imported" 222 | }, 223 | 224 | "form_is_required": { 225 | "message": "$1 is required" 226 | }, 227 | 228 | "feedback_error": { 229 | "message": "Something wrong" 230 | }, 231 | 232 | "firefox_incognito_access_error_html": { 233 | "message": "
Due to Firefox's restrictions, Proxyverse does not work properly as it's not enabled in private browsing windows.
\n
Please enable the extension in private windows.
\n" 234 | }, 235 | 236 | "_": { 237 | "message": "" 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /public/_locales/pt_BR/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_desc": { 3 | "message": "Uma ferramenta para ajudá-lo a gerenciar e alternar seus perfis de proxy" 4 | }, 5 | 6 | "nav_preference": { 7 | "message": "Preferência" 8 | }, 9 | "nav_config": { 10 | "message": "Configuração" 11 | }, 12 | "nav_custom_profiles": { 13 | "message": "Perfis personalizados" 14 | }, 15 | "nav_system_profiles": { 16 | "message": "Predefinições" 17 | }, 18 | 19 | "theme_light_mode": { 20 | "message": "Modo de luz" 21 | }, 22 | "theme_dark_mode": { 23 | "message": "Modo escuro" 24 | }, 25 | "theme_auto_mode": { 26 | "message": "Seguir o sistema" 27 | }, 28 | 29 | "mode_auto_switch": { 30 | "message": "Comutação automática" 31 | }, 32 | "mode_auto_switch_abbr": { 33 | "message": "automático" 34 | }, 35 | 36 | "mode_auto_switch_detection_info": { 37 | "message": "$1 é usado para essa guia" 38 | }, 39 | "mode_auto_switch_detection_tips": { 40 | "message": "Devido à limitação do navegador, a detecção nem sempre é precisa." 41 | }, 42 | 43 | "mode_direct": { 44 | "message": "Direto" 45 | }, 46 | "mode_system": { 47 | "message": "Usar proxy do sistema" 48 | }, 49 | "mode_profile_create": { 50 | "message": "Criar novo perfil" 51 | }, 52 | 53 | "config_proxy_type": { 54 | "message": "Tipo de proxy" 55 | }, 56 | "config_proxy_type_proxy": { 57 | "message": "Proxy" 58 | }, 59 | "config_proxy_type_pac": { 60 | "message": "Script PAC" 61 | }, 62 | "config_proxy_type_auto": { 63 | "message": "Comutação automática" 64 | }, 65 | "config_proxy_type_default": { 66 | "message": "Igual ao padrão" 67 | }, 68 | "config_section_proxy_server": { 69 | "message": "Servidor proxy" 70 | }, 71 | "config_section_proxy_server_default": { 72 | "message": "Servidor padrão" 73 | }, 74 | "config_section_proxy_server_http": { 75 | "message": "HTTP" 76 | }, 77 | "config_section_proxy_server_https": { 78 | "message": "HTTPS" 79 | }, 80 | "config_section_proxy_server_ftp": { 81 | "message": "FTP" 82 | }, 83 | "config_section_proxy_auth_tips": { 84 | "message": "Defina o nome de usuário e a senha se o proxy exigir autenticação" 85 | }, 86 | "config_section_proxy_auth_unsupported": { 87 | "message": "O tipo de proxy atual não é compatível com a autenticação" 88 | }, 89 | "config_section_proxy_auth_title": { 90 | "message": "Autenticação de proxy" 91 | }, 92 | "config_section_proxy_auth_username": { 93 | "message": "Nome de usuário" 94 | }, 95 | "config_section_proxy_auth_password": { 96 | "message": "Senha" 97 | }, 98 | 99 | "config_section_bypass_list": { 100 | "message": "Lista de desvios" 101 | }, 102 | "config_section_advance": { 103 | "message": "Configuração avançada" 104 | }, 105 | "config_reference_bypass_list": { 106 | "message": "Saiba mais sobre a lista de bypass" 107 | }, 108 | 109 | "config_section_auto_switch_rules": { 110 | "message": "Regras de comutação automática" 111 | }, 112 | "config_section_auto_switch_type": { 113 | "message": "Tipo de condição" 114 | }, 115 | "config_section_auto_switch_type_domain": { 116 | "message": "Domínio" 117 | }, 118 | "config_section_auto_switch_type_url": { 119 | "message": "URL" 120 | }, 121 | "config_section_auto_switch_type_url_malformed": { 122 | "message": "URL inválido" 123 | }, 124 | "config_section_auto_switch_type_url_malformed_chrome": { 125 | "message": "Por motivos de segurança, o Chrome não suporta a correspondência de caminhos em URLs HTTPS. Saiba mais em https://issues.chromium.org/40083832" 126 | }, 127 | "config_section_auto_switch_type_cidr": { 128 | "message": "CIDR" 129 | }, 130 | "config_section_auto_switch_type_cidr_malformed": { 131 | "message": "CIDR inválido. Use o formato `192.168.1.1/24` ou `2001:db8::/32`" 132 | }, 133 | "config_section_auto_switch_type_disabled": { 134 | "message": "Ignorar temporariamente" 135 | }, 136 | "config_section_auto_switch_condition": { 137 | "message": "Condição" 138 | }, 139 | "config_section_auto_switch_profile": { 140 | "message": "Rota para" 141 | }, 142 | "config_section_auto_switch_actions": { 143 | "message": "Ações" 144 | }, 145 | "config_section_auto_switch_add_rule": { 146 | "message": "Adicionar regra" 147 | }, 148 | "config_section_auto_switch_delete_rule": { 149 | "message": "Excluir regra atual" 150 | }, 151 | "config_section_auto_switch_duplicate_rule": { 152 | "message": "Duplicar regra atual" 153 | }, 154 | "config_section_auto_switch_default_profile": { 155 | "message": "Perfil padrão" 156 | }, 157 | "config_section_auto_switch_pac_preview": { 158 | "message": "Visualizar o PAC Script" 159 | }, 160 | 161 | "config_action_edit": { 162 | "message": "Editar" 163 | }, 164 | "config_action_delete": { 165 | "message": "Excluir perfil" 166 | }, 167 | "config_action_delete_double_confirm": { 168 | "message": "Tem certeza de que deseja excluir o perfil atual?" 169 | }, 170 | "config_action_save": { 171 | "message": "Salvar" 172 | }, 173 | "config_action_cancel": { 174 | "message": "Mudança de descarte" 175 | }, 176 | "config_action_clear": { 177 | "message": "Limpo" 178 | }, 179 | 180 | "config_feedback_saved": { 181 | "message": "O perfil foi salvo" 182 | }, 183 | "config_feedback_copied": { 184 | "message": "Copiado" 185 | }, 186 | "config_feedback_deleted": { 187 | "message": "O perfil foi excluído" 188 | }, 189 | "config_feedback_unknown_profile": { 190 | "message": "Perfil desconhecido" 191 | }, 192 | "config_feedback_error_occurred": { 193 | "message": "Ocorreu um erro: $1" 194 | }, 195 | 196 | "preferences_section_import_export": { 197 | "message": "Importação e exportação" 198 | }, 199 | 200 | "preferences_section_import_export_desc": { 201 | "message": "Exporte seus perfis de proxy para um arquivo para backup ou compartilhamento, ou importe-os para restaurar perfis quando necessário." 202 | }, 203 | 204 | "preferences_action_profile_export": { 205 | "message": "Perfis de exportação" 206 | }, 207 | 208 | "preferences_action_profile_import": { 209 | "message": "Perfis de importação" 210 | }, 211 | 212 | "preferences_feedback_no_profile_to_be_exported": { 213 | "message": "Nenhum perfil válido a ser exportado" 214 | }, 215 | 216 | "preferences_tips_profile_overwrite": { 217 | "message": "Alguns perfis podem ser sobrescritos após a importação. Tem certeza de que deseja continuar?" 218 | }, 219 | 220 | "preferences_feedback_n_profiles_being_imported": { 221 | "message": "$1 perfis foram importados" 222 | }, 223 | 224 | "form_is_required": { 225 | "message": "$1 é necessário" 226 | }, 227 | 228 | "feedback_error": { 229 | "message": "Algo errado" 230 | }, 231 | 232 | "firefox_incognito_access_error_html": { 233 | "message": "
Devido às limitações do Firefox, o Proxyverse não funciona corretamente, pois não está ativado nas janelas de navegação privada.
\n
Ative a extensão em janelas privadas.
\n" 234 | }, 235 | 236 | "_": { 237 | "message": "" 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/components/configs/AutoSwitchInput.vue: -------------------------------------------------------------------------------- 1 | 156 | 157 | 281 | 282 | 294 | -------------------------------------------------------------------------------- /tests/services/config/local-config.test.ts: -------------------------------------------------------------------------------- 1 | import { config2json, json2config } from "@/services/config/schema"; 2 | import { ProxyProfile } from "@/services/profile"; 3 | import { expect, test, describe } from "vitest"; 4 | 5 | const mockInnerConfig: Record< 6 | string, 7 | ProxyProfile & { 8 | [key: string]: any; // Index signature to allow additional properties 9 | } 10 | > = { 11 | "90750184-fb8f-4130-ad83-c997a7207300": { 12 | color: "#DCF190", 13 | defaultProfileID: "direct", 14 | pacScript: { 15 | data: "function FindProxyForURL(url, host) {\n // …\n return 'DIRECT';\n}", 16 | }, 17 | profileID: "90750184-fb8f-4130-ad83-c997a7207300", 18 | profileName: "PAC Script", 19 | proxyRules: { 20 | bypassList: ["", "127.0.0.1", "[::1]"], 21 | default: { 22 | host: "127.0.0.1", 23 | port: 8080, 24 | scheme: "http", 25 | }, 26 | }, 27 | proxyType: "pac", 28 | rules: [ 29 | { 30 | condition: "example.com", 31 | profileID: "direct", 32 | type: "domain", 33 | }, 34 | { 35 | condition: "http://example.com/api/*", 36 | profileID: "direct", 37 | type: "url", 38 | }, 39 | ], 40 | }, 41 | "ac8eeebe-3a13-4969-881c-3c7419e91f95": { 42 | color: "#93BEFF", 43 | defaultProfileID: "direct", 44 | pacScript: { 45 | data: "function FindProxyForURL(url, host) {\n // …\n return 'DIRECT';\n}", 46 | }, 47 | profileID: "ac8eeebe-3a13-4969-881c-3c7419e91f95", 48 | profileName: "HTTP", 49 | proxyRules: { 50 | bypassList: ["", "127.0.0.1", "[::1]"], 51 | default: { 52 | host: "localhost", 53 | port: 8080, 54 | scheme: "http", 55 | }, 56 | }, 57 | proxyType: "proxy", 58 | rules: [ 59 | { 60 | condition: "example.com", 61 | profileID: "direct", 62 | type: "domain", 63 | }, 64 | { 65 | condition: "http://example.com/api/*", 66 | profileID: "direct", 67 | type: "url", 68 | }, 69 | ], 70 | }, 71 | "bd4a7580-2c03-4465-acae-bde628ffbb16": { 72 | color: "#FBB0A7", 73 | defaultProfileID: "direct", 74 | pacScript: { 75 | data: "function FindProxyForURL(url, host) {\n // …\n return 'DIRECT';\n}", 76 | }, 77 | profileID: "bd4a7580-2c03-4465-acae-bde628ffbb16", 78 | profileName: "HTTP (with Auth)", 79 | proxyRules: { 80 | bypassList: ["", "127.0.0.1", "[::1]"], 81 | default: { 82 | auth: { 83 | password: "securepassword", 84 | username: "admin", 85 | }, 86 | host: "127.0.0.1", 87 | port: 8080, 88 | scheme: "http", 89 | }, 90 | }, 91 | proxyType: "proxy", 92 | rules: [ 93 | { 94 | condition: "example.com", 95 | profileID: "direct", 96 | type: "domain", 97 | }, 98 | { 99 | condition: "http://example.com/api/*", 100 | profileID: "direct", 101 | type: "url", 102 | }, 103 | ], 104 | }, 105 | "fb29f908-a284-4dca-b7e5-c606e6b3c2f1": { 106 | color: "#3491FA", 107 | defaultProfileID: "direct", 108 | pacScript: { 109 | data: "function FindProxyForURL(url, host) {\n // …\n return 'DIRECT';\n}", 110 | }, 111 | profileID: "fb29f908-a284-4dca-b7e5-c606e6b3c2f1", 112 | profileName: "Auto Switch", 113 | proxyRules: { 114 | bypassList: ["", "127.0.0.1", "[::1]"], 115 | default: { 116 | host: "127.0.0.1", 117 | port: 8080, 118 | scheme: "http", 119 | }, 120 | }, 121 | proxyType: "auto", 122 | rules: [ 123 | { 124 | condition: "example.com", 125 | profileID: "direct", 126 | type: "domain", 127 | }, 128 | { 129 | condition: "http://example.com/api/*", 130 | profileID: "ac8eeebe-3a13-4969-881c-3c7419e91f95", 131 | type: "url", 132 | }, 133 | ], 134 | }, 135 | }; 136 | 137 | const mockExportedJSON = { 138 | version: "2025-01", 139 | profiles: [ 140 | { 141 | color: "#DCF190", 142 | profileID: "90750184-fb8f-4130-ad83-c997a7207300", 143 | profileName: "PAC Script", 144 | pacScript: { 145 | data: "function FindProxyForURL(url, host) {\n // …\n return 'DIRECT';\n}", 146 | }, 147 | proxyType: "pac", 148 | }, 149 | { 150 | color: "#93BEFF", 151 | profileID: "ac8eeebe-3a13-4969-881c-3c7419e91f95", 152 | profileName: "HTTP", 153 | proxyRules: { 154 | bypassList: ["", "127.0.0.1", "[::1]"], 155 | default: { 156 | host: "localhost", 157 | scheme: "http", 158 | port: 8080, 159 | }, 160 | }, 161 | proxyType: "proxy", 162 | }, 163 | { 164 | color: "#FBB0A7", 165 | profileID: "bd4a7580-2c03-4465-acae-bde628ffbb16", 166 | profileName: "HTTP (with Auth)", 167 | proxyRules: { 168 | bypassList: ["", "127.0.0.1", "[::1]"], 169 | default: { 170 | host: "127.0.0.1", 171 | scheme: "http", 172 | port: 8080, 173 | auth: { 174 | username: "admin", 175 | password: "securepassword", 176 | }, 177 | }, 178 | }, 179 | proxyType: "proxy", 180 | }, 181 | { 182 | color: "#3491FA", 183 | profileID: "fb29f908-a284-4dca-b7e5-c606e6b3c2f1", 184 | profileName: "Auto Switch", 185 | defaultProfileID: "direct", 186 | proxyType: "auto", 187 | rules: [ 188 | { 189 | type: "domain", 190 | condition: "example.com", 191 | profileID: "direct", 192 | }, 193 | { 194 | type: "url", 195 | condition: "http://example.com/api/*", 196 | profileID: "ac8eeebe-3a13-4969-881c-3c7419e91f95", 197 | }, 198 | ], 199 | }, 200 | ], 201 | }; 202 | 203 | const mockJSONToBeImported = { 204 | version: "2025-01", 205 | profiles: [ 206 | { 207 | color: "#FBB0A7", 208 | profileID: "bd4a7580-2c03-4465-acae-bde628ffbb16", 209 | profileName: "HTTP (with Auth)", 210 | proxyRules: { 211 | bypassList: ["", "127.0.0.1", "[::1]"], 212 | default: { 213 | host: "127.0.0.1", 214 | scheme: "http", 215 | port: 8080, 216 | auth: { 217 | username: "admin", 218 | password: "securepassword", 219 | }, 220 | }, 221 | }, 222 | proxyType: "proxy", 223 | 224 | // additional properties 225 | dummy: "dummy", 226 | }, 227 | ], 228 | }; 229 | 230 | const mockJSONToBeImportedWithError = { 231 | version: "2025-01", 232 | profiles: [ 233 | { 234 | color: "#FBB0A7", 235 | profileID: "bd4a7580-2c03-4465-acae-bde628ffbb16", 236 | profileName: "HTTP (with Auth)", 237 | proxyRules: { 238 | bypassList: ["", "127.0.0.1", "[::1]"], 239 | default: { 240 | host: "127.0.0.1", 241 | scheme: "http", 242 | port: 65536, // invalid port 243 | auth: { 244 | username: "admin", 245 | password: "securepassword", 246 | }, 247 | }, 248 | }, 249 | proxyType: "proxy", 250 | }, 251 | ], 252 | }; 253 | 254 | describe("testing exporting config", () => { 255 | test("export JSON config", async () => { 256 | const jsonStr = config2json(mockInnerConfig); 257 | 258 | expect(JSON.parse(jsonStr)).toMatchObject(mockExportedJSON); 259 | }); 260 | }); 261 | 262 | describe("testing parsing config", () => { 263 | test("parse malformed JSON config", async () => { 264 | expect(() => json2config("{blah")).toThrow("Invalid config data"); 265 | }); 266 | 267 | test("parse empty JSON config", async () => { 268 | expect(() => json2config("{}")).toThrow(/Could not validate data:/); 269 | }); 270 | 271 | test("parse normal JSON config", async () => { 272 | const profiles = json2config(JSON.stringify(mockJSONToBeImported)); 273 | expect(profiles).toHaveLength(1); 274 | 275 | const expectedProfile: any = { 276 | ...mockJSONToBeImported["profiles"][0], 277 | }; 278 | delete expectedProfile.dummy; 279 | 280 | expect(profiles[0]).toMatchObject(expectedProfile); 281 | }); 282 | 283 | test("parse normal JSON config with invalid value", async () => { 284 | expect(() => 285 | json2config(JSON.stringify(mockJSONToBeImportedWithError)) 286 | ).toThrow("Could not validate data: .profiles.0.proxyRules.default.port"); 287 | }); 288 | }); 289 | -------------------------------------------------------------------------------- /tests/services/proxy/pacSimulator.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from "vitest"; 2 | import { isInNet, UNKNOWN } from "@/services/proxy/pacSimulator"; 3 | 4 | describe("isInNet function", () => { 5 | describe("valid IP addresses that match the subnet", () => { 6 | test("should return true for IP in /24 subnet", () => { 7 | expect(isInNet("192.168.31.100", "192.168.31.0", "255.255.255.0")).toBe( 8 | true 9 | ); 10 | expect(isInNet("192.168.1.1", "192.168.1.0", "255.255.255.0")).toBe(true); 11 | expect(isInNet("192.168.1.254", "192.168.1.0", "255.255.255.0")).toBe( 12 | true 13 | ); 14 | }); 15 | 16 | test("should return true for IP in /16 subnet", () => { 17 | expect(isInNet("192.168.100.50", "192.168.0.0", "255.255.0.0")).toBe( 18 | true 19 | ); 20 | expect(isInNet("192.168.255.255", "192.168.0.0", "255.255.0.0")).toBe( 21 | true 22 | ); 23 | }); 24 | 25 | test("should return true for IP in /8 subnet", () => { 26 | expect(isInNet("10.100.200.50", "10.0.0.0", "255.0.0.0")).toBe(true); 27 | expect(isInNet("10.255.255.255", "10.0.0.0", "255.0.0.0")).toBe(true); 28 | }); 29 | 30 | test("should return true for exact match with /32 subnet", () => { 31 | expect(isInNet("192.168.1.1", "192.168.1.1", "255.255.255.255")).toBe( 32 | true 33 | ); 34 | }); 35 | 36 | test("should return true for any IP with /0 subnet (all match)", () => { 37 | expect(isInNet("1.2.3.4", "0.0.0.0", "0.0.0.0")).toBe(true); 38 | expect(isInNet("255.255.255.255", "0.0.0.0", "0.0.0.0")).toBe(true); 39 | }); 40 | 41 | test("should return true for localhost in 127.0.0.0/8", () => { 42 | expect(isInNet("127.0.0.1", "127.0.0.0", "255.0.0.0")).toBe(true); 43 | expect(isInNet("127.255.255.255", "127.0.0.0", "255.0.0.0")).toBe(true); 44 | }); 45 | }); 46 | 47 | describe("valid IP addresses that don't match the subnet", () => { 48 | test("should return false for IP outside /24 subnet", () => { 49 | expect(isInNet("192.168.2.100", "192.168.1.0", "255.255.255.0")).toBe( 50 | false 51 | ); 52 | expect(isInNet("192.169.1.100", "192.168.1.0", "255.255.255.0")).toBe( 53 | false 54 | ); 55 | expect(isInNet("193.168.1.100", "192.168.1.0", "255.255.255.0")).toBe( 56 | false 57 | ); 58 | }); 59 | 60 | test("should return false for IP outside /16 subnet", () => { 61 | expect(isInNet("192.169.1.100", "192.168.0.0", "255.255.0.0")).toBe( 62 | false 63 | ); 64 | expect(isInNet("193.168.1.100", "192.168.0.0", "255.255.0.0")).toBe( 65 | false 66 | ); 67 | }); 68 | 69 | test("should return false for IP outside /8 subnet", () => { 70 | expect(isInNet("11.100.200.50", "10.0.0.0", "255.0.0.0")).toBe(false); 71 | }); 72 | 73 | test("should return false for different IP with /32 subnet", () => { 74 | expect(isInNet("192.168.1.2", "192.168.1.1", "255.255.255.255")).toBe( 75 | false 76 | ); 77 | }); 78 | }); 79 | 80 | describe("invalid IP addresses (should return UNKNOWN)", () => { 81 | test("should return UNKNOWN for hostnames", () => { 82 | expect(isInNet("example.com", "192.168.1.0", "255.255.255.0")).toBe( 83 | UNKNOWN 84 | ); 85 | expect(isInNet("localhost", "127.0.0.0", "255.0.0.0")).toBe(UNKNOWN); 86 | expect( 87 | isInNet("subdomain.example.com", "192.168.1.0", "255.255.255.0") 88 | ).toBe(UNKNOWN); 89 | }); 90 | 91 | test("should return UNKNOWN for IPs with out-of-range octets", () => { 92 | expect(isInNet("256.168.1.1", "192.168.1.0", "255.255.255.0")).toBe( 93 | UNKNOWN 94 | ); 95 | expect(isInNet("192.256.1.1", "192.168.1.0", "255.255.255.0")).toBe( 96 | UNKNOWN 97 | ); 98 | expect(isInNet("192.168.256.1", "192.168.1.0", "255.255.255.0")).toBe( 99 | UNKNOWN 100 | ); 101 | expect(isInNet("192.168.1.256", "192.168.1.0", "255.255.255.0")).toBe( 102 | UNKNOWN 103 | ); 104 | expect(isInNet("999.999.999.999", "192.168.1.0", "255.255.255.0")).toBe( 105 | UNKNOWN 106 | ); 107 | }); 108 | 109 | test("should return UNKNOWN for IPs with wrong format", () => { 110 | expect(isInNet("192.168.1", "192.168.1.0", "255.255.255.0")).toBe( 111 | UNKNOWN 112 | ); 113 | expect(isInNet("192.168", "192.168.1.0", "255.255.255.0")).toBe(UNKNOWN); 114 | expect(isInNet("192", "192.168.1.0", "255.255.255.0")).toBe(UNKNOWN); 115 | expect(isInNet("192.168.1.1.1", "192.168.1.0", "255.255.255.0")).toBe( 116 | UNKNOWN 117 | ); 118 | }); 119 | 120 | test("should return UNKNOWN for empty string", () => { 121 | expect(isInNet("", "192.168.1.0", "255.255.255.0")).toBe(UNKNOWN); 122 | }); 123 | 124 | test("should return UNKNOWN for non-numeric values", () => { 125 | expect(isInNet("abc.def.ghi.jkl", "192.168.1.0", "255.255.255.0")).toBe( 126 | UNKNOWN 127 | ); 128 | expect(isInNet("192.168.1.a", "192.168.1.0", "255.255.255.0")).toBe( 129 | UNKNOWN 130 | ); 131 | }); 132 | 133 | test("should return UNKNOWN for IPs with leading zeros that exceed 255", () => { 134 | // Note: JavaScript parseInt("0256") = 256, so this should be caught 135 | expect(isInNet("0256.168.1.1", "192.168.1.0", "255.255.255.0")).toBe( 136 | UNKNOWN 137 | ); 138 | }); 139 | }); 140 | 141 | describe("edge cases and boundary values", () => { 142 | test("should handle all zeros", () => { 143 | expect(isInNet("0.0.0.0", "0.0.0.0", "255.255.255.255")).toBe(true); 144 | expect(isInNet("0.0.0.1", "0.0.0.0", "255.255.255.0")).toBe(true); 145 | }); 146 | 147 | test("should handle all 255s", () => { 148 | expect( 149 | isInNet("255.255.255.255", "255.255.255.255", "255.255.255.255") 150 | ).toBe(true); 151 | expect(isInNet("255.255.255.254", "255.255.255.0", "255.255.255.0")).toBe( 152 | true 153 | ); 154 | }); 155 | 156 | test("should handle boundary values in subnet", () => { 157 | // First IP in subnet 158 | expect(isInNet("192.168.1.0", "192.168.1.0", "255.255.255.0")).toBe(true); 159 | // Last IP in subnet 160 | expect(isInNet("192.168.1.255", "192.168.1.0", "255.255.255.0")).toBe( 161 | true 162 | ); 163 | // IP just before subnet 164 | expect(isInNet("192.168.0.255", "192.168.1.0", "255.255.255.0")).toBe( 165 | false 166 | ); 167 | // IP just after subnet 168 | expect(isInNet("192.168.2.0", "192.168.1.0", "255.255.255.0")).toBe( 169 | false 170 | ); 171 | }); 172 | 173 | test("should handle single octet subnet masks", () => { 174 | // /24 mask 175 | expect(isInNet("192.168.1.50", "192.168.1.0", "255.255.255.0")).toBe( 176 | true 177 | ); 178 | // /16 mask 179 | expect(isInNet("192.168.50.100", "192.168.0.0", "255.255.0.0")).toBe( 180 | true 181 | ); 182 | // /8 mask 183 | expect(isInNet("192.50.100.200", "192.0.0.0", "255.0.0.0")).toBe(true); 184 | }); 185 | 186 | test("should handle non-standard subnet masks", () => { 187 | // /25 subnet (255.255.255.128) 188 | expect(isInNet("192.168.1.50", "192.168.1.0", "255.255.255.128")).toBe( 189 | true 190 | ); 191 | expect(isInNet("192.168.1.200", "192.168.1.0", "255.255.255.128")).toBe( 192 | false 193 | ); 194 | // /17 subnet (255.255.128.0) 195 | expect(isInNet("192.168.50.100", "192.168.0.0", "255.255.128.0")).toBe( 196 | true 197 | ); 198 | expect(isInNet("192.168.200.100", "192.168.0.0", "255.255.128.0")).toBe( 199 | false 200 | ); 201 | }); 202 | }); 203 | 204 | describe("real-world scenarios", () => { 205 | test("should match private network ranges", () => { 206 | // 10.0.0.0/8 207 | expect(isInNet("10.1.2.3", "10.0.0.0", "255.0.0.0")).toBe(true); 208 | // 172.16.0.0/12 209 | expect(isInNet("172.16.1.1", "172.16.0.0", "255.240.0.0")).toBe(true); 210 | expect(isInNet("172.31.255.255", "172.16.0.0", "255.240.0.0")).toBe(true); 211 | expect(isInNet("172.32.1.1", "172.16.0.0", "255.240.0.0")).toBe(false); 212 | // 192.168.0.0/16 213 | expect(isInNet("192.168.1.1", "192.168.0.0", "255.255.0.0")).toBe(true); 214 | }); 215 | 216 | test("should handle loopback addresses", () => { 217 | expect(isInNet("127.0.0.1", "127.0.0.0", "255.0.0.0")).toBe(true); 218 | expect(isInNet("127.255.255.255", "127.0.0.0", "255.0.0.0")).toBe(true); 219 | }); 220 | 221 | test("should handle multicast addresses", () => { 222 | // 224.0.0.0/4 223 | expect(isInNet("224.0.0.1", "224.0.0.0", "240.0.0.0")).toBe(true); 224 | expect(isInNet("239.255.255.255", "224.0.0.0", "240.0.0.0")).toBe(true); 225 | }); 226 | }); 227 | 228 | describe("mask validation", () => { 229 | test("should work with valid masks", () => { 230 | expect(isInNet("192.168.1.1", "192.168.1.0", "255.255.255.0")).toBe(true); 231 | expect(isInNet("192.168.1.1", "192.168.1.0", "255.255.0.0")).toBe(true); 232 | expect(isInNet("192.168.1.1", "192.168.1.0", "255.0.0.0")).toBe(true); 233 | }); 234 | 235 | test("should handle invalid mask format (but function doesn't validate mask)", () => { 236 | // Note: The function doesn't validate the mask format, 237 | // it just uses it in the bitwise operation 238 | // Invalid masks get converted to 0 by convert_addr, which matches everything 239 | // This test documents the current behavior 240 | expect(isInNet("192.168.1.1", "192.168.1.0", "invalid")).toBe(true); // Invalid mask becomes 0, which matches all IPs 241 | }); 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /tests/services/proxy/profile2config.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from "vitest"; 2 | import { 3 | ProxyProfile, 4 | SystemProfile, 5 | ProfileAutoSwitch, 6 | } from "@/services/profile"; 7 | import { ProfileConverter } from "@/services/proxy/profile2config"; 8 | 9 | const profiles: Record = { 10 | simpleProxy: { 11 | profileID: "simpleProxy", 12 | color: "", 13 | profileName: "", 14 | proxyType: "proxy", 15 | proxyRules: { 16 | default: { 17 | scheme: "http", 18 | host: "127.0.0.1", 19 | port: 8080, 20 | }, 21 | https: { 22 | scheme: "direct", 23 | host: "", 24 | }, 25 | bypassList: [ 26 | "", 27 | "127.0.0.1", 28 | "192.168.0.1/16", 29 | "[::1]", 30 | "fefe:13::abc/33", 31 | ], 32 | }, 33 | pacScript: {}, 34 | }, 35 | 36 | pacProxy: { 37 | profileID: "pacProxy", 38 | color: "", 39 | profileName: "", 40 | proxyType: "pac", 41 | proxyRules: { 42 | default: { 43 | scheme: "http", 44 | host: "", 45 | }, 46 | bypassList: [], 47 | }, 48 | pacScript: { 49 | data: "function FindProxyForURL(url, host) { return 'DIRECT'; }", 50 | }, 51 | }, 52 | 53 | autoProxy: { 54 | profileID: "autoProxy", 55 | color: "", 56 | profileName: "", 57 | proxyType: "auto", 58 | rules: [ 59 | { 60 | type: "domain", 61 | condition: "*.example.com", 62 | profileID: "simpleProxy", 63 | }, 64 | { 65 | type: "url", 66 | condition: "http://example.com/api/*", 67 | profileID: "pacProxy", 68 | }, 69 | { 70 | type: "cidr", 71 | condition: "192.168.10.1/24", 72 | profileID: "simpleProxy", 73 | }, 74 | { 75 | type: "domain", 76 | condition: "*.404.com", 77 | profileID: "non-exists", 78 | }, 79 | ], 80 | defaultProfileID: "direct", 81 | }, 82 | 83 | direct: { 84 | profileID: "direct", 85 | color: "", 86 | profileName: "", 87 | proxyType: "direct", 88 | }, 89 | 90 | autoProxy2: { 91 | profileID: "autoProxy2", 92 | color: "", 93 | profileName: "", 94 | proxyType: "auto", 95 | rules: [ 96 | { 97 | type: "domain", 98 | condition: "*.example.com", 99 | profileID: "autoProxy", 100 | }, 101 | ], 102 | defaultProfileID: "direct", 103 | }, 104 | }; 105 | 106 | describe("testing generating ProxyConfig for direct and system", () => { 107 | test("proxy config mode", async () => { 108 | const profile = new ProfileConverter(SystemProfile.DIRECT); 109 | const cfg = await profile.toProxyConfig(); 110 | expect(cfg.mode).toBe("direct"); 111 | }); 112 | 113 | test("proxy config mode for others", async () => { 114 | const profile = new ProfileConverter(profiles.simpleProxy); 115 | const cfg = await profile.toProxyConfig(); 116 | expect(cfg.mode).toBe("pac_script"); 117 | }); 118 | }); 119 | 120 | describe("testing bypass list", () => { 121 | test("bypass list with ipv6", async () => { 122 | const profile = new ProfileConverter(profiles.simpleProxy); 123 | const cfg = await profile.toProxyConfig(); 124 | expect(cfg.pacScript?.data).toMatch( 125 | /.*?isInNet\(host, '192\.168\.0\.1', '255\.255\.0\.0'\).*?/ 126 | ); 127 | expect(cfg.pacScript?.data).toMatch( 128 | /.*?isInNet\(host, 'fefe:13::abc', 'ffff:ffff:8000:0:0:0:0:0'\).*?/ 129 | ); 130 | }); 131 | }); 132 | 133 | describe("testing auto switch profile", () => { 134 | test("auto switch profile", async () => { 135 | const profile = new ProfileConverter(profiles.autoProxy, async (id) => { 136 | return profiles[id]; 137 | }); 138 | const cfg = await profile.toProxyConfig(); 139 | expect(cfg.mode).toBe("pac_script"); 140 | 141 | expect(cfg.pacScript?.data).toContain(` 142 | register('pacProxy', function () { 143 | function FindProxyForURL(url, host) { 144 | return 'DIRECT'; 145 | } 146 | return FindProxyForURL; 147 | }());`); 148 | 149 | expect(cfg.pacScript?.data).toContain(` 150 | if (isInNet(host, '192.168.10.1', '255.255.255.0')) { 151 | return profiles['simpleProxy'](url, host); 152 | }`); 153 | 154 | expect(cfg.pacScript?.data).toContain( 155 | `alert('Profile non-exists not found, skipped');` 156 | ); 157 | expect(cfg.pacScript?.data).toContain( 158 | `return profiles['direct'](url, host);` 159 | ); 160 | }); 161 | test("nested auto switch profile", async () => { 162 | const profile = new ProfileConverter(profiles.autoProxy2, async (id) => { 163 | return profiles[id]; 164 | }); 165 | const cfg = await profile.toProxyConfig(); 166 | expect(cfg.mode).toBe("pac_script"); 167 | 168 | expect(cfg.pacScript?.data).toContain(` 169 | if (shExpMatch(host, '*.example.com')) { 170 | return profiles['autoProxy'](url, host); 171 | }`); 172 | }); 173 | }); 174 | 175 | describe("testing findProfile function", () => { 176 | const profileLoader = async (id: string) => profiles[id]; 177 | 178 | test("simple profiles return themselves", async () => { 179 | const url = new URL("https://example.com"); 180 | 181 | const direct = new ProfileConverter(SystemProfile.DIRECT); 182 | expect((await direct.findProfile(url)).profile).toBe(direct); 183 | expect((await direct.findProfile(url)).isConfident).toBe(true); 184 | 185 | const system = new ProfileConverter(SystemProfile.SYSTEM); 186 | expect((await system.findProfile(url)).profile).toBe(system); 187 | expect((await system.findProfile(url)).isConfident).toBe(true); 188 | 189 | const pac = new ProfileConverter(profiles.pacProxy); 190 | expect((await pac.findProfile(url)).profile).toBe(pac); 191 | expect((await pac.findProfile(url)).isConfident).toBe(true); 192 | }); 193 | 194 | test("auto profile matches rules and falls back to default", async () => { 195 | const profile = new ProfileConverter(profiles.autoProxy, profileLoader); 196 | 197 | // Domain rule match 198 | expect( 199 | (await profile.findProfile(new URL("https://test.example.com"))).profile 200 | ).toBeDefined(); 201 | 202 | // URL rule match 203 | expect( 204 | (await profile.findProfile(new URL("http://example.com/api/v1/users"))) 205 | .profile 206 | ).toBeDefined(); 207 | 208 | // CIDR rule match 209 | expect( 210 | (await profile.findProfile(new URL("http://192.168.10.50"))).profile 211 | ).toBeDefined(); 212 | 213 | // No match - falls back to default 214 | expect( 215 | (await profile.findProfile(new URL("https://other.com"))).profile 216 | ).toBeDefined(); 217 | }); 218 | 219 | test("auto profile handles edge cases", async () => { 220 | const profile = new ProfileConverter(profiles.autoProxy, profileLoader); 221 | 222 | // CIDR with hostname (non-IP) - non-confident 223 | const hostnameResult = await profile.findProfile( 224 | new URL("http://example.com") 225 | ); 226 | expect(hostnameResult.profile).toBeDefined(); 227 | expect(hostnameResult.isConfident).toBe(false); 228 | 229 | // Missing profile in rule - skips and uses default 230 | expect( 231 | (await profile.findProfile(new URL("https://test.404.com"))).profile 232 | ).toBeDefined(); 233 | 234 | // Disabled rule - skipped 235 | const autoWithDisabled: ProfileAutoSwitch = { 236 | profileID: "autoDisabled", 237 | color: "", 238 | profileName: "", 239 | proxyType: "auto", 240 | rules: [ 241 | { 242 | type: "disabled", 243 | condition: "*.example.com", 244 | profileID: "simpleProxy", 245 | }, 246 | { type: "domain", condition: "*.test.com", profileID: "simpleProxy" }, 247 | ], 248 | defaultProfileID: "direct", 249 | }; 250 | const disabledProfile = new ProfileConverter( 251 | autoWithDisabled, 252 | profileLoader 253 | ); 254 | expect( 255 | (await disabledProfile.findProfile(new URL("https://test.example.com"))) 256 | .profile 257 | ).toBeDefined(); 258 | 259 | // Missing default profile - falls back to DIRECT 260 | const autoMissingDefault: ProfileAutoSwitch = { 261 | profileID: "autoMissingDefault", 262 | color: "", 263 | profileName: "", 264 | proxyType: "auto", 265 | rules: [], 266 | defaultProfileID: "missing-default", 267 | }; 268 | const missingDefaultProfile = new ProfileConverter( 269 | autoMissingDefault, 270 | profileLoader 271 | ); 272 | const result = await missingDefaultProfile.findProfile( 273 | new URL("https://other.com") 274 | ); 275 | expect(result.profile).toBeDefined(); 276 | expect((await result.profile!.toProxyConfig()).mode).toBe("direct"); 277 | }); 278 | 279 | test("nested auto profiles work correctly", async () => { 280 | const profile = new ProfileConverter(profiles.autoProxy2, profileLoader); 281 | const result = await profile.findProfile( 282 | new URL("https://test.example.com") 283 | ); 284 | 285 | expect(result.profile).toBeDefined(); 286 | expect(result.isConfident).toBe(true); 287 | }); 288 | 289 | test("first matching rule wins", async () => { 290 | const autoMultiple: ProfileAutoSwitch = { 291 | profileID: "autoMultiple", 292 | color: "", 293 | profileName: "", 294 | proxyType: "auto", 295 | rules: [ 296 | { 297 | type: "domain", 298 | condition: "*.example.com", 299 | profileID: "simpleProxy", 300 | }, 301 | { type: "domain", condition: "*.example.com", profileID: "pacProxy" }, 302 | ], 303 | defaultProfileID: "direct", 304 | }; 305 | 306 | const profile = new ProfileConverter(autoMultiple, profileLoader); 307 | const result = await profile.findProfile( 308 | new URL("https://test.example.com") 309 | ); 310 | 311 | expect(result.profile).toBeDefined(); 312 | expect(result.isConfident).toBe(true); 313 | }); 314 | }); 315 | -------------------------------------------------------------------------------- /src/components/ProfileConfig.vue: -------------------------------------------------------------------------------- 1 | 257 | 258 | 437 | 438 | 449 | -------------------------------------------------------------------------------- /src/services/proxy/profile2config.ts: -------------------------------------------------------------------------------- 1 | import { generate as generateJS } from "escodegen"; 2 | import type { Program, Statement } from "acorn"; 3 | import { 4 | AutoSwitchRule, 5 | ProfileAutoSwitch, 6 | ProxyProfile, 7 | ProxyServer, 8 | SystemProfile, 9 | } from "../profile"; 10 | import { IPv4, IPv6, isValidCIDR, parseCIDR } from "ipaddr.js"; 11 | import { 12 | newProxyString, 13 | PACScriptHelper, 14 | parsePACScript, 15 | } from "./scriptHelper"; 16 | import { ProxyConfig } from "@/adapters"; 17 | import { isInNet, shExpMatch, UNKNOWN } from "./pacSimulator"; 18 | 19 | export type ProfileLoader = ( 20 | profileID: string 21 | ) => Promise; 22 | 23 | export type ProfileResult = { 24 | profile: ProfileConverter | undefined; 25 | isConfident: boolean; 26 | }; 27 | 28 | export class ProfileConverter { 29 | constructor( 30 | public readonly profile: ProxyProfile, 31 | private profileLoader?: ProfileLoader 32 | ) {} 33 | 34 | async toProxyConfig(): Promise { 35 | switch (this.profile.proxyType) { 36 | case "direct": 37 | case "system": 38 | return { mode: this.profile.proxyType }; 39 | 40 | case "pac": 41 | return { 42 | mode: "pac_script", 43 | pacScript: this.profile.pacScript, 44 | }; 45 | 46 | default: 47 | return { 48 | mode: "pac_script", 49 | pacScript: { 50 | data: await this.toPAC(), 51 | }, 52 | }; 53 | } 54 | } 55 | 56 | async findProfile(url: URL): Promise { 57 | switch (this.profile.proxyType) { 58 | case "auto": 59 | return await this.findProfileForAutoProfile(url); 60 | 61 | default: 62 | return { profile: this, isConfident: true }; 63 | } 64 | } 65 | 66 | /** 67 | * Convert the `auto` profile to a PAC script 68 | * @returns the PAC script 69 | */ 70 | async toPAC() { 71 | const astProgram: Program = { 72 | type: "Program", 73 | sourceType: "script", 74 | body: await this.genStatements(), 75 | start: 0, // dummy 76 | end: 0, // dummy 77 | }; 78 | 79 | return generateJS(astProgram); 80 | } 81 | 82 | /** 83 | * Convert the profile to a closure, which can be used for auto profiles 84 | * (function() { 85 | * // the definition of FindProxyForURL 86 | * return FindProxyForURL; 87 | * })() 88 | * @returns 89 | */ 90 | async toClosure() { 91 | const stmts = await this.genStatements(); 92 | stmts.push( 93 | PACScriptHelper.newReturnStatement( 94 | PACScriptHelper.newIdentifier("FindProxyForURL") 95 | ) 96 | ); 97 | 98 | return PACScriptHelper.newCallExpression( 99 | PACScriptHelper.newFunctionExpression([], stmts), 100 | [] 101 | ); 102 | } 103 | 104 | /** 105 | * genStatements that returns a list of statements, containing the main function `FindProxyForURL` 106 | * @returns 107 | */ 108 | private async genStatements() { 109 | switch (this.profile.proxyType) { 110 | case "system": 111 | case "direct": 112 | case "proxy": 113 | return this.genFindProxyForURLFunction(); 114 | case "pac": 115 | return this.genFindProxyForURLFunctionForPAC(); 116 | case "auto": 117 | return await this.genFindProxyForURLFunctionForAutoProfile(); 118 | } 119 | } 120 | private genFindProxyForURLFunctionForPAC(): Statement[] { 121 | if (this.profile.proxyType != "pac") { 122 | throw new Error("this function should only be called for pac profile"); 123 | } 124 | 125 | if (!this.profile.pacScript.data) { 126 | return []; 127 | } 128 | 129 | return parsePACScript(this.profile.pacScript.data); 130 | } 131 | 132 | /** 133 | * genFindProxyForURLFunction for `ProxySimple` and `ProxyPreset` 134 | * @returns 135 | */ 136 | private genFindProxyForURLFunction(): Statement[] { 137 | const body: Statement[] = []; 138 | 139 | switch (this.profile.proxyType) { 140 | case "direct": 141 | body.push( 142 | PACScriptHelper.newReturnStatement( 143 | PACScriptHelper.newSimpleLiteral("DIRECT") 144 | ) 145 | ); 146 | break; 147 | case "proxy": 148 | body.push( 149 | ...this.genBypassList(), 150 | ...this.genAdvancedRules(), 151 | PACScriptHelper.newReturnStatement( 152 | newProxyString(this.profile.proxyRules.default) 153 | ) 154 | ); 155 | break; 156 | 157 | default: 158 | throw new Error("unexpected proxy type"); 159 | } 160 | 161 | return [ 162 | PACScriptHelper.newFunctionDeclaration( 163 | "FindProxyForURL", 164 | ["url", "host"], 165 | body 166 | ), 167 | ]; 168 | } 169 | 170 | private async genFindProxyForURLFunctionForAutoProfile() { 171 | if (this.profile.proxyType != "auto") { 172 | throw new Error("this function should only be called for auto profile"); 173 | } 174 | 175 | const { stmt, loadedProfiles } = await this.prepareAutoProfilePrecedence( 176 | this.profile 177 | ); 178 | const body: Statement[] = []; 179 | 180 | // rules 181 | this.profile.rules.forEach((rule) => { 182 | switch (rule.type) { 183 | case "disabled": 184 | return; // skipped 185 | default: 186 | if (loadedProfiles.has(rule.profileID)) { 187 | return body.push(this.genAutoProfileRule(rule)); 188 | } 189 | } 190 | 191 | // if a dependent profile is not loaded, skip it, and add some alerts 192 | body.push(this.genAutoProfileMissingProfileAlert(rule.profileID)); 193 | }); 194 | 195 | // default profile 196 | if (loadedProfiles.has(this.profile.defaultProfileID)) { 197 | body.push( 198 | PACScriptHelper.newReturnStatement( 199 | this.genAutoProfileCallExpression(this.profile.defaultProfileID) 200 | ) 201 | ); 202 | } else { 203 | body.push( 204 | this.genAutoProfileMissingProfileAlert(this.profile.defaultProfileID), 205 | PACScriptHelper.newReturnStatement( 206 | PACScriptHelper.newSimpleLiteral("DIRECT") // fallback to direct 207 | ) 208 | ); 209 | } 210 | 211 | stmt.push( 212 | PACScriptHelper.newFunctionDeclaration( 213 | "FindProxyForURL", 214 | ["url", "host"], 215 | body 216 | ) 217 | ); 218 | 219 | return stmt; 220 | } 221 | 222 | private async findProfileForAutoProfile(url: URL): Promise { 223 | if (this.profile.proxyType != "auto") { 224 | throw new Error("this function should only be called for auto profile"); 225 | } 226 | 227 | for (let rule of this.profile.rules) { 228 | if (rule.type == "disabled") { 229 | continue; 230 | } 231 | 232 | const profile = await this.loadProfile(rule.profileID); 233 | if (!profile) { 234 | continue; 235 | } 236 | 237 | const ret = await profile.findProfileForAutoProfileRule( 238 | url, 239 | rule, 240 | profile 241 | ); 242 | if (ret.profile) { 243 | return ret; 244 | } 245 | } 246 | 247 | const defaultProfile = 248 | (await this.loadProfile(this.profile.defaultProfileID)) || 249 | new ProfileConverter(SystemProfile.DIRECT); 250 | 251 | return await defaultProfile.findProfile(url); 252 | } 253 | 254 | private genAutoProfileRule(rule: AutoSwitchRule): Statement { 255 | switch (rule.type) { 256 | case "domain": 257 | return PACScriptHelper.newIfStatement( 258 | PACScriptHelper.newCallExpression( 259 | PACScriptHelper.newIdentifier("shExpMatch"), 260 | [ 261 | PACScriptHelper.newIdentifier("host"), 262 | PACScriptHelper.newSimpleLiteral(rule.condition), 263 | ] 264 | ), 265 | [ 266 | PACScriptHelper.newReturnStatement( 267 | this.genAutoProfileCallExpression(rule.profileID) 268 | ), 269 | ] 270 | ); 271 | 272 | case "url": 273 | return PACScriptHelper.newIfStatement( 274 | PACScriptHelper.newCallExpression( 275 | PACScriptHelper.newIdentifier("shExpMatch"), 276 | [ 277 | PACScriptHelper.newIdentifier("url"), 278 | PACScriptHelper.newSimpleLiteral(rule.condition), 279 | ] 280 | ), 281 | [ 282 | PACScriptHelper.newReturnStatement( 283 | this.genAutoProfileCallExpression(rule.profileID) 284 | ), 285 | ] 286 | ); 287 | 288 | case "cidr": 289 | // if it's a CIDR 290 | if (isValidCIDR(rule.condition)) { 291 | try { 292 | const [ip, maskPrefixLen] = parseCIDR(rule.condition); 293 | let mask = ( 294 | ip.kind() == "ipv4" ? IPv4 : IPv6 295 | ).subnetMaskFromPrefixLength(maskPrefixLen); 296 | 297 | return PACScriptHelper.newIfStatement( 298 | PACScriptHelper.newCallExpression( 299 | PACScriptHelper.newIdentifier("isInNet"), 300 | [ 301 | PACScriptHelper.newIdentifier("host"), 302 | PACScriptHelper.newSimpleLiteral(ip.toString()), 303 | PACScriptHelper.newSimpleLiteral(mask.toNormalizedString()), 304 | ] 305 | ), 306 | [ 307 | PACScriptHelper.newReturnStatement( 308 | this.genAutoProfileCallExpression(rule.profileID) 309 | ), 310 | ] 311 | ); 312 | } catch (e) { 313 | console.error(e); 314 | } 315 | } 316 | } 317 | 318 | return PACScriptHelper.newExpressionStatement( 319 | PACScriptHelper.newCallExpression( 320 | PACScriptHelper.newIdentifier("alert"), 321 | [ 322 | PACScriptHelper.newSimpleLiteral( 323 | `Invalid condition ${rule.type}: ${rule.condition}, skipped` 324 | ), 325 | ] 326 | ) 327 | ); 328 | } 329 | 330 | private async findProfileForAutoProfileRule( 331 | url: URL, 332 | rule: AutoSwitchRule, 333 | profile: ProfileConverter 334 | ): Promise { 335 | switch (rule.type) { 336 | case "domain": 337 | if (shExpMatch(url.hostname, rule.condition)) { 338 | return profile.findProfile(url); 339 | } 340 | 341 | break; 342 | case "url": 343 | if (shExpMatch(url.href, rule.condition)) { 344 | return profile.findProfile(url); 345 | } 346 | 347 | break; 348 | case "cidr": 349 | // if it's a CIDR 350 | if (isValidCIDR(rule.condition)) { 351 | try { 352 | const [ip, maskPrefixLen] = parseCIDR(rule.condition); 353 | let mask = ( 354 | ip.kind() == "ipv4" ? IPv4 : IPv6 355 | ).subnetMaskFromPrefixLength(maskPrefixLen); 356 | 357 | switch ( 358 | isInNet(url.hostname, ip.toString(), mask.toNormalizedString()) 359 | ) { 360 | case true: 361 | return profile.findProfile(url); 362 | case false: 363 | break; // not in the CIDR 364 | case UNKNOWN: 365 | return { profile: profile, isConfident: false }; // unknown 366 | } 367 | } catch (e) { 368 | console.error(e); 369 | } 370 | } 371 | 372 | break; 373 | } 374 | 375 | return { profile: undefined, isConfident: true }; 376 | } 377 | 378 | private genAutoProfileCallExpression(profileID: string) { 379 | return PACScriptHelper.newCallExpression( 380 | PACScriptHelper.newMemberExpression( 381 | PACScriptHelper.newIdentifier("profiles"), 382 | PACScriptHelper.newSimpleLiteral(profileID), 383 | true 384 | ), 385 | [ 386 | PACScriptHelper.newIdentifier("url"), 387 | PACScriptHelper.newIdentifier("host"), 388 | ] 389 | ); 390 | } 391 | 392 | private genAutoProfileMissingProfileAlert(profileID: string) { 393 | return PACScriptHelper.newExpressionStatement( 394 | PACScriptHelper.newCallExpression( 395 | PACScriptHelper.newIdentifier("alert"), 396 | [ 397 | PACScriptHelper.newSimpleLiteral( 398 | `Profile ${profileID} not found, skipped` 399 | ), 400 | ] 401 | ) 402 | ); 403 | } 404 | 405 | private async prepareAutoProfilePrecedence(profile: ProfileAutoSwitch) { 406 | const loadedProfiles = new Set(); 407 | const stmt: Statement[] = [ 408 | // var profiles = profiles || {}; 409 | PACScriptHelper.newVariableDeclaration( 410 | "profiles", 411 | PACScriptHelper.newLogicalExpression( 412 | "||", 413 | PACScriptHelper.newIdentifier("profiles"), 414 | PACScriptHelper.newObjectExpression([]) 415 | ) 416 | ), 417 | 418 | /** 419 | * function register(profileID, funcFindProxyForURL) { 420 | * profiles[profileID] = funcFindProxyForURL; 421 | * } 422 | */ 423 | PACScriptHelper.newFunctionDeclaration( 424 | "register", 425 | ["profileID", "funcFindProxyForURL"], 426 | [ 427 | PACScriptHelper.newExpressionStatement( 428 | PACScriptHelper.newAssignmentExpression( 429 | "=", 430 | PACScriptHelper.newMemberExpression( 431 | PACScriptHelper.newIdentifier("profiles"), 432 | PACScriptHelper.newIdentifier("profileID"), 433 | true 434 | ), 435 | PACScriptHelper.newIdentifier("funcFindProxyForURL") 436 | ) 437 | ), 438 | ] 439 | ), 440 | ]; 441 | 442 | // register all profiles 443 | const profileIDs = [ 444 | profile.defaultProfileID, 445 | ...profile.rules.map((r) => r.profileID), 446 | ]; 447 | for (let profileID of profileIDs) { 448 | if (loadedProfiles.has(profileID)) { 449 | continue; 450 | } 451 | 452 | const profile = await this.loadProfile(profileID); 453 | if (!profile) { 454 | continue; 455 | } 456 | 457 | loadedProfiles.add(profileID); 458 | 459 | stmt.push( 460 | PACScriptHelper.newExpressionStatement( 461 | PACScriptHelper.newCallExpression( 462 | PACScriptHelper.newIdentifier("register"), 463 | [ 464 | PACScriptHelper.newSimpleLiteral(profileID), 465 | await profile.toClosure(), 466 | ] 467 | ) 468 | ) 469 | ); 470 | } 471 | 472 | return { stmt, loadedProfiles }; 473 | } 474 | 475 | private async loadProfile( 476 | profileID: string 477 | ): Promise { 478 | if (!this.profileLoader) { 479 | return; 480 | } 481 | 482 | const profile = await this.profileLoader(profileID); 483 | if (!profile) { 484 | return; 485 | } 486 | 487 | return new ProfileConverter(profile, this.profileLoader); 488 | } 489 | 490 | private genBypassList() { 491 | if (this.profile.proxyType != "proxy") { 492 | throw new Error("Only proxy profile can have bypass list"); 493 | } 494 | 495 | const directExpr = PACScriptHelper.newReturnStatement( 496 | PACScriptHelper.newSimpleLiteral("DIRECT") 497 | ); 498 | return this.profile.proxyRules.bypassList.map((item) => { 499 | if (item == "") { 500 | return PACScriptHelper.newIfStatement( 501 | PACScriptHelper.newCallExpression( 502 | PACScriptHelper.newIdentifier("isPlainHostName"), 503 | [PACScriptHelper.newIdentifier("host")] 504 | ), 505 | [directExpr] 506 | ); 507 | } 508 | 509 | // if it's a CIDR 510 | if (isValidCIDR(item)) { 511 | try { 512 | const [ip, maskPrefixLen] = parseCIDR(item); 513 | let mask = ( 514 | ip.kind() == "ipv4" ? IPv4 : IPv6 515 | ).subnetMaskFromPrefixLength(maskPrefixLen); 516 | 517 | return PACScriptHelper.newIfStatement( 518 | PACScriptHelper.newCallExpression( 519 | PACScriptHelper.newIdentifier("isInNet"), 520 | [ 521 | PACScriptHelper.newIdentifier("host"), 522 | PACScriptHelper.newSimpleLiteral(ip.toString()), 523 | PACScriptHelper.newSimpleLiteral(mask.toNormalizedString()), 524 | ] 525 | ), 526 | [directExpr] 527 | ); 528 | } catch (e) { 529 | console.error(e); 530 | } 531 | } 532 | 533 | return PACScriptHelper.newIfStatement( 534 | PACScriptHelper.newCallExpression( 535 | PACScriptHelper.newIdentifier("shExpMatch"), 536 | [ 537 | PACScriptHelper.newIdentifier("host"), 538 | PACScriptHelper.newSimpleLiteral(item), 539 | ] 540 | ), 541 | [directExpr] 542 | ); 543 | }); 544 | } 545 | 546 | private genAdvancedRules() { 547 | if (this.profile.proxyType != "proxy") { 548 | throw new Error("Only proxy profile can have bypass list"); 549 | } 550 | 551 | const ret = []; 552 | 553 | type KeyVal = "ftp" | "https" | "http"; 554 | const keys: KeyVal[] = ["ftp", "https", "http"]; 555 | const rules = this.profile.proxyRules as Record< 556 | KeyVal, 557 | ProxyServer | undefined 558 | >; 559 | 560 | for (let item of keys) { 561 | const cfg = rules[item]; 562 | if (!cfg) { 563 | continue; 564 | } 565 | 566 | ret.push( 567 | PACScriptHelper.newIfStatement( 568 | PACScriptHelper.newCallExpression( 569 | PACScriptHelper.newMemberExpression( 570 | PACScriptHelper.newIdentifier("url"), 571 | PACScriptHelper.newIdentifier("startsWith") 572 | ), 573 | [PACScriptHelper.newSimpleLiteral(`${item}:`)] 574 | ), 575 | [PACScriptHelper.newReturnStatement(newProxyString(cfg))] 576 | ) 577 | ); 578 | } 579 | return ret; 580 | } 581 | } 582 | --------------------------------------------------------------------------------