diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..298d848 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What is Proxyverse? + +A Manifest V3 browser extension (Chrome, Edge, Firefox) for proxy profile management with auto-switch rules and PAC script support. It is an alternative to Proxy SwitchyOmega. + +## Build and Test Commands + +```bash +npm run build # Type-check (vue-tsc) + build for Chrome/Edge +npm run build:firefox # Type-check + build for Firefox (transforms manifest) +npm run build:test # Build in test mode (no Sentry, no visualizer) +npm run dev # Vite dev server +npm test # Run vitest in watch mode +npm run coverage # Single run with coverage report +npx vitest run tests/services/proxy/profile2config.test.ts # Run a single test file +``` + +CI runs `npm run coverage` on PRs to main/develop; `npm run build` + `npm run build:firefox` on pushes and tags. + +## Architecture + +### Three build entry points + +The Vite config defines three entry points that produce the extension bundle: +- **`index.html`** / **`popup.html`** -- Both load `src/main.ts` (Vue app). The Vue router uses hash history: `#/popup` renders `PopupPage`, `#/` renders `ConfigPage` with nested profile/preference routes. +- **`src/background.ts`** -- Service worker. Wires up proxy auth, request stats, and badge indicator. No Vue, no DOM. + +### Browser adapter layer (`src/adapters/`) + +`BaseAdapter` defines the abstract contract for all browser APIs (storage, proxy, webRequest, tabs, i18n). Concrete implementations: `Chrome`, `Firefox`, `WebBrowser` (dev stub). A singleton `Host` is auto-detected at import time and used everywhere. This is the only layer that touches `chrome.*` or `browser.*` APIs directly. + +### Proxy engine (`src/services/proxy/`) + +The core complexity lives here: +- **`profile2config.ts`** -- `ProfileConverter` turns a `ProxyProfile` into a `ProxyConfig` (for `chrome.proxy.settings`). For non-PAC profiles and auto-switch profiles, it **generates PAC scripts via AST** using `escodegen`/`acorn` node builders in `scriptHelper.ts`. Auto-switch profiles compose multiple sub-profiles by generating `register()` calls that build a lookup table. +- **`pacSimulator.ts`** -- JS reimplementations of PAC functions (`shExpMatch`, `isInNet`) used to simulate PAC evaluation in-extension (e.g., for tab badge resolution). `isInNet` returns `UNKNOWN` when given a hostname instead of an IP (can't do DNS in extension context). +- **`auth.ts`** -- Resolves proxy auth credentials by walking the profile tree (auto-switch profiles delegate to sub-profiles). + +### Profile system (`src/services/profile.ts`) + +Profiles are stored in `chrome.storage.local` under key `"profiles"`. Types: `ProfileSimple` (proxy/pac), `ProfilePreset` (system/direct), `ProfileAutoSwitch` (rule-based routing). System profiles `DIRECT` and `SYSTEM` have fixed IDs `"direct"` and `"system"`. + +### Config import/export (`src/services/config/schema/`) + +Schema definitions for importing/exporting profile configurations using `io-ts` for runtime type validation. + +## Critical Gotcha: `deepClone()` must use JSON round-trip + +`deepClone()` in `src/services/utils.ts` uses `JSON.parse(JSON.stringify(obj))`. **Do not replace with `structuredClone()`** -- Vue's reactive Proxy objects throw `DataCloneError` under `structuredClone()`. The JSON round-trip serializes through Vue's Proxy traps and produces plain objects safe for `chrome.storage`. + +## Firefox build differences + +The `vite.config.ts` `TRANSFORMER_CONFIG` rewrites `manifest.json` at build time for Firefox: +- Replaces `background.service_worker` with `background.scripts` array +- Removes `version_name` +- Adds `browser_specific_settings.gecko` + +## Path alias + +`@/` maps to `src/` (configured in both `vite.config.ts` and `tsconfig.json`). + +## i18n + +Translation strings live in `public/_locales/{locale}/messages.json`. Translations are managed via Transifex. diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 66ceb1c..b7faed9 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -100,7 +100,7 @@ "message": "Bypass List" }, "config_section_advance": { - "message": "Advance Config" + "message": "Advanced Config" }, "config_reference_bypass_list": { "message": "Learn more about bypass list" diff --git a/src/background.ts b/src/background.ts index 84d6941..add7f02 100644 --- a/src/background.ts +++ b/src/background.ts @@ -34,7 +34,7 @@ class ProxyAuthProvider { static requests: Record = {}; static onCompleted( - details: WebResponseDetails | WebRequestErrorOccurredDetails + details: WebResponseDetails | WebRequestErrorOccurredDetails, ) { if (ProxyAuthProvider.requests[details.requestId]) { delete ProxyAuthProvider.requests[details.requestId]; @@ -43,7 +43,7 @@ class ProxyAuthProvider { static onAuthRequired( details: WebAuthenticationChallengeDetails, - asyncCallback?: (response: BlockingResponse) => void + asyncCallback?: (response: BlockingResponse) => void, ): BlockingResponse | undefined { if (!details.isProxy) { asyncCallback && asyncCallback({}); @@ -60,10 +60,10 @@ class ProxyAuthProvider { getAuthInfos(details.challenger.host, details.challenger.port).then( (authInfos) => { const auth = authInfos.at( - ProxyAuthProvider.requests[details.requestId] + ProxyAuthProvider.requests[details.requestId], ); if (!auth) { - asyncCallback && asyncCallback({ cancel: true }); + asyncCallback && asyncCallback({}); return; } @@ -75,7 +75,7 @@ class ProxyAuthProvider { }, }); return; - } + }, ); } } @@ -96,12 +96,14 @@ class StatsProvider { // this.stats.addFailedRequest(details); // TODO: update indicator const proxySetting = await getCurrentProxySetting(); - console.log("onResponseStarted", details); if (details.tabId > 0 && proxySetting.activeProfile) { - const ret = await findProfile( - proxySetting.activeProfile, - new URL(details.url) - ); + let parsedUrl: URL; + try { + parsedUrl = new URL(details.url); + } catch { + return; + } + const ret = await findProfile(proxySetting.activeProfile, parsedUrl); StatsProvider.stats.setCurrentProfile(details.tabId, ret); diff --git a/src/components/configs/AutoSwitchInput.vue b/src/components/configs/AutoSwitchInput.vue index 4f1be10..214412c 100644 --- a/src/components/configs/AutoSwitchInput.vue +++ b/src/components/configs/AutoSwitchInput.vue @@ -102,7 +102,6 @@ const getConditionInputRule = (type: AutoSwitchType): FieldRule => { case "url": return { validator: async (value: string, cb: (message?: string) => void) => { - console.log("test"); let u; try { u = new URL(value || ""); diff --git a/src/components/controls/ThemeSwitcher.vue b/src/components/controls/ThemeSwitcher.vue index 5debbea..d96dbb9 100644 --- a/src/components/controls/ThemeSwitcher.vue +++ b/src/components/controls/ThemeSwitcher.vue @@ -28,7 +28,6 @@ const onDarkModeChanged = (newMode: DarkMode) => { }; const toggleDarkMode = async () => { - console.log(await currentDarkMode()); switch (await currentDarkMode()) { case DarkMode.Dark: onDarkModeChanged(DarkMode.Light); diff --git a/src/pages/PopupPage.vue b/src/pages/PopupPage.vue index 25d8a1b..d5ab199 100644 --- a/src/pages/PopupPage.vue +++ b/src/pages/PopupPage.vue @@ -40,14 +40,12 @@ onMounted(async () => { const jumpTo = (to: RouteLocationRaw) => { const path = router.resolve(to).fullPath; - window.open(`/index.html#${path}`, import.meta.url); - // window.open(router.resolve(to).href, import.meta.url) + window.open(`/index.html#${path}`, "_blank"); }; // actions const setProxyByProfile = async (val: ProxyProfile) => { try { - console.log(toRaw(val)); await setProxy(toRaw(val)); activeProfile.value = toRaw(val); } catch (e: any) { diff --git a/src/services/preference.ts b/src/services/preference.ts index db5928a..5dea90b 100644 --- a/src/services/preference.ts +++ b/src/services/preference.ts @@ -53,10 +53,10 @@ export async function changeDarkMode(newMode: DarkMode) { switch (newMode) { case DarkMode.Dark: - document && document.body.setAttribute("arco-theme", "dark"); + document?.body?.setAttribute("arco-theme", "dark"); break; case DarkMode.Light: - document && document.body.removeAttribute("arco-theme"); + document?.body?.removeAttribute("arco-theme"); break; } } diff --git a/src/services/profile.ts b/src/services/profile.ts index f057b4b..d3de6c5 100644 --- a/src/services/profile.ts +++ b/src/services/profile.ts @@ -107,7 +107,7 @@ async function overwriteProfiles(profiles: ProfilesStorage) { // Deep clone to remove any Proxy objects before saving const clonedProfiles = deepClone(profiles); await Host.set(keyProfileStorage, clonedProfiles); - onProfileUpdateListeners.map((cb) => cb(profiles)); + onProfileUpdateListeners.forEach((cb) => cb(profiles)); } /** diff --git a/src/services/proxy/auth.ts b/src/services/proxy/auth.ts index fcb7bc0..cfa29a3 100644 --- a/src/services/proxy/auth.ts +++ b/src/services/proxy/auth.ts @@ -50,7 +50,7 @@ export class ProfileAuthProvider { ]; // check if there's any matching host and port - auths.map((item) => { + auths.forEach((item) => { if (!item) return; if ( diff --git a/src/services/proxy/index.ts b/src/services/proxy/index.ts index 9da5c75..f194c7b 100644 --- a/src/services/proxy/index.ts +++ b/src/services/proxy/index.ts @@ -75,7 +75,7 @@ export async function refreshProxy() { const newProfile = await getProfile(current.activeProfile.profileID); // if it's preset profiles, then do nothing - if (!newProfile || current.activeProfile.proxyType in ["system", "direct"]) { + if (!newProfile || ["system", "direct"].includes(current.activeProfile.proxyType)) { return; } diff --git a/src/services/utils.ts b/src/services/utils.ts index fbe4b8c..d060598 100644 --- a/src/services/utils.ts +++ b/src/services/utils.ts @@ -2,6 +2,11 @@ * Deep clone an object to remove all Proxy objects (e.g., from Vue reactivity). * This is necessary because chrome.storage and browser.storage use structured clone * which cannot clone Proxy objects. + * + * Note: structuredClone() is NOT used here intentionally — it throws a DataCloneError + * on JavaScript Proxy objects (including Vue reactive/ref wrappers). JSON round-trip + * serializes through the Proxy traps and produces a plain object, which is exactly + * what chrome.storage requires. */ export function deepClone(obj: T): T { return JSON.parse(JSON.stringify(obj));