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:
1 parent
11508e6740
commit
d0c5fc5679
12 files changed
+256
-14
No files matched your search
@@ -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,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
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
@@ -0,0 +1 @@
|
||||
importScripts('browser-api.js', 'config-manager.js', 'background-chrome.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')
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
+4
-4
@@ -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;
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,7 @@
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="browser-api.js"></script>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -92,6 +92,7 @@
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="browser-api.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user