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
This commit is contained in:
wt committed 2026-10-09 16:17:33 +07:00
1 parent 11508e6740
commit d0c5fc5679
12 files changed
+256 -14

No files matched your search

+15 -2
View File
@@ -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.
+3
View File
@@ -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
+20 -3
View File
@@ -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.
- `<all_urls>` — 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
+102
View File
@@ -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: ['<all_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));
+1
View File
@@ -0,0 +1 @@
importScripts('browser-api.js', 'config-manager.js', 'background-chrome.js');
+31
View File
@@ -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')
}
}
};
})();
+4 -4
View File
@@ -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;
+36
View File
@@ -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": ["<all_urls>"],
"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
}
}
+2 -5
View File
@@ -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"
}
}
}
+1
View File
@@ -243,6 +243,7 @@
</footer>
</div>
<script src="browser-api.js"></script>
<script src="options.js"></script>
</body>
</html>
+1
View File
@@ -92,6 +92,7 @@
</footer>
</div>
<script src="browser-api.js"></script>
<script src="popup.js"></script>
</body>
</html>
+40
View File
@@ -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()