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 ## 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.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. - `config-manager.js` — local configuration storage and update API.
- `popup.html`, `popup.js`, `popup.css` — current-domain quick controls. - `popup.html`, `popup.js`, `popup.css` — current-domain quick controls.
- `options.html`, `options.js`, `options.css` — profiles, domains, management actions, and Advanced Settings. - `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 ## Development rules
- Keep all configuration local. Do not add telemetry, remote requests, analytics, or external script dependencies. - 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. - 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`. - 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. - 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. 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: build:
web-ext build --overwrite-dest --filename proxyswitcherforkbywt.xpi web-ext build --overwrite-dest --filename proxyswitcherforkbywt.xpi
build-chrome:
python scripts/build-chrome.py
clean: clean:
rm -rvf web-ext-artifacts 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. - Choose a **System**, **Light**, or **Dark** theme in Advanced Settings.
- Use the same theme in the options page and popup. - Use the same theme in the options page and popup.
- Run on Firefox desktop and Firefox Android versions that support the declared minimum version. - 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 ## 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. 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. - `webRequest` and `webRequestBlocking` — provide proxy authentication credentials.
- `<all_urls>` — apply the domain routing policy to web requests. - `<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 ## 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. 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 ## 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. 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: To remove generated packages:
```sh ```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 // Create global instance of configuration manager
const configManager = new ConfigManager(); const configManager = new ConfigManager();
// Create global variable for use in other scripts // Expose globals in extension pages and service workers.
window.ConfigManager = ConfigManager; globalThis.ConfigManager = ConfigManager;
window.configManager = configManager; globalThis.configManager = configManager;
window.DEFAULT_CONFIG = DEFAULT_CONFIG; 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": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "proxy-switcher-for-by-wt@9c0b2d3d", "id": "proxy-switcher-for-by-wt@9c0b2d3d",
"strict_min_version": "91.1.0", "strict_min_version": "91.1.0"
"data_collection_permissions": {
"required": ["none"]
}
}, },
"gecko_android": { "gecko_android": {
"strict_min_version": "91.1.0" "strict_min_version": "113.0"
} }
} }
} }
+1
View File
@@ -243,6 +243,7 @@
</footer> </footer>
</div> </div>
<script src="browser-api.js"></script>
<script src="options.js"></script> <script src="options.js"></script>
</body> </body>
</html> </html>
+1
View File
@@ -92,6 +92,7 @@
</footer> </footer>
</div> </div>
<script src="browser-api.js"></script>
<script src="popup.js"></script> <script src="popup.js"></script>
</body> </body>
</html> </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()