From d0c5fc5679e36f8951fbe20110708486b764a62f Mon Sep 17 00:00:00 2001 From: wt Date: Fri, 9 Oct 2026 16:17:33 +0700 Subject: [PATCH] feat: add cross-browser builds and refresh project documentation - add Chrome/Chromium Manifest V3 build with PAC proxy routing - keep Firefox Manifest V2 and Android compatibility - add Chromium API adapter and service worker - add Chrome build script and Makefile target - update README and add AGENT.md - document installation, signing, permissions, themes, and platform limitations --- AGENT.md | 17 +++++- Makefile | 3 ++ README.md | 23 ++++++-- background-chrome.js | 102 +++++++++++++++++++++++++++++++++++ background-service-worker.js | 1 + browser-api.js | 31 +++++++++++ config-manager.js | 8 +-- manifest.chrome.json | 36 +++++++++++++ manifest.json | 7 +-- options.html | 1 + popup.html | 1 + scripts/build-chrome.py | 40 ++++++++++++++ 12 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 background-chrome.js create mode 100644 background-service-worker.js create mode 100644 browser-api.js create mode 100644 manifest.chrome.json create mode 100644 scripts/build-chrome.py diff --git a/AGENT.md b/AGENT.md index 7f14672..1c03228 100644 --- a/AGENT.md +++ b/AGENT.md @@ -6,8 +6,11 @@ This repository contains the Firefox WebExtension **Proxy Switcher Fork By WT**. ## Source layout -- `manifest.json` — extension metadata, permissions, icons, popup, options page, and Firefox/Android compatibility. +- `manifest.json` — Firefox Manifest V2 metadata, permissions, icons, popup, options page, and Firefox/Android compatibility. +- `manifest.chrome.json` — Chrome/Chromium Manifest V3 metadata. - `background.js` — proxy routing, proxy authentication, tab-domain tracking, and runtime message handling. +- `background-chrome.js` and `background-service-worker.js` — Chrome/Chromium PAC routing and Manifest V3 service worker. +- `browser-api.js` — Promise-based API adapter used by shared popup/options pages on Chromium. - `config-manager.js` — local configuration storage and update API. - `popup.html`, `popup.js`, `popup.css` — current-domain quick controls. - `options.html`, `options.js`, `options.css` — profiles, domains, management actions, and Advanced Settings. @@ -17,7 +20,9 @@ This repository contains the Firefox WebExtension **Proxy Switcher Fork By WT**. ## Development rules - Keep all configuration local. Do not add telemetry, remote requests, analytics, or external script dependencies. -- Preserve the `browser.*` WebExtension API style and Manifest V2 compatibility unless the task explicitly requests a migration. +- Preserve the `browser.*` WebExtension API style in shared UI code. Firefox uses Manifest V2 and `proxy.onRequest`; Chromium uses Manifest V3, `chrome.proxy.settings`, and a PAC script. +- Keep the Firefox Android minimum version at 113 or newer when using the `gecko_android` manifest key. +- Keep the Firefox and Chromium manifests as separate build targets. Do not replace the Firefox proxy listener with the Chrome PAC implementation. - Proxy settings are domain-specific. A missing domain entry means direct connection; an entry with `enabled: false` is configured but disabled. - Keep popup and options theme behavior consistent. Supported theme values are `system`, `light`, and `dark`. - When changing an icon, update every size referenced by the manifest and verify the XPI contains the new paths. @@ -43,3 +48,11 @@ make build ``` The resulting XPI is written to `web-ext-artifacts/`. It is unsigned when produced by `web-ext build`; Android installation requires a package signed by Mozilla/AMO. + +Build the Chrome/Chromium package with: + +```sh +make build-chrome +``` + +The resulting ZIP is written to `web-ext-artifacts/proxyswitcherforkbywt-chrome.zip`. Chrome Android and iOS do not support ordinary extension installation. diff --git a/Makefile b/Makefile index c89ff7a..44693b9 100644 --- a/Makefile +++ b/Makefile @@ -3,5 +3,8 @@ default: build build: web-ext build --overwrite-dest --filename proxyswitcherforkbywt.xpi +build-chrome: + python scripts/build-chrome.py + clean: rm -rvf web-ext-artifacts diff --git a/README.md b/README.md index b72cf1c..3c95156 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ Proxy Switcher is a Firefox WebExtension that routes requests for selected domai - Choose a **System**, **Light**, or **Dark** theme in Advanced Settings. - Use the same theme in the options page and popup. - Run on Firefox desktop and Firefox Android versions that support the declared minimum version. +- Build a separate Manifest V3 package for Chrome and Chromium-based desktop browsers. ## How it works -The background script listens to `browser.proxy.onRequest`. It extracts the request's origin domain and returns the matching proxy configuration. Domains without a configuration, and configured domains whose `enabled` value is `false`, use a direct connection. Proxy authentication is supplied only when the matching domain has credentials. +On Firefox, the background script listens to `browser.proxy.onRequest`. On Chrome and Chromium, the background service worker generates a PAC script through `chrome.proxy.settings`. Both implementations extract the request host and route only enabled matching domains; all other domains use a direct connection. Proxy authentication is supplied only when the matching domain has credentials. The extension does not collect browsing history or send configuration to a server. Domain entries, profiles, theme selection, and credentials are saved with `browser.storage.local`. The extension does not implement an additional encryption layer, so protect the Firefox profile and device that contain the data. @@ -47,7 +48,7 @@ The manifest requests these permissions: - `webRequest` and `webRequestBlocking` — provide proxy authentication credentials. - `` — apply the domain routing policy to web requests. -These permissions are used for proxy management. The manifest declares `data_collection_permissions.required` as `none`. +These permissions are used for proxy management. The extension does not collect or transmit data. ## Installation @@ -65,7 +66,21 @@ For a normal install, use an XPI signed by Mozilla/AMO. Firefox may reject an un Firefox Android requires a signed extension package. Submit the build through [addons.mozilla.org/developers](https://addons.mozilla.org/developers/) and download the signed XPI after review, or follow Mozilla's [self-distributed extension instructions](https://extensionworkshop.com/documentation/publish/install-self-distributed/). The manifest includes `browser_specific_settings.gecko_android` so AMO can mark the extension as Android-compatible. -The `proxy.onRequest` API is supported on Firefox Android, but the extension still needs to be tested on the target Firefox version and device. +The manifest targets Firefox Android 113 or newer. The `proxy.onRequest` API is supported there, but the extension still needs to be tested on the target Firefox version and device. + +### Chrome and Chromium desktop + +Chrome and Chromium do not use the Firefox proxy API, so the project includes a separate Manifest V3 package. It works on desktop Chrome, Chromium, Edge, Brave, Opera, and other Chromium browsers that expose the `chrome.proxy` API. Chrome Android and Chrome iOS do not support installing ordinary extensions; this project cannot enable extensions on those platforms. + +Build and install the unpacked package: + +```sh +make build-chrome +``` + +Then open `chrome://extensions` (or the equivalent page), enable **Developer mode**, and select **Load unpacked** using a directory containing the Chrome build. The ZIP at `web-ext-artifacts/proxyswitcherforkbywt-chrome.zip` can be uploaded to the Chrome Web Store or unpacked for local testing. + +The Chrome build uses a PAC script because Chrome's proxy API is global rather than Firefox's per-request `proxy.onRequest` API. Changing a proxy configuration regenerates that PAC script. Chrome proxy credentials are provided through `webRequest.onAuthRequired`. ## Build @@ -77,6 +92,8 @@ make build The package is created at `web-ext-artifacts/proxyswitcherforkbywt.xpi`. This local build is not an AMO signature. After changing any file, rebuild and sign again; do not reuse old `META-INF` signature files. +For Chrome/Chromium, run `make build-chrome`. That creates `web-ext-artifacts/proxyswitcherforkbywt-chrome.zip` from `manifest.chrome.json` and the shared UI files. + To remove generated packages: ```sh diff --git a/background-chrome.js b/background-chrome.js new file mode 100644 index 0000000..8153fbd --- /dev/null +++ b/background-chrome.js @@ -0,0 +1,102 @@ +// Chromium implementation. Chrome's proxy API uses a PAC script rather than +// Firefox's per-request proxy.onRequest listener. +let config = configManager.getConfig(); + +async function init() { + await configManager.load(); + config = configManager.getConfig(); + await applyProxyConfig(); + configManager.addListener(() => { + config = configManager.getConfig(); + applyProxyConfig().catch(error => console.error('Failed to apply proxy config:', error)); + }); +} + +function escapePacString(value) { + return JSON.stringify(String(value)); +} + +function buildPacScript() { + const rules = {}; + Object.entries(config.domains || {}).forEach(([domain, entry]) => { + if (!entry || !entry.enabled || !entry.host || !entry.port) return; + const scheme = entry.type === 'socks' ? 'SOCKS5' : 'PROXY'; + rules[domain] = `${scheme} ${entry.host}:${parseInt(entry.port, 10)}`; + }); + + return `function FindProxyForURL(url, host) { + var rules = ${JSON.stringify(rules)}; + return Object.prototype.hasOwnProperty.call(rules, host) ? rules[host] : "DIRECT"; + }`; +} + +async function applyProxyConfig() { + const hasRules = Object.values(config.domains || {}).some(entry => + entry && entry.enabled && entry.host && entry.port + ); + + return chrome.proxy.settings.set({ + value: hasRules + ? { mode: 'pac_script', pacScript: { data: buildPacScript(), mandatory: true } } + : { mode: 'direct' }, + scope: 'regular' + }); +} + +function getDomain(url) { + try { return new URL(url).hostname.replace(/^www\./, ''); } catch (_) { return null; } +} + +function sendConfigResponse(sendResponse, action) { + if (action === 'getConfig') sendResponse({ config: configManager.getConfig() }); + else if (action === 'getProfiles') sendResponse({ profiles: configManager.getProfiles() }); + else return false; + return true; +} + +chrome.webRequest.onAuthRequired.addListener( + (details, sendResponse) => { + if (!details.isProxy) return sendResponse({}); + const entry = config.domains && config.domains[getDomain(details.url)]; + if (!entry || !entry.enabled || !entry.username || !entry.password) return sendResponse({}); + sendResponse({ authCredentials: { username: entry.username, password: entry.password } }); + }, + { urls: [''] }, + ['blocking'] +); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + const direct = sendConfigResponse(sendResponse, message.action); + if (direct) return true; + + const asyncActions = { + updateDomain: () => configManager.updateDomain(message.domain, message.domainConfig), + removeDomain: () => configManager.removeDomain(message.domain), + updateProfile: () => configManager.updateProfile(message.profileName, message.profileConfig), + removeProfile: () => configManager.removeProfile(message.profileName), + updateSettings: () => configManager.updateSettings(message.settings || {}), + reloadConfig: () => configManager.reload(), + clearAllDomains: () => { configManager.config.domains = {}; return configManager.save(); }, + clearAllProfiles: () => { configManager.config.profiles = {}; return configManager.save(); }, + clearEverything: () => { configManager.config.domains = {}; configManager.config.profiles = {}; return configManager.save(); } + }; + + if (message.action === 'getProfile') { + sendResponse({ profile: configManager.getProfileConfig(message.profileName) }); + return false; + } + if (message.action === 'toggleProxy') { + const entry = configManager.getDomainConfig(message.domain); + if (!entry) { sendResponse({ success: false, error: 'Domain not configured' }); return false; } + entry.enabled = message.enabled; + asyncActions.updateDomain = () => configManager.updateDomain(message.domain, entry); + } + const operation = asyncActions[message.action]; + if (!operation) return false; + operation().then(() => sendResponse({ success: true })).catch(error => + sendResponse({ success: false, error: error.message }) + ); + return true; +}); + +init().catch(error => console.error('Failed to initialize Chrome proxy support:', error)); diff --git a/background-service-worker.js b/background-service-worker.js new file mode 100644 index 0000000..12ed09c --- /dev/null +++ b/background-service-worker.js @@ -0,0 +1 @@ +importScripts('browser-api.js', 'config-manager.js', 'background-chrome.js'); diff --git a/browser-api.js b/browser-api.js new file mode 100644 index 0000000..b6990ec --- /dev/null +++ b/browser-api.js @@ -0,0 +1,31 @@ +// Small Promise-based WebExtension API adapter for Chromium browsers. +// Firefox already provides the browser namespace, so this is a no-op there. +(function () { + if (globalThis.browser || !globalThis.chrome) return; + + const wrap = (object, method) => (...args) => new Promise((resolve, reject) => { + object[method](...args, result => { + const error = globalThis.chrome.runtime.lastError; + if (error) reject(new Error(error.message)); + else resolve(result); + }); + }); + + globalThis.browser = { + runtime: { + sendMessage: wrap(chrome.runtime, 'sendMessage'), + openOptionsPage: wrap(chrome.runtime, 'openOptionsPage'), + onMessage: chrome.runtime.onMessage + }, + tabs: { + query: wrap(chrome.tabs, 'query'), + reload: wrap(chrome.tabs, 'reload') + }, + storage: { + local: { + get: wrap(chrome.storage.local, 'get'), + set: wrap(chrome.storage.local, 'set') + } + } + }; +})(); diff --git a/config-manager.js b/config-manager.js index 0c22717..c0af545 100644 --- a/config-manager.js +++ b/config-manager.js @@ -140,7 +140,7 @@ class ConfigManager { // Create global instance of configuration manager const configManager = new ConfigManager(); -// Create global variable for use in other scripts -window.ConfigManager = ConfigManager; -window.configManager = configManager; -window.DEFAULT_CONFIG = DEFAULT_CONFIG; +// Expose globals in extension pages and service workers. +globalThis.ConfigManager = ConfigManager; +globalThis.configManager = configManager; +globalThis.DEFAULT_CONFIG = DEFAULT_CONFIG; diff --git a/manifest.chrome.json b/manifest.chrome.json new file mode 100644 index 0000000..77206db --- /dev/null +++ b/manifest.chrome.json @@ -0,0 +1,36 @@ +{ + "manifest_version": 3, + "name": "Proxy Switcher Fork By WT", + "version": "1.0.1", + "description": "Selectively apply proxy servers to specific domains.", + "minimum_chrome_version": "99", + "icons": { + "16": "icons/proxy-switcher-16.png", + "32": "icons/proxy-switcher-32.png", + "48": "icons/proxy-switcher-48.png", + "64": "icons/proxy-switcher-64.png", + "96": "icons/proxy-switcher-96.png", + "128": "icons/proxy-switcher-128.png", + "512": "icons/proxy-switcher-512.png" + }, + "permissions": [ + "proxy", + "storage", + "tabs", + "activeTab", + "webRequest", + "webRequestAuthProvider" + ], + "host_permissions": [""], + "background": { + "service_worker": "background-service-worker.js" + }, + "action": { + "default_popup": "popup.html", + "default_icon": "icons/proxy-switcher-16.png" + }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + } +} diff --git a/manifest.json b/manifest.json index a97e45d..04d64a9 100644 --- a/manifest.json +++ b/manifest.json @@ -36,13 +36,10 @@ "browser_specific_settings": { "gecko": { "id": "proxy-switcher-for-by-wt@9c0b2d3d", - "strict_min_version": "91.1.0", - "data_collection_permissions": { - "required": ["none"] - } + "strict_min_version": "91.1.0" }, "gecko_android": { - "strict_min_version": "91.1.0" + "strict_min_version": "113.0" } } } diff --git a/options.html b/options.html index d824624..f7dfa8c 100644 --- a/options.html +++ b/options.html @@ -243,6 +243,7 @@ + diff --git a/popup.html b/popup.html index adb86a7..e4ad880 100644 --- a/popup.html +++ b/popup.html @@ -92,6 +92,7 @@ + diff --git a/scripts/build-chrome.py b/scripts/build-chrome.py new file mode 100644 index 0000000..fa645a7 --- /dev/null +++ b/scripts/build-chrome.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Build a Chrome/Chromium Manifest V3 zip without changing the Firefox tree.""" +from pathlib import Path +import json +import shutil +import tempfile +import zipfile + +ROOT = Path(__file__).resolve().parent.parent +OUTPUT = ROOT / "web-ext-artifacts" / "proxyswitcherforkbywt-chrome.zip" +FILES = [ + "background-chrome.js", + "background-service-worker.js", + "browser-api.js", + "config-manager.js", + "options.css", + "options.html", + "options.js", + "popup.css", + "popup.html", + "popup.js", +] + +def main(): + OUTPUT.parent.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory() as temp: + staging = Path(temp) + manifest = json.loads((ROOT / "manifest.chrome.json").read_text()) + (staging / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + for name in FILES: + shutil.copy2(ROOT / name, staging / name) + shutil.copytree(ROOT / "icons", staging / "icons") + if OUTPUT.exists(): OUTPUT.unlink() + with zipfile.ZipFile(OUTPUT, "w", zipfile.ZIP_DEFLATED) as archive: + for path in sorted(staging.rglob("*")): + if path.is_file(): archive.write(path, path.relative_to(staging)) + print(OUTPUT) + +if __name__ == "__main__": + main()